740 lines
32 KiB
JavaScript
740 lines
32 KiB
JavaScript
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
const database = require('../config/database');
|
|
|
|
const TASK_TABLE = 'desktop_execution_tasks';
|
|
const EVENT_TABLE = 'desktop_execution_task_events';
|
|
const TASK_TYPES = new Set([
|
|
'auto_listing',
|
|
'presale',
|
|
'review',
|
|
'campaign',
|
|
'promotion',
|
|
'delisting',
|
|
'product_test',
|
|
]);
|
|
const ACTIVE_STATUSES = new Set(['queued', 'claimed', 'running']);
|
|
const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'cancelled']);
|
|
const DEFAULT_LEASE_SECONDS = 15 * 60;
|
|
const MAX_LEASE_SECONDS = 60 * 60;
|
|
let schemaReady = false;
|
|
let schemaPromise = null;
|
|
|
|
class DesktopExecutionTaskError extends Error {
|
|
constructor(message, status = 400, code = 'DESKTOP_TASK_ERROR') {
|
|
super(message);
|
|
this.name = 'DesktopExecutionTaskError';
|
|
this.status = status;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
function normalizeText(value, maxLength = 255) {
|
|
return String(value || '').trim().slice(0, maxLength);
|
|
}
|
|
|
|
function normalizePositiveInteger(value, fallback = 0) {
|
|
const parsed = Number.parseInt(value, 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function normalizeTaskType(value) {
|
|
const type = normalizeText(value, 40).toLowerCase().replace(/-/g, '_');
|
|
if (!TASK_TYPES.has(type)) {
|
|
throw new DesktopExecutionTaskError('不支持的桌面任务类型', 400, 'DESKTOP_TASK_TYPE_INVALID');
|
|
}
|
|
return type;
|
|
}
|
|
|
|
function normalizeTaskStatus(value) {
|
|
const status = normalizeText(value, 24).toLowerCase();
|
|
return [...ACTIVE_STATUSES, ...TERMINAL_STATUSES].includes(status) ? status : '';
|
|
}
|
|
|
|
function normalizeJsonObject(value, fieldName = '任务参数') {
|
|
if (value == null || value === '') return {};
|
|
if (typeof value === 'string') {
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
|
} catch {
|
|
// Use the validation error below.
|
|
}
|
|
throw new DesktopExecutionTaskError(`${fieldName}格式无效`, 400, 'DESKTOP_TASK_PAYLOAD_INVALID');
|
|
}
|
|
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new DesktopExecutionTaskError(`${fieldName}必须是对象`, 400, 'DESKTOP_TASK_PAYLOAD_INVALID');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parseStoredJson(value) {
|
|
if (!value) return {};
|
|
if (typeof value === 'object') return value;
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function toMysqlDateTime(value) {
|
|
if (!value) return null;
|
|
const date = value instanceof Date ? value : new Date(value);
|
|
if (Number.isNaN(date.getTime())) return null;
|
|
return date.toISOString().slice(0, 19).replace('T', ' ');
|
|
}
|
|
|
|
function buildTaskCode(now = new Date(), randomBytes = crypto.randomBytes) {
|
|
const stamp = now.toISOString().replace(/[-:TZ.]/g, '').slice(0, 14);
|
|
return `DT${stamp}${randomBytes(4).toString('hex').toUpperCase()}`;
|
|
}
|
|
|
|
function hashClaimToken(value) {
|
|
return crypto.createHash('sha256').update(String(value || ''), 'utf8').digest('hex');
|
|
}
|
|
|
|
function mapTask(row = {}) {
|
|
return {
|
|
id: Number(row.id || 0),
|
|
taskCode: row.task_code || row.taskCode || '',
|
|
taskType: row.task_type || row.taskType || '',
|
|
title: row.title || '',
|
|
assignedUserId: Number(row.assigned_user_id || row.assignedUserId || 0),
|
|
assignedUserName: row.assigned_user_name || row.assignedUserName || '',
|
|
assignedUsername: row.assigned_username || row.assignedUsername || '',
|
|
targetShopId: row.target_shop_id || row.targetShopId || '',
|
|
targetShopName: row.target_shop_name || row.targetShopName || '',
|
|
sourceType: row.source_type || row.sourceType || '',
|
|
sourceId: row.source_id || row.sourceId || '',
|
|
sourceCode: row.source_code || row.sourceCode || '',
|
|
status: row.status || 'queued',
|
|
priority: Number(row.priority || 0),
|
|
scheduledAt: row.scheduled_at || row.scheduledAt || null,
|
|
expiresAt: row.expires_at || row.expiresAt || null,
|
|
input: parseStoredJson(row.input_json ?? row.input),
|
|
ruleSnapshot: parseStoredJson(row.rule_snapshot_json ?? row.ruleSnapshot),
|
|
progress: {
|
|
stage: row.progress_stage || row.progressStage || '',
|
|
current: Number(row.progress_current || row.progressCurrent || 0),
|
|
total: Number(row.progress_total || row.progressTotal || 0),
|
|
percent: Number(row.progress_percent || row.progressPercent || 0),
|
|
message: row.progress_message || row.progressMessage || '',
|
|
},
|
|
claimedClientId: row.claimed_client_id || row.claimedClientId || '',
|
|
claimExpiresAt: row.claim_expires_at || row.claimExpiresAt || null,
|
|
attemptCount: Number(row.attempt_count || row.attemptCount || 0),
|
|
result: parseStoredJson(row.result_json ?? row.result),
|
|
errorMessage: row.error_message || row.errorMessage || '',
|
|
createdByUserId: Number(row.created_by_user_id || row.createdByUserId || 0),
|
|
createdByName: row.created_by_name || row.createdByName || '',
|
|
startedAt: row.started_at || row.startedAt || null,
|
|
completedAt: row.completed_at || row.completedAt || null,
|
|
cancelledAt: row.cancelled_at || row.cancelledAt || null,
|
|
createdAt: row.created_at || row.createdAt || null,
|
|
updatedAt: row.updated_at || row.updatedAt || null,
|
|
};
|
|
}
|
|
|
|
async function ensureDesktopExecutionTaskSchema(db = database.local) {
|
|
if (db === database.local && schemaReady) return true;
|
|
if (db === database.local && schemaPromise) return schemaPromise;
|
|
|
|
const ensure = async () => {
|
|
await db.query(`
|
|
CREATE TABLE IF NOT EXISTS ${TASK_TABLE} (
|
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
|
task_code VARCHAR(40) NOT NULL,
|
|
task_type VARCHAR(40) NOT NULL,
|
|
title VARCHAR(255) NOT NULL,
|
|
assigned_user_id BIGINT NOT NULL,
|
|
assigned_user_name VARCHAR(120) NULL,
|
|
assigned_username VARCHAR(120) NULL,
|
|
target_shop_id VARCHAR(160) NULL,
|
|
target_shop_name VARCHAR(255) NULL,
|
|
source_type VARCHAR(40) NULL,
|
|
source_id VARCHAR(160) NULL,
|
|
source_code VARCHAR(160) NULL,
|
|
status VARCHAR(24) NOT NULL DEFAULT 'queued',
|
|
priority INT NOT NULL DEFAULT 0,
|
|
scheduled_at DATETIME NULL,
|
|
expires_at DATETIME NULL,
|
|
input_json LONGTEXT NULL,
|
|
rule_snapshot_json LONGTEXT NULL,
|
|
progress_stage VARCHAR(80) NULL,
|
|
progress_current INT NOT NULL DEFAULT 0,
|
|
progress_total INT NOT NULL DEFAULT 0,
|
|
progress_percent DECIMAL(6,2) NOT NULL DEFAULT 0,
|
|
progress_message VARCHAR(500) NULL,
|
|
claimed_client_id VARCHAR(64) NULL,
|
|
claim_token_hash CHAR(64) NULL,
|
|
claim_expires_at DATETIME NULL,
|
|
attempt_count INT NOT NULL DEFAULT 0,
|
|
result_json LONGTEXT NULL,
|
|
error_message VARCHAR(1000) NULL,
|
|
created_by_user_id BIGINT NOT NULL,
|
|
created_by_name VARCHAR(120) NULL,
|
|
started_at DATETIME NULL,
|
|
completed_at DATETIME NULL,
|
|
cancelled_at DATETIME NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
UNIQUE KEY uk_desktop_execution_task_code (task_code),
|
|
UNIQUE KEY uk_desktop_execution_task_source (task_type, source_type, source_id),
|
|
KEY idx_desktop_execution_assignee_status (assigned_user_id, status, scheduled_at, priority),
|
|
KEY idx_desktop_execution_updated (updated_at),
|
|
KEY idx_desktop_execution_client (claimed_client_id, status)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='总控下发的桌面执行任务'
|
|
`);
|
|
for (const statement of [
|
|
`ALTER TABLE ${TASK_TABLE} ADD COLUMN source_type VARCHAR(40) NULL AFTER target_shop_name`,
|
|
`ALTER TABLE ${TASK_TABLE} ADD COLUMN source_id VARCHAR(160) NULL AFTER source_type`,
|
|
`ALTER TABLE ${TASK_TABLE} ADD COLUMN source_code VARCHAR(160) NULL AFTER source_id`,
|
|
`ALTER TABLE ${TASK_TABLE} ADD UNIQUE KEY uk_desktop_execution_task_source (task_type, source_type, source_id)`,
|
|
]) {
|
|
try {
|
|
await db.query(statement);
|
|
} catch (error) {
|
|
if (!['ER_DUP_FIELDNAME', 'ER_DUP_KEYNAME'].includes(error?.code)) throw error;
|
|
}
|
|
}
|
|
await db.query(`
|
|
CREATE TABLE IF NOT EXISTS ${EVENT_TABLE} (
|
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
|
task_id BIGINT NOT NULL,
|
|
task_code VARCHAR(40) NOT NULL,
|
|
event_type VARCHAR(40) NOT NULL,
|
|
from_status VARCHAR(24) NULL,
|
|
to_status VARCHAR(24) NULL,
|
|
actor_type VARCHAR(24) NOT NULL DEFAULT 'system',
|
|
actor_id VARCHAR(120) NULL,
|
|
actor_name VARCHAR(120) NULL,
|
|
client_id VARCHAR(64) NULL,
|
|
message VARCHAR(500) NULL,
|
|
payload_json LONGTEXT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
KEY idx_desktop_execution_event_task (task_id, created_at),
|
|
KEY idx_desktop_execution_event_code (task_code, created_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='桌面任务执行事件'
|
|
`);
|
|
if (db === database.local) schemaReady = true;
|
|
return true;
|
|
};
|
|
|
|
if (db !== database.local) return ensure();
|
|
schemaPromise = ensure().finally(() => { schemaPromise = null; });
|
|
return schemaPromise;
|
|
}
|
|
|
|
async function recordEvent(db, task, input = {}) {
|
|
await db.insert(
|
|
`INSERT INTO ${EVENT_TABLE} (
|
|
task_id, task_code, event_type, from_status, to_status, actor_type,
|
|
actor_id, actor_name, client_id, message, payload_json, created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())`,
|
|
[
|
|
task.id,
|
|
task.taskCode,
|
|
normalizeText(input.eventType, 40),
|
|
normalizeTaskStatus(input.fromStatus) || null,
|
|
normalizeTaskStatus(input.toStatus) || null,
|
|
normalizeText(input.actorType || 'system', 24),
|
|
normalizeText(input.actorId, 120) || null,
|
|
normalizeText(input.actorName, 120) || null,
|
|
normalizeText(input.clientId, 64) || null,
|
|
normalizeText(input.message, 500) || null,
|
|
input.payload ? JSON.stringify(normalizeJsonObject(input.payload, '事件参数')) : null,
|
|
]
|
|
);
|
|
}
|
|
|
|
async function getTaskById(taskId, options = {}) {
|
|
const db = options.db || database.local;
|
|
await ensureDesktopExecutionTaskSchema(db);
|
|
const rows = await db.query(`SELECT * FROM ${TASK_TABLE} WHERE id = ? LIMIT 1`, [Number(taskId)]);
|
|
return rows[0] ? mapTask(rows[0]) : null;
|
|
}
|
|
|
|
async function createDesktopExecutionTask(input = {}, actor = {}, options = {}) {
|
|
const db = options.db || database.local;
|
|
await ensureDesktopExecutionTaskSchema(db);
|
|
const taskType = normalizeTaskType(input.taskType || input.type);
|
|
const assignedUserId = normalizePositiveInteger(input.assignedUserId || input.userId);
|
|
if (!assignedUserId) {
|
|
throw new DesktopExecutionTaskError('请选择任务执行人', 400, 'DESKTOP_TASK_ASSIGNEE_REQUIRED');
|
|
}
|
|
const payload = normalizeJsonObject(input.input || input.payload, '任务参数');
|
|
const title = normalizeText(input.title, 255) || ({
|
|
presale: '自动预售处理',
|
|
auto_listing: '自动上架',
|
|
product_test: '商品测款',
|
|
}[taskType] || '桌面执行任务');
|
|
const taskCode = buildTaskCode(options.now || new Date(), options.randomBytes || crypto.randomBytes);
|
|
const priority = Math.max(-10, Math.min(10, Number.parseInt(input.priority, 10) || 0));
|
|
const scheduledAt = toMysqlDateTime(input.scheduledAt);
|
|
const expiresAt = toMysqlDateTime(input.expiresAt);
|
|
const result = await db.insert(
|
|
`INSERT INTO ${TASK_TABLE} (
|
|
task_code, task_type, title, assigned_user_id, assigned_user_name, assigned_username,
|
|
target_shop_id, target_shop_name, source_type, source_id, source_code,
|
|
status, priority, scheduled_at, expires_at,
|
|
input_json, rule_snapshot_json, created_by_user_id, created_by_name, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`,
|
|
[
|
|
taskCode,
|
|
taskType,
|
|
title,
|
|
assignedUserId,
|
|
normalizeText(input.assignedUserName, 120) || null,
|
|
normalizeText(input.assignedUsername, 120) || null,
|
|
normalizeText(input.targetShopId, 160) || null,
|
|
normalizeText(input.targetShopName, 255) || null,
|
|
normalizeText(input.sourceType, 40) || null,
|
|
normalizeText(input.sourceId, 160) || null,
|
|
normalizeText(input.sourceCode, 160) || null,
|
|
priority,
|
|
scheduledAt,
|
|
expiresAt,
|
|
JSON.stringify(payload),
|
|
JSON.stringify(normalizeJsonObject(input.ruleSnapshot, '规则快照')),
|
|
normalizePositiveInteger(actor.userId || actor.id),
|
|
normalizeText(actor.name || actor.username, 120) || null,
|
|
]
|
|
);
|
|
const task = await getTaskById(result.insertId, { db });
|
|
await recordEvent(db, task, {
|
|
eventType: 'created',
|
|
toStatus: 'queued',
|
|
actorType: 'control_center',
|
|
actorId: actor.userId || actor.id,
|
|
actorName: actor.name || actor.username,
|
|
message: '总控已下发任务',
|
|
});
|
|
return task;
|
|
}
|
|
|
|
function buildAutoListingDesktopTaskInputs(plan = {}, actor = {}) {
|
|
const listingShopSnapshot = plan.listingShopSnapshot && typeof plan.listingShopSnapshot === 'object'
|
|
? plan.listingShopSnapshot
|
|
: {};
|
|
const assignedUserId = normalizePositiveInteger(
|
|
listingShopSnapshot.executorUserId || actor.userId || actor.id
|
|
);
|
|
const executorClientId = normalizeText(
|
|
listingShopSnapshot.executorClientId || listingShopSnapshot.clientId,
|
|
64
|
|
).toLowerCase();
|
|
const targetShopId = normalizeText(plan.listingShopId || plan.listingShop?.shopId, 160);
|
|
const targetShopName = normalizeText(plan.listingShopName || plan.listingShop?.shopName, 255);
|
|
if (!assignedUserId || !targetShopId) return [];
|
|
return (Array.isArray(plan.listingTasks) ? plan.listingTasks : [])
|
|
.filter((item) => (
|
|
item?.id
|
|
&& item?.selectedSpuCode
|
|
&& !item?.listedItemId
|
|
&& ['pending', 'failed', 'claimed'].includes(item?.listingStatus || 'pending')
|
|
))
|
|
.map((item) => ({
|
|
taskType: 'auto_listing',
|
|
title: `自动上架 · ${normalizeText(item.selectedProductTitle || item.selectedSpuName || item.taskCode, 220)}`,
|
|
assignedUserId,
|
|
assignedUserName: normalizeText(
|
|
listingShopSnapshot.executorUserName || actor.name || plan.dingtalkUserName,
|
|
120
|
|
),
|
|
assignedUsername: normalizeText(
|
|
listingShopSnapshot.executorUsername || actor.username || plan.dingtalkUsername,
|
|
120
|
|
),
|
|
targetShopId,
|
|
targetShopName,
|
|
sourceType: 'selection_fission_item',
|
|
sourceId: String(item.id),
|
|
sourceCode: normalizeText(item.taskCode || item.id, 160),
|
|
input: {
|
|
sourceTaskId: Number(item.id),
|
|
sourceTaskCode: normalizeText(item.taskCode || item.id, 160),
|
|
planId: Number(plan.id || 0),
|
|
planCode: normalizeText(plan.schemeCode, 160),
|
|
spuCode: normalizeText(item.selectedSpuCode, 160),
|
|
productTitle: normalizeText(item.selectedProductTitle || item.selectedSpuName, 255),
|
|
listingShopId: targetShopId,
|
|
listingShopName: targetShopName,
|
|
executorClientId,
|
|
executorSessionId: normalizeText(listingShopSnapshot.sessionId, 64),
|
|
routingMode: normalizeText(listingShopSnapshot.routingMode || 'self', 20),
|
|
requestedByUserId: normalizePositiveInteger(actor.userId || actor.id),
|
|
requestedByUserName: normalizeText(actor.name || actor.username, 120),
|
|
},
|
|
ruleSnapshot: {
|
|
executor: 'selection-fission-auto-listing-v1',
|
|
authoritativeClaim: 'selection-fission',
|
|
requireOnlineClient: true,
|
|
requireAuthenticatedShop: true,
|
|
executorClientId,
|
|
},
|
|
}));
|
|
}
|
|
|
|
async function upsertDesktopExecutionTaskFromSource(input = {}, actor = {}, options = {}) {
|
|
const db = options.db || database.local;
|
|
await ensureDesktopExecutionTaskSchema(db);
|
|
const taskType = normalizeTaskType(input.taskType || input.type);
|
|
const sourceType = normalizeText(input.sourceType, 40);
|
|
const sourceId = normalizeText(input.sourceId, 160);
|
|
if (!sourceType || !sourceId) {
|
|
throw new DesktopExecutionTaskError('来源任务身份不完整', 400, 'DESKTOP_TASK_SOURCE_REQUIRED');
|
|
}
|
|
const findExisting = async () => {
|
|
const rows = await db.query(
|
|
`SELECT * FROM ${TASK_TABLE} WHERE task_type = ? AND source_type = ? AND source_id = ? LIMIT 1`,
|
|
[taskType, sourceType, sourceId]
|
|
);
|
|
return rows[0] ? mapTask(rows[0]) : null;
|
|
};
|
|
let existing = await findExisting();
|
|
if (!existing) {
|
|
try {
|
|
return await createDesktopExecutionTask({ ...input, taskType, sourceType, sourceId }, actor, { ...options, db });
|
|
} catch (error) {
|
|
if (error?.code !== 'ER_DUP_ENTRY') throw error;
|
|
existing = await findExisting();
|
|
}
|
|
}
|
|
if (!existing) throw new DesktopExecutionTaskError('来源任务同步失败', 500, 'DESKTOP_TASK_SOURCE_SYNC_FAILED');
|
|
|
|
const reset = TERMINAL_STATUSES.has(existing.status);
|
|
const updateAssignment = existing.status === 'queued' || reset;
|
|
await db.update(
|
|
`UPDATE ${TASK_TABLE}
|
|
SET title = ?, source_code = ?, input_json = ?, rule_snapshot_json = ?,
|
|
${updateAssignment ? 'assigned_user_id = ?, assigned_user_name = ?, assigned_username = ?, target_shop_id = ?, target_shop_name = ?,' : ''}
|
|
${reset ? `status = 'queued', progress_stage = '', progress_current = 0, progress_total = 0,
|
|
progress_percent = 0, progress_message = '等待桌面客户端领取', claimed_client_id = NULL,
|
|
claim_token_hash = NULL, claim_expires_at = NULL, result_json = NULL, error_message = NULL,
|
|
started_at = NULL, completed_at = NULL, cancelled_at = NULL,` : ''}
|
|
updated_at = NOW()
|
|
WHERE id = ?`,
|
|
[
|
|
normalizeText(input.title, 255) || existing.title,
|
|
normalizeText(input.sourceCode, 160) || null,
|
|
JSON.stringify(normalizeJsonObject(input.input || input.payload, '任务参数')),
|
|
JSON.stringify(normalizeJsonObject(input.ruleSnapshot, '规则快照')),
|
|
...(updateAssignment ? [
|
|
normalizePositiveInteger(input.assignedUserId || input.userId),
|
|
normalizeText(input.assignedUserName, 120) || null,
|
|
normalizeText(input.assignedUsername, 120) || null,
|
|
normalizeText(input.targetShopId, 160) || null,
|
|
normalizeText(input.targetShopName, 255) || null,
|
|
] : []),
|
|
existing.id,
|
|
]
|
|
);
|
|
const updated = await getTaskById(existing.id, { db });
|
|
if (reset) {
|
|
await recordEvent(db, updated, {
|
|
eventType: 'source_requeued', fromStatus: existing.status, toStatus: 'queued', actorType: 'control_center',
|
|
actorId: actor.userId || actor.id, actorName: actor.name || actor.username,
|
|
message: '来源上架任务重新进入执行队列',
|
|
});
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
async function syncAutoListingDesktopTasks(plan = {}, actor = {}, options = {}) {
|
|
const inputs = buildAutoListingDesktopTaskInputs(plan, actor);
|
|
const tasks = [];
|
|
for (const input of inputs) {
|
|
tasks.push(await upsertDesktopExecutionTaskFromSource(input, actor, options));
|
|
}
|
|
return tasks;
|
|
}
|
|
|
|
function buildListWhere(filters = {}, actor = {}) {
|
|
const clauses = [];
|
|
const params = [];
|
|
if (actor.role !== 'admin') {
|
|
clauses.push('(assigned_user_id = ? OR created_by_user_id = ?)');
|
|
params.push(normalizePositiveInteger(actor.userId || actor.id), normalizePositiveInteger(actor.userId || actor.id));
|
|
} else if (normalizePositiveInteger(filters.assignedUserId)) {
|
|
clauses.push('assigned_user_id = ?');
|
|
params.push(normalizePositiveInteger(filters.assignedUserId));
|
|
}
|
|
const status = normalizeTaskStatus(filters.status);
|
|
if (status) {
|
|
clauses.push('status = ?');
|
|
params.push(status);
|
|
}
|
|
if (filters.taskType) {
|
|
clauses.push('task_type = ?');
|
|
params.push(normalizeTaskType(filters.taskType));
|
|
}
|
|
return { sql: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '', params };
|
|
}
|
|
|
|
async function listDesktopExecutionTasks(filters = {}, actor = {}, options = {}) {
|
|
const db = options.db || database.local;
|
|
await ensureDesktopExecutionTaskSchema(db);
|
|
const page = Math.max(1, Number.parseInt(filters.page, 10) || 1);
|
|
const pageSize = Math.max(1, Math.min(100, Number.parseInt(filters.pageSize, 10) || 30));
|
|
const where = buildListWhere(filters, actor);
|
|
const totalRows = await db.query(`SELECT COUNT(*) AS total FROM ${TASK_TABLE} ${where.sql}`, where.params);
|
|
const rows = await db.query(
|
|
`SELECT * FROM ${TASK_TABLE} ${where.sql}
|
|
ORDER BY FIELD(status, 'running', 'claimed', 'queued', 'failed', 'succeeded', 'cancelled'),
|
|
priority DESC, created_at DESC
|
|
LIMIT ? OFFSET ?`,
|
|
[...where.params, pageSize, (page - 1) * pageSize]
|
|
);
|
|
return { rows: rows.map(mapTask), total: Number(totalRows[0]?.total || 0), page, pageSize };
|
|
}
|
|
|
|
async function getDesktopExecutionSummary(filters = {}, actor = {}, options = {}) {
|
|
const db = options.db || database.local;
|
|
await ensureDesktopExecutionTaskSchema(db);
|
|
const where = buildListWhere(filters, actor);
|
|
const rows = await db.query(
|
|
`SELECT status, COUNT(*) AS total FROM ${TASK_TABLE} ${where.sql} GROUP BY status`,
|
|
where.params
|
|
);
|
|
const counts = { queued: 0, claimed: 0, running: 0, succeeded: 0, failed: 0, cancelled: 0 };
|
|
rows.forEach((row) => { if (Object.hasOwn(counts, row.status)) counts[row.status] = Number(row.total || 0); });
|
|
return {
|
|
counts,
|
|
active: counts.queued + counts.claimed + counts.running,
|
|
completed: counts.succeeded + counts.failed + counts.cancelled,
|
|
};
|
|
}
|
|
|
|
async function releaseExpiredClaims(db) {
|
|
await db.update(
|
|
`UPDATE ${TASK_TABLE}
|
|
SET status = 'queued', claimed_client_id = NULL, claim_token_hash = NULL,
|
|
claim_expires_at = NULL, progress_message = '上次领取已超时,等待重新领取', updated_at = NOW()
|
|
WHERE status IN ('claimed', 'running') AND claim_expires_at IS NOT NULL AND claim_expires_at < NOW()`,
|
|
[]
|
|
);
|
|
}
|
|
|
|
async function claimDesktopExecutionTask(input = {}, actor = {}, options = {}) {
|
|
const db = options.db || database.local;
|
|
await ensureDesktopExecutionTaskSchema(db);
|
|
const assignedUserId = normalizePositiveInteger(actor.userId || actor.id);
|
|
const clientId = normalizeText(input.clientId, 64).toLowerCase();
|
|
if (!assignedUserId || !clientId) {
|
|
throw new DesktopExecutionTaskError('桌面任务领取身份不完整', 400, 'DESKTOP_TASK_CLAIM_IDENTITY_INVALID');
|
|
}
|
|
const requestedTypes = (Array.isArray(input.taskTypes) ? input.taskTypes : [])
|
|
.map(normalizeTaskType)
|
|
.filter((value, index, values) => values.indexOf(value) === index);
|
|
if (!requestedTypes.length) return { claimed: false, task: null };
|
|
await releaseExpiredClaims(db);
|
|
const placeholders = requestedTypes.map(() => '?').join(', ');
|
|
const candidates = await db.query(
|
|
`SELECT id FROM ${TASK_TABLE}
|
|
WHERE assigned_user_id = ? AND status = 'queued' AND task_type IN (${placeholders})
|
|
AND (
|
|
NULLIF(JSON_UNQUOTE(JSON_EXTRACT(input_json, '$.executorClientId')), '') IS NULL
|
|
OR LOWER(JSON_UNQUOTE(JSON_EXTRACT(input_json, '$.executorClientId'))) = ?
|
|
)
|
|
AND (scheduled_at IS NULL OR scheduled_at <= NOW())
|
|
AND (expires_at IS NULL OR expires_at > NOW())
|
|
ORDER BY priority DESC, created_at ASC LIMIT 5`,
|
|
[assignedUserId, ...requestedTypes, clientId]
|
|
);
|
|
const leaseSeconds = Math.max(60, Math.min(MAX_LEASE_SECONDS, normalizePositiveInteger(input.leaseSeconds, DEFAULT_LEASE_SECONDS)));
|
|
for (const candidate of candidates) {
|
|
const claimToken = crypto.randomBytes(32).toString('base64url');
|
|
const affected = await db.update(
|
|
`UPDATE ${TASK_TABLE}
|
|
SET status = 'claimed', claimed_client_id = ?, claim_token_hash = ?,
|
|
claim_expires_at = DATE_ADD(NOW(), INTERVAL ? SECOND), attempt_count = attempt_count + 1,
|
|
progress_stage = 'claimed', progress_message = '桌面客户端已领取', updated_at = NOW()
|
|
WHERE id = ? AND status = 'queued'`,
|
|
[clientId, hashClaimToken(claimToken), leaseSeconds, Number(candidate.id)]
|
|
);
|
|
if (!affected) continue;
|
|
const task = await getTaskById(candidate.id, { db });
|
|
await recordEvent(db, task, {
|
|
eventType: 'claimed', fromStatus: 'queued', toStatus: 'claimed', actorType: 'desktop',
|
|
actorId: assignedUserId, actorName: actor.name, clientId, message: '桌面客户端领取任务',
|
|
});
|
|
return { claimed: true, claimToken, leaseSeconds, task };
|
|
}
|
|
return { claimed: false, task: null };
|
|
}
|
|
|
|
async function assertClaimedTask(taskId, input = {}, actor = {}, options = {}) {
|
|
const db = options.db || database.local;
|
|
const task = await getTaskById(taskId, { db });
|
|
if (!task) throw new DesktopExecutionTaskError('桌面任务不存在', 404, 'DESKTOP_TASK_NOT_FOUND');
|
|
const userId = normalizePositiveInteger(actor.userId || actor.id);
|
|
const clientId = normalizeText(input.clientId, 64).toLowerCase();
|
|
if (task.assignedUserId !== userId || task.claimedClientId !== clientId) {
|
|
throw new DesktopExecutionTaskError('任务不属于当前桌面会话', 403, 'DESKTOP_TASK_CLAIM_OWNER_MISMATCH');
|
|
}
|
|
const rows = await db.query(`SELECT claim_token_hash FROM ${TASK_TABLE} WHERE id = ? LIMIT 1`, [task.id]);
|
|
if (!input.claimToken || rows[0]?.claim_token_hash !== hashClaimToken(input.claimToken)) {
|
|
throw new DesktopExecutionTaskError('任务领取凭证无效', 409, 'DESKTOP_TASK_CLAIM_TOKEN_INVALID');
|
|
}
|
|
if (!ACTIVE_STATUSES.has(task.status)) {
|
|
throw new DesktopExecutionTaskError('任务已结束,不能继续回传', 409, 'DESKTOP_TASK_ALREADY_FINISHED');
|
|
}
|
|
return { db, task, clientId, userId };
|
|
}
|
|
|
|
function normalizeProgress(input = {}) {
|
|
const total = Math.max(0, Number.parseInt(input.total, 10) || 0);
|
|
const current = Math.max(0, Number.parseInt(input.current, 10) || 0);
|
|
const derived = total > 0 ? (Math.min(current, total) / total) * 100 : Number(input.percent || 0);
|
|
return {
|
|
stage: normalizeText(input.stage || 'running', 80),
|
|
current,
|
|
total,
|
|
percent: Math.max(0, Math.min(100, Number.isFinite(derived) ? derived : 0)),
|
|
message: normalizeText(input.message, 500),
|
|
};
|
|
}
|
|
|
|
async function updateDesktopExecutionProgress(taskId, input = {}, actor = {}, options = {}) {
|
|
const context = await assertClaimedTask(taskId, input, actor, options);
|
|
const progress = normalizeProgress(input);
|
|
await context.db.update(
|
|
`UPDATE ${TASK_TABLE}
|
|
SET status = 'running', progress_stage = ?, progress_current = ?, progress_total = ?,
|
|
progress_percent = ?, progress_message = ?, started_at = COALESCE(started_at, NOW()),
|
|
claim_expires_at = GREATEST(
|
|
COALESCE(claim_expires_at, NOW()),
|
|
DATE_ADD(NOW(), INTERVAL ? SECOND)
|
|
), updated_at = NOW()
|
|
WHERE id = ?`,
|
|
[progress.stage, progress.current, progress.total, progress.percent, progress.message || null,
|
|
DEFAULT_LEASE_SECONDS, context.task.id]
|
|
);
|
|
return getTaskById(context.task.id, { db: context.db });
|
|
}
|
|
|
|
async function finishDesktopExecutionTask(taskId, input = {}, actor = {}, outcome, options = {}) {
|
|
const context = await assertClaimedTask(taskId, input, actor, options);
|
|
const succeeded = outcome === 'succeeded';
|
|
const toStatus = succeeded ? 'succeeded' : 'failed';
|
|
const message = normalizeText(input.message || input.errorMessage, 1000);
|
|
await context.db.update(
|
|
`UPDATE ${TASK_TABLE}
|
|
SET status = ?, progress_stage = ?, progress_percent = ?, progress_message = ?,
|
|
result_json = ?, error_message = ?, completed_at = NOW(), claim_expires_at = NULL, updated_at = NOW()
|
|
WHERE id = ?`,
|
|
[
|
|
toStatus,
|
|
succeeded ? 'completed' : 'failed',
|
|
succeeded ? 100 : context.task.progress.percent,
|
|
message || (succeeded ? '执行完成' : '执行失败'),
|
|
JSON.stringify(normalizeJsonObject(input.result, '执行结果')),
|
|
succeeded ? null : (message || '桌面任务执行失败'),
|
|
context.task.id,
|
|
]
|
|
);
|
|
const task = await getTaskById(context.task.id, { db: context.db });
|
|
await recordEvent(context.db, task, {
|
|
eventType: toStatus, fromStatus: context.task.status, toStatus, actorType: 'desktop',
|
|
actorId: context.userId, actorName: actor.name, clientId: context.clientId,
|
|
message: message || (succeeded ? '桌面任务执行完成' : '桌面任务执行失败'),
|
|
});
|
|
return task;
|
|
}
|
|
|
|
async function completeDesktopExecutionTask(taskId, input, actor, options) {
|
|
return finishDesktopExecutionTask(taskId, input, actor, 'succeeded', options);
|
|
}
|
|
|
|
async function failDesktopExecutionTask(taskId, input, actor, options) {
|
|
return finishDesktopExecutionTask(taskId, input, actor, 'failed', options);
|
|
}
|
|
|
|
async function cancelDesktopExecutionTask(taskId, actor = {}, options = {}) {
|
|
const db = options.db || database.local;
|
|
const task = await getTaskById(taskId, { db });
|
|
if (!task) throw new DesktopExecutionTaskError('桌面任务不存在', 404, 'DESKTOP_TASK_NOT_FOUND');
|
|
if (actor.role !== 'admin' && task.createdByUserId !== normalizePositiveInteger(actor.userId || actor.id)) {
|
|
throw new DesktopExecutionTaskError('无权取消该任务', 403, 'DESKTOP_TASK_ACCESS_DENIED');
|
|
}
|
|
if (TERMINAL_STATUSES.has(task.status)) return task;
|
|
if (task.status !== 'queued') {
|
|
throw new DesktopExecutionTaskError(
|
|
'任务已被客户端领取,不能在总控端直接取消',
|
|
409,
|
|
'DESKTOP_TASK_CANCEL_NOT_ALLOWED'
|
|
);
|
|
}
|
|
await db.update(
|
|
`UPDATE ${TASK_TABLE} SET status = 'cancelled', cancelled_at = NOW(), completed_at = NOW(),
|
|
progress_stage = 'cancelled', progress_message = '总控已取消任务', claim_expires_at = NULL, updated_at = NOW()
|
|
WHERE id = ? AND status = 'queued'`,
|
|
[task.id]
|
|
);
|
|
const updated = await getTaskById(task.id, { db });
|
|
await recordEvent(db, updated, {
|
|
eventType: 'cancelled', fromStatus: task.status, toStatus: 'cancelled', actorType: 'control_center',
|
|
actorId: actor.userId || actor.id, actorName: actor.name || actor.username, message: '总控取消任务',
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
async function retryDesktopExecutionTask(taskId, actor = {}, options = {}) {
|
|
const db = options.db || database.local;
|
|
const task = await getTaskById(taskId, { db });
|
|
if (!task) throw new DesktopExecutionTaskError('桌面任务不存在', 404, 'DESKTOP_TASK_NOT_FOUND');
|
|
if (actor.role !== 'admin' && task.createdByUserId !== normalizePositiveInteger(actor.userId || actor.id)) {
|
|
throw new DesktopExecutionTaskError('无权重试该任务', 403, 'DESKTOP_TASK_ACCESS_DENIED');
|
|
}
|
|
if (!['failed', 'cancelled'].includes(task.status)) {
|
|
throw new DesktopExecutionTaskError('只有失败或已取消任务可以重试', 409, 'DESKTOP_TASK_RETRY_NOT_ALLOWED');
|
|
}
|
|
await db.update(
|
|
`UPDATE ${TASK_TABLE} SET status = 'queued', progress_stage = '', progress_current = 0,
|
|
progress_total = 0, progress_percent = 0, progress_message = '等待桌面客户端领取',
|
|
claimed_client_id = NULL, claim_token_hash = NULL, claim_expires_at = NULL,
|
|
result_json = NULL, error_message = NULL, started_at = NULL, completed_at = NULL,
|
|
cancelled_at = NULL, updated_at = NOW() WHERE id = ?`,
|
|
[task.id]
|
|
);
|
|
const updated = await getTaskById(task.id, { db });
|
|
await recordEvent(db, updated, {
|
|
eventType: 'retried', fromStatus: task.status, toStatus: 'queued', actorType: 'control_center',
|
|
actorId: actor.userId || actor.id, actorName: actor.name || actor.username, message: '总控重新下发任务',
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
function resetDesktopExecutionTaskStateForTests() {
|
|
schemaReady = false;
|
|
schemaPromise = null;
|
|
}
|
|
|
|
module.exports = {
|
|
ACTIVE_STATUSES,
|
|
DesktopExecutionTaskError,
|
|
TASK_TYPES,
|
|
buildTaskCode,
|
|
buildAutoListingDesktopTaskInputs,
|
|
cancelDesktopExecutionTask,
|
|
claimDesktopExecutionTask,
|
|
completeDesktopExecutionTask,
|
|
createDesktopExecutionTask,
|
|
ensureDesktopExecutionTaskSchema,
|
|
failDesktopExecutionTask,
|
|
getDesktopExecutionSummary,
|
|
getTaskById,
|
|
listDesktopExecutionTasks,
|
|
mapTask,
|
|
normalizeProgress,
|
|
normalizeTaskType,
|
|
resetDesktopExecutionTaskStateForTests,
|
|
retryDesktopExecutionTask,
|
|
syncAutoListingDesktopTasks,
|
|
upsertDesktopExecutionTaskFromSource,
|
|
updateDesktopExecutionProgress,
|
|
};
|