From 23ad60a1ef2ffe8e9ba287b61564e24d05e8b02a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 1 Aug 2026 19:02:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=8D=95=E5=B7=A5=E5=8D=95=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E4=BB=BB=E5=8A=A1=E6=97=B6=E9=99=90=EF=BC=8C=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E8=87=AA=E5=8A=A8=E5=88=A4=E5=AE=9A=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=B9=B6=E5=A4=84=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migrations/009_work_order_timeout.sql | 18 ++ .../src/repositories/worker-platform/types.ts | 7 + .../worker-platform/work-order-repo.ts | 199 +++++++++++++++++- .../src/services/scheduler/config-service.ts | 54 ++++- .../services/scheduler/scheduler-service.ts | 49 +++-- .../scheduler/work-order-timeout-job.ts | 24 +++ .../services/worker-platform/admin-service.ts | 17 +- .../src/services/worker-platform/mappers.ts | 5 + .../worker-platform/worker-service.ts | 73 ++++++- .../src/components/DeadlineCountdown.tsx | 34 +++ .../pages/admin/panels/ProductRulesPanel.tsx | 23 ++ .../pages/admin/panels/WorkOrdersPanel.tsx | 87 ++++++++ .../src/pages/admin/panels/shared.tsx | 6 + .../admin/platform/AdminPlatformShopsPage.tsx | 125 +++++++++-- .../src/pages/worker/WorkerOrdersPage.tsx | 6 +- .../src/services/admin/worker-platform.ts | 4 + .../admin/platform-config/scheduled-jobs.ts | 6 +- apps/frontend/src/types/worker-platform.ts | 5 + 18 files changed, 681 insertions(+), 61 deletions(-) create mode 100644 apps/backend/src/db/migrations/009_work_order_timeout.sql create mode 100644 apps/backend/src/services/scheduler/work-order-timeout-job.ts create mode 100644 apps/frontend/src/components/DeadlineCountdown.tsx diff --git a/apps/backend/src/db/migrations/009_work_order_timeout.sql b/apps/backend/src/db/migrations/009_work_order_timeout.sql new file mode 100644 index 00000000..1ba2f1da --- /dev/null +++ b/apps/backend/src/db/migrations/009_work_order_timeout.sql @@ -0,0 +1,18 @@ +ALTER TABLE work_orders + ADD COLUMN deadline_at TIMESTAMPTZ, + ADD COLUMN timeout_minutes INTEGER NOT NULL DEFAULT 0, + ADD COLUMN timeout_policy TEXT NOT NULL DEFAULT 'reopen'; + +CREATE INDEX idx_work_orders_deadline + ON work_orders(status, deadline_at) + WHERE deadline_at IS NOT NULL; + +ALTER TABLE work_product_rules + ADD COLUMN timeout_minutes INTEGER NOT NULL DEFAULT 0, + ADD COLUMN timeout_policy TEXT NOT NULL DEFAULT 'reopen'; + +COMMENT ON COLUMN work_orders.deadline_at IS '代练超时截止时间,打手抢单后按时限计算'; +COMMENT ON COLUMN work_orders.timeout_minutes IS '任务时限(分钟),0 表示不限时'; +COMMENT ON COLUMN work_orders.timeout_policy IS '超时处置策略:reopen 退押金回大厅 / cancel_release 取消退押金 / cancel_deduct 取消扣押金'; +COMMENT ON COLUMN work_product_rules.timeout_minutes IS '规则默认任务时限(分钟),0 表示不限时'; +COMMENT ON COLUMN work_product_rules.timeout_policy IS '规则默认超时处置策略'; diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 592986ef..8bb20502 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -109,6 +109,8 @@ export type WorkProductRuleRow = { sharing_enabled: boolean sharing_total_quantity: number sharing_unit_reward: number + timeout_minutes: number + timeout_policy: string requirement_json: string | Record sort_order: number created_at: string @@ -159,6 +161,9 @@ export type WorkOrderRow = { assigned_at: string | null submitted_at: string | null accepted_at: string | null + deadline_at: string | null + timeout_minutes: number + timeout_policy: string created_at: string updated_at: string category_name?: string @@ -241,6 +246,8 @@ export type CreateWorkOrderInput = { sharingEnabled?: boolean sharingTotalQuantity?: number sharingUnitReward?: number + timeoutMinutes?: number + timeoutPolicy?: string materialJson: string requirementJson: string now: string diff --git a/apps/backend/src/repositories/worker-platform/work-order-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-repo.ts index f0353603..0ca0844c 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -408,6 +408,8 @@ export async function upsertWorkProductRule(input: { sharingEnabled?: boolean sharingTotalQuantity?: number sharingUnitReward?: number + timeoutMinutes?: number + timeoutPolicy?: string requirementJson: string sortOrder: number now: string @@ -419,6 +421,7 @@ export async function upsertWorkProductRule(input: { match_type, category_id, enabled, auto_create, reward_amount, required_deposit_amount, deposit_threshold_amount, sharing_enabled, sharing_total_quantity, sharing_unit_reward, + timeout_minutes, timeout_policy, requirement_json, sort_order, created_at, updated_at ) VALUES ( @@ -426,8 +429,9 @@ export async function upsertWorkProductRule(input: { $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, - $17::jsonb, - $18, $19, $20 + $17, $18, + $19::jsonb, + $20, $21, $22 ) ON CONFLICT (rule_key) DO UPDATE SET @@ -446,6 +450,8 @@ export async function upsertWorkProductRule(input: { sharing_enabled = EXCLUDED.sharing_enabled, sharing_total_quantity = EXCLUDED.sharing_total_quantity, sharing_unit_reward = EXCLUDED.sharing_unit_reward, + timeout_minutes = EXCLUDED.timeout_minutes, + timeout_policy = EXCLUDED.timeout_policy, requirement_json = EXCLUDED.requirement_json, sort_order = EXCLUDED.sort_order, updated_at = EXCLUDED.updated_at @@ -468,6 +474,8 @@ export async function upsertWorkProductRule(input: { input.sharingEnabled === true, toPositiveInteger(input.sharingTotalQuantity, 1), toPositiveInteger(input.sharingUnitReward, 0), + toPositiveInteger(input.timeoutMinutes, 0), + String(input.timeoutPolicy || 'reopen').trim(), input.requirementJson, input.sortOrder, input.now, @@ -484,10 +492,11 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise { @@ -687,8 +700,10 @@ export async function updateWorkOrderBasic( required_deposit_amount = $5, deposit_threshold_amount = $6, requirement_json = $7::jsonb, - updated_at = $8 - WHERE id = $9 + timeout_minutes = $8, + timeout_policy = $9, + updated_at = $10 + WHERE id = $11 `, [ String((patch.platformOrderId ?? current.platform_order_id) || '').trim(), @@ -704,6 +719,8 @@ export async function updateWorkOrderBasic( 0, ), toJsonString(patch.requirementJson ?? current.requirement_json), + toPositiveInteger(patch.timeoutMinutes ?? current.timeout_minutes, 0), + String((patch.timeoutPolicy ?? current.timeout_policy) || 'reopen').trim(), patch.updatedAt, Number(workOrderId), ], @@ -727,6 +744,7 @@ export async function grabWorkOrder(input: { workerId: number depositAmount: number maxActiveOrders: number + deadlineAt: string | null now: string }): Promise { return withTransaction(async (client) => { @@ -755,13 +773,14 @@ export async function grabWorkOrder(input: { status = 'in_progress', assigned_worker_id = $1, assigned_at = $2, + deadline_at = $3, updated_at = $2 - WHERE id = $3 + WHERE id = $4 AND status = 'open' AND assigned_worker_id IS NULL RETURNING id `, - [input.workerId, input.now, input.workOrderId], + [input.workerId, input.now, input.deadlineAt, input.workOrderId], ) if (!result.rows[0]) { return { @@ -857,6 +876,7 @@ export async function cancelWorkerWorkOrder(input: { status = 'open', assigned_worker_id = NULL, assigned_at = NULL, + deadline_at = NULL, updated_at = $1 WHERE id = $2 `, @@ -1228,6 +1248,12 @@ export async function resolveProblemWorkOrder(input: { acceptance_json = CASE WHEN $3 THEN '{}'::jsonb ELSE acceptance_json END, submitted_at = CASE WHEN $3 THEN NULL ELSE submitted_at END, problem_note = CASE WHEN $2 THEN '' ELSE problem_note END, + deadline_at = CASE + WHEN $1 = 'in_progress' AND timeout_minutes > 0 + THEN NOW() + (timeout_minutes * INTERVAL '1 minute') + WHEN $1 = 'open' THEN NULL + ELSE deadline_at + END, updated_at = $4 WHERE id = $5 `, @@ -1253,6 +1279,163 @@ export async function resolveProblemWorkOrder(input: { }) } +export async function listOverdueWorkOrders({ + limit = 50, + workerId = 0, +}: { limit?: number; workerId?: number } = {}): Promise { + const params: unknown[] = [] + let workerClause = '' + if (workerId > 0) { + params.push(workerId) + workerClause = `AND wo.assigned_worker_id = $${params.length}` + } + params.push(limit) + const result = await query( + `${WORK_ORDER_SELECT} + WHERE wo.status = 'in_progress' + AND wo.deadline_at IS NOT NULL + AND wo.deadline_at < NOW() + ${workerClause} + ORDER BY wo.deadline_at ASC + LIMIT $${params.length}`, + params, + ) + return result.rows +} + +export async function settleOverdueWorkOrder(input: { + workOrderId: number + policy: 'reopen' | 'cancel_release' | 'cancel_deduct' + now: string +}): Promise<{ + order: WorkOrderRow | null + failureReason: 'work_order_not_overdue' | 'work_order_has_active_shares' | null +}> { + return withTransaction(async (client) => { + const currentResult = await client.query( + ` + SELECT * + FROM work_orders + WHERE id = $1 + AND status = 'in_progress' + AND deadline_at IS NOT NULL + AND deadline_at < NOW() + FOR UPDATE + `, + [input.workOrderId], + ) + const workOrder = currentResult.rows[0] || null + if (!workOrder) { + return { order: null, failureReason: 'work_order_not_overdue' } + } + + const shareResult = await client.query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM work_order_shares + WHERE work_order_id = $1 AND status != 'cancelled' + `, + [input.workOrderId], + ) + if (Number(shareResult.rows[0]?.total || 0) > 0) { + return { order: null, failureReason: 'work_order_has_active_shares' } + } + + const shouldReleaseDeposit = ['reopen', 'cancel_release'].includes(input.policy) + const shouldDeductDeposit = input.policy === 'cancel_deduct' + const workerId = Number(workOrder.assigned_worker_id || 0) + let resolvedDepositAmount = 0 + + if (workerId > 0 && (shouldReleaseDeposit || shouldDeductDeposit)) { + await ensureWorkerWalletWithClient(client, workerId, input.now) + const wallet = await getWorkerWalletWithClient(client, workerId) + resolvedDepositAmount = Math.min( + await getOutstandingDepositAmountWithClient(client, workerId, input.workOrderId), + Number(wallet?.frozen_deposit_amount || 0), + ) + + if (resolvedDepositAmount > 0) { + const nextAvailable = + Number(wallet?.available_amount || 0) + (shouldReleaseDeposit ? resolvedDepositAmount : 0) + const nextFrozen = Math.max( + 0, + Number(wallet?.frozen_deposit_amount || 0) - resolvedDepositAmount, + ) + const ledgerType = shouldReleaseDeposit ? 'deposit_release' : 'deposit_deduction' + const ledgerAmount = shouldReleaseDeposit ? resolvedDepositAmount : -resolvedDepositAmount + const ledgerNote = shouldReleaseDeposit ? '超时处置释放押金' : '超时处置扣除押金' + + await client.query( + ` + UPDATE worker_wallets + SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 + WHERE worker_id = $4 + `, + [nextAvailable, nextFrozen, input.now, workerId], + ) + await client.query( + ` + INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9) + `, + [ + workerId, + ledgerType, + ledgerAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + ledgerNote, + JSON.stringify({ action: `timeout_${input.policy}` }), + input.now, + ], + ) + } + } + + const targetStatus = input.policy === 'reopen' ? 'open' : 'cancelled' + + await client.query( + ` + UPDATE work_orders + SET + status = $1, + assigned_worker_id = NULL, + assigned_at = NULL, + deadline_at = NULL, + acceptance_json = CASE WHEN $1 = 'open' THEN '{}'::jsonb ELSE acceptance_json END, + submitted_at = CASE WHEN $1 = 'open' THEN NULL ELSE submitted_at END, + problem_note = CASE WHEN $1 = 'open' THEN '' ELSE problem_note END, + published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END, + updated_at = $2 + WHERE id = $3 + `, + [targetStatus, input.now, input.workOrderId], + ) + + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'system', + actorId: '', + eventType: `timeout_${input.policy}`, + fromStatus: 'in_progress', + toStatus: targetStatus, + payloadJson: JSON.stringify({ + policy: input.policy, + depositAmount: resolvedDepositAmount, + }), + now: input.now, + }) + + return { + order: await getWorkOrderByIdWithClient(client, input.workOrderId), + failureReason: null, + } + }) +} + export async function countWorkerActiveOrders(workerId: number | string): Promise { const result = await query<{ total: number }>( ` diff --git a/apps/backend/src/services/scheduler/config-service.ts b/apps/backend/src/services/scheduler/config-service.ts index 80149a43..95269b45 100644 --- a/apps/backend/src/services/scheduler/config-service.ts +++ b/apps/backend/src/services/scheduler/config-service.ts @@ -10,6 +10,7 @@ import { const SCHEDULED_JOBS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'scheduled-jobs.json') const CLOUDTENTACLES_HEALTH_JOB_ID = 'cloudtentacles-health' +const WORK_ORDER_TIMEOUT_JOB_ID = 'work-order-timeout' export function getScheduledJobsFilePath() { return SCHEDULED_JOBS_FILE_PATH @@ -39,17 +40,32 @@ export function getCloudtentaclesHealthJob(config: JsonObject = getScheduledJobs || createDefaultCloudtentaclesHealthJob() } +export function getWorkOrderTimeoutJob(config: JsonObject = getScheduledJobsConfig()) { + return (Array.isArray(config.jobs) ? config.jobs : []) + .find((item) => String(item.id || '').trim() === WORK_ORDER_TIMEOUT_JOB_ID) + || createDefaultWorkOrderTimeoutJob() +} + +export function getScheduledJobById(jobId: unknown, config: JsonObject = getScheduledJobsConfig()) { + return (Array.isArray(config.jobs) ? config.jobs : []) + .find((item) => String(item.id || '').trim() === String(jobId || '').trim()) || null +} + export function normalizeScheduledJobsConfig(rawValue: unknown) { const source = isPlainObject(rawValue) ? rawValue : {} const rawJobs = Array.isArray(source.jobs) ? source.jobs : [] const jobs = rawJobs .map((item) => normalizeScheduledJob(item)) - .filter((item): item is ReturnType => Boolean(item)) + .filter((item): item is NonNullable> => Boolean(item)) const hasCloudtentaclesHealth = jobs.some((item) => item.id === CLOUDTENTACLES_HEALTH_JOB_ID) + const hasWorkOrderTimeout = jobs.some((item) => item.id === WORK_ORDER_TIMEOUT_JOB_ID) if (!hasCloudtentaclesHealth) { jobs.push(createDefaultCloudtentaclesHealthJob()) } + if (!hasWorkOrderTimeout) { + jobs.push(createDefaultWorkOrderTimeoutJob()) + } return { enabled: typeof source.enabled === 'boolean' ? source.enabled : true, @@ -63,11 +79,28 @@ function normalizeScheduledJob(rawValue: unknown) { } const type = String(rawValue.type || '').trim() - if (type !== 'cloudtentacles_health') { - return null + if (type === 'cloudtentacles_health') { + return normalizeCloudtentaclesHealthJob(rawValue) + } + if (type === 'work_order_timeout') { + return normalizeWorkOrderTimeoutJob(rawValue) } - return normalizeCloudtentaclesHealthJob(rawValue) + return null +} + +function normalizeWorkOrderTimeoutJob(rawValue: JsonObject) { + const config = isPlainObject(rawValue.config) ? rawValue.config : {} + + return { + id: WORK_ORDER_TIMEOUT_JOB_ID, + type: 'work_order_timeout', + enabled: rawValue.enabled !== false, + intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600), + config: { + scanLimit: normalizeRangeInteger(config.scanLimit, 50, 1, 1000), + }, + } } function normalizeCloudtentaclesHealthJob(rawValue: JsonObject) { @@ -137,10 +170,23 @@ function createDefaultScheduledJobsConfig() { enabled: true, jobs: [ createDefaultCloudtentaclesHealthJob(), + createDefaultWorkOrderTimeoutJob(), ], } } +function createDefaultWorkOrderTimeoutJob() { + return { + id: WORK_ORDER_TIMEOUT_JOB_ID, + type: 'work_order_timeout', + enabled: true, + intervalSeconds: 60, + config: { + scanLimit: 50, + }, + } +} + function createDefaultCloudtentaclesHealthJob() { return { id: CLOUDTENTACLES_HEALTH_JOB_ID, diff --git a/apps/backend/src/services/scheduler/scheduler-service.ts b/apps/backend/src/services/scheduler/scheduler-service.ts index 11491750..2b1f3621 100644 --- a/apps/backend/src/services/scheduler/scheduler-service.ts +++ b/apps/backend/src/services/scheduler/scheduler-service.ts @@ -2,10 +2,11 @@ import { logError, logInfo, logWarn } from '../../utils/logger.js' import { createHttpError } from '../../utils/http.js' import type { JsonObject } from '../../types/json.js' import { - getCloudtentaclesHealthJob, + getScheduledJobById, getScheduledJobsConfig, } from './config-service.js' import { runCloudtentaclesHealthJob } from './cloudtentacles-health-job.js' +import { runWorkOrderTimeoutJob } from './work-order-timeout-job.js' const timers = new Map() const jobStates = new Map() @@ -67,14 +68,20 @@ export async function runScheduledJobNow(jobId: unknown) { const result = await runJob(job, { manual: true }) if (getScheduledJobsConfig().enabled !== false) { - const latestJob = getCloudtentaclesHealthJob(getScheduledJobsConfig()) - if (latestJob.enabled === true) { - scheduleJob(latestJob, Math.max(60, Number(latestJob.intervalSeconds || 300)) * 1000) - } + rescheduleJob(job) } return result } +function rescheduleJob(job: JsonObject) { + const jobId = String(job.id || '').trim() + if (!jobId) return + const latestJob = getScheduledJobById(jobId) + if (!latestJob || latestJob.enabled !== true) return + const intervalMs = Math.max(60, Number(latestJob.intervalSeconds || 60)) * 1000 + scheduleJob(latestJob, intervalMs) +} + function scheduleJob(job: JsonObject, delayMs: number) { const jobId = String(job.id || '').trim() if (!jobId) { @@ -90,10 +97,7 @@ function scheduleJob(job: JsonObject, delayMs: number) { const timer = setTimeout(() => { timers.delete(jobId) void runJob(job).finally(() => { - const latestJob = getCloudtentaclesHealthJob(getScheduledJobsConfig()) - if (latestJob.enabled === true) { - scheduleJob(latestJob, Math.max(60, Number(latestJob.intervalSeconds || 300)) * 1000) - } + rescheduleJob(job) }) }, delayMs) @@ -126,25 +130,27 @@ async function runJob(job: JsonObject, { manual = false }: { manual?: boolean } try { const result = await dispatchJob(job) + const summary = + result && typeof result === 'object' ? (result as JsonObject) : {} updateJobState(jobId, { running: false, lastFinishedAt: new Date().toISOString(), - lastStatus: result?.status || 'ok', - lastMessage: result?.message || '执行完成', - lastAsset: typeof result?.asset === 'number' ? result.asset : null, - lastThreshold: typeof result?.threshold === 'number' ? result.threshold : null, - lastAccounts: Array.isArray(result?.accounts) ? result.accounts : [], - lastAccountCount: Number(result?.accountCount || 0), - lastCheckedCount: Number(result?.checkedCount || 0), - lastOkCount: Number(result?.okCount || 0), - lastLowAssetCount: Number(result?.lowAssetCount || 0), - lastFailedCount: Number(result?.failedCount || 0), + lastStatus: String(summary.status || 'ok'), + lastMessage: String(summary.message || '执行完成'), + lastAsset: typeof summary.asset === 'number' ? summary.asset : null, + lastThreshold: typeof summary.threshold === 'number' ? summary.threshold : null, + lastAccounts: Array.isArray(summary.accounts) ? summary.accounts : [], + lastAccountCount: Number(summary.accountCount || 0), + lastCheckedCount: Number(summary.checkedCount || 0), + lastOkCount: Number(summary.okCount || 0), + lastLowAssetCount: Number(summary.lowAssetCount || 0), + lastFailedCount: Number(summary.failedCount || 0), lastManual: manual, }) logInfo('[scheduler]', '定时任务执行完成', { jobId, type: job.type, - status: result?.status || 'ok', + status: String(summary.status || 'ok'), }) return result } catch (error) { @@ -173,6 +179,9 @@ function dispatchJob(job: JsonObject) { if (job.type === 'cloudtentacles_health') { return runCloudtentaclesHealthJob(job) } + if (job.type === 'work_order_timeout') { + return runWorkOrderTimeoutJob(job) + } throw createHttpError(`不支持的定时任务类型:${job.type}`, { statusCode: 400, diff --git a/apps/backend/src/services/scheduler/work-order-timeout-job.ts b/apps/backend/src/services/scheduler/work-order-timeout-job.ts new file mode 100644 index 00000000..23fcbaf3 --- /dev/null +++ b/apps/backend/src/services/scheduler/work-order-timeout-job.ts @@ -0,0 +1,24 @@ +import type { JsonObject } from '../../types/json.js' +import { logInfo } from '../../utils/logger.js' +import { settleOverdueWorkOrders } from '../worker-platform/worker-service.js' + +export async function runWorkOrderTimeoutJob(job: JsonObject) { + const config = + job.config && typeof job.config === 'object' && !Array.isArray(job.config) + ? (job.config as JsonObject) + : {} + const scanLimit = Math.max(1, Number(config.scanLimit || 50)) + + const result = await settleOverdueWorkOrders({ limit: scanLimit }) + + logInfo('[work-order-timeout]', '超时工单扫描完成', result) + + return { + ok: result.processedCount === result.checkedCount, + status: 'ok', + message: `扫描 ${result.checkedCount} 个,处置 ${result.processedCount} 个,跳过 ${result.skippedCount} 个`, + checkedCount: result.checkedCount, + failedCount: result.skippedCount, + asset: result.processedCount, + } +} diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index 021404ad..e81b6978 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -86,7 +86,7 @@ import { } from './worker-finance-config-service.js' import { DEFAULT_CATEGORY_KEY, DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, DEFAULT_LEVEL_KEY, DEFAULT_LEVEL_NAME, mapFinanceRequest, mapWallet, mapWorkCategory, mapWorkOrderAdmin, mapWorkOrderShare, mapWorkProductRule, mapWorkerLevel, mapWorkerUser, normalizeAdminFinanceReviewStatus, normalizeAmountFen, normalizeBoolean, normalizeEnabledStatus, normalizeFinanceRequestStatus, normalizeFinanceRequestType, normalizeInteger, normalizeMatchType, normalizeOptionalId, normalizePositiveInteger, normalizeProblemResolutionAction, normalizeRequirementFields, normalizeRequirementFieldsFromPayload, normalizeReviewStatus, normalizeSessionVersion, normalizeSlugKey, normalizeSubmittedFields, normalizeUploadedFiles, resolveMatchingProductRule, resolveRequirementFields } from './mappers.js' -import { ensureWorkerPlatformDefaults, getRequiredWorkOrder, getRequiredWorker } from './worker-service.js' +import { ensureWorkerPlatformDefaults, getRequiredWorkOrder, getRequiredWorker, normalizeWorkOrderTimeoutPolicy } from './worker-service.js' export async function listAdminWorkerLevels() { await ensureWorkerPlatformDefaults() @@ -281,6 +281,8 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { sharingEnabled, sharingTotalQuantity, sharingUnitReward, + timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)), + timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy), requirementJson: JSON.stringify({ fields }), sortOrder: normalizeInteger(payload.sortOrder, 100), now: nowIso(), @@ -646,6 +648,8 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { rewardAmount, requiredDepositAmount, depositThresholdAmount, + timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)), + timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy), materialJson: JSON.stringify(material), requirementJson: JSON.stringify({ fields }), now, @@ -721,6 +725,13 @@ export async function updateAdminWorkOrder( rewardAmount, requiredDepositAmount, depositThresholdAmount, + timeoutMinutes: + payload.timeoutMinutes === undefined + ? workOrder.timeout_minutes + : Math.max(0, normalizeInteger(payload.timeoutMinutes, workOrder.timeout_minutes)), + timeoutPolicy: normalizeWorkOrderTimeoutPolicy( + payload.timeoutPolicy ?? workOrder.timeout_policy, + ), requirementJson: JSON.stringify({ fields }), updatedAt: now, }) @@ -735,6 +746,8 @@ export async function updateAdminWorkOrder( productName: updated?.product_name, rewardAmount, requiredDepositAmount, + timeoutMinutes: updated?.timeout_minutes, + timeoutPolicy: updated?.timeout_policy, fields, }), now, @@ -995,6 +1008,8 @@ export async function syncWorkerOrdersForSourceOrder( sharingEnabled: rule.sharing_enabled === true, sharingTotalQuantity: Number(rule.sharing_total_quantity || 1), sharingUnitReward: Number(rule.sharing_unit_reward || 0), + timeoutMinutes: Number(rule.timeout_minutes || 0), + timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(), materialJson: JSON.stringify({ source: { orderId: Number(order.id), diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index b660c10c..005ce48d 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -247,6 +247,8 @@ export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined) totalQuantity: Number(rule.sharing_total_quantity || 1), unitReward: Number(rule.sharing_unit_reward || 0), }, + timeoutMinutes: Number(rule.timeout_minutes || 0), + timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(), requirement: { fields: normalizeRequirementFields(requirement.fields), }, @@ -355,6 +357,9 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow) { totalQuantity: Number(workOrder.sharing_total_quantity || 1), unitReward: Number(workOrder.sharing_unit_reward || 0), }, + timeoutMinutes: Number(workOrder.timeout_minutes || 0), + timeoutPolicy: String(workOrder.timeout_policy || 'reopen').trim(), + deadlineAt: workOrder.deadline_at, depositThresholdAmount: Number( workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, ), diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 2b510bb5..6ed2819e 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -47,8 +47,10 @@ import { listWorkerUsers, listWorkOrderShares, listWorkOrderSharesByOrderIds, + listOverdueWorkOrders, resolveProblemWorkOrder, reviewWorkerFinanceRequest, + settleOverdueWorkOrder, submitWorkOrderShareAcceptance, updateWorkOrder, updateWorkerPassword, @@ -642,6 +644,7 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work const pageSize = normalizePageSize(query.pageSize) const categoryId = normalizeOptionalId(query.categoryId) const worker = await getRequiredWorker(session.workerId) + await settleOverdueWorkOrders({ workerId: worker.id, limit: 50 }) const permissions = resolveWorkerPermissions(worker) const visibleDelaySeconds = Number(permissions.visibleDelaySeconds || 0) const visibleAfterIso = @@ -706,6 +709,7 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session: workerId: Number(worker.id), depositAmount: freezeAmount, maxActiveOrders: permissions.maxActiveOrders, + deadlineAt: resolveWorkOrderDeadlineAt(workOrder, nowIso()), now: nowIso(), }) if (!grabbed.order) { @@ -715,11 +719,61 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session: return { order: mapWorkOrderForWorker(grabbed.order, permissions) } } +function resolveWorkOrderDeadlineAt( + workOrder: WorkOrderRow, + now: string, +): string | null { + const timeoutMinutes = Number(workOrder.timeout_minutes || 0) + if (timeoutMinutes <= 0) return null + return new Date(new Date(now).getTime() + timeoutMinutes * 60 * 1000).toISOString() +} + +export const WORK_ORDER_TIMEOUT_POLICIES = ['reopen', 'cancel_release', 'cancel_deduct'] as const + +export type WorkOrderTimeoutPolicy = (typeof WORK_ORDER_TIMEOUT_POLICIES)[number] + +export function isWorkOrderTimeoutPolicy(value: unknown): value is WorkOrderTimeoutPolicy { + return WORK_ORDER_TIMEOUT_POLICIES.includes(value as WorkOrderTimeoutPolicy) +} + +export function normalizeWorkOrderTimeoutPolicy(value: unknown): WorkOrderTimeoutPolicy { + return isWorkOrderTimeoutPolicy(value) ? value : 'reopen' +} + +export async function settleOverdueWorkOrders( + options: { limit?: number; workerId?: number } = {}, +) { + const overdue = await listOverdueWorkOrders({ + limit: Math.max(1, Number(options.limit || 50)), + workerId: Number(options.workerId || 0), + }) + let processedCount = 0 + let skippedCount = 0 + for (const workOrder of overdue) { + const result = await settleOverdueWorkOrder({ + workOrderId: workOrder.id, + policy: normalizeWorkOrderTimeoutPolicy(workOrder.timeout_policy), + now: nowIso(), + }) + if (result.order) { + processedCount += 1 + } else { + skippedCount += 1 + } + } + return { + checkedCount: overdue.length, + processedCount, + skippedCount, + } +} + export async function listWorkerMyOrders(query: JsonObject = {}, session: WorkerSession) { requireActiveWorkerSession(session) + const worker = await getRequiredWorker(session.workerId) + await settleOverdueWorkOrders({ workerId: worker.id, limit: 50 }) const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) - const worker = await getRequiredWorker(session.workerId) const { items, total } = await listWorkOrders({ page, pageSize, @@ -835,6 +889,22 @@ export async function submitWorkerOrderAcceptance( if (sharingShare) { return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session) } + const now = nowIso() + if ( + workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS && + workOrder.deadline_at && + new Date(workOrder.deadline_at).getTime() <= Date.now() + ) { + await settleOverdueWorkOrder({ + workOrderId: workOrder.id, + policy: normalizeWorkOrderTimeoutPolicy(workOrder.timeout_policy), + now, + }) + throw createHttpError('任务已超时,系统已按超时策略处置', { + statusCode: 409, + errorCode: 'work_order_timeout_expired', + }) + } if (Number(workOrder.assigned_worker_id || 0) !== workerId) { throw createHttpError('只能提交自己的订单', { statusCode: 403, @@ -850,7 +920,6 @@ export async function submitWorkerOrderAcceptance( }) } - const now = nowIso() const files = normalizeUploadedFiles(payload.files) const imageUrls = [ ...files.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean), diff --git a/apps/frontend/src/components/DeadlineCountdown.tsx b/apps/frontend/src/components/DeadlineCountdown.tsx new file mode 100644 index 00000000..b765c399 --- /dev/null +++ b/apps/frontend/src/components/DeadlineCountdown.tsx @@ -0,0 +1,34 @@ +import { Tag } from 'antd' +import { useEffect, useState } from 'react' + +export function DeadlineCountdown({ + deadlineAt, + showExpired = false, +}: { + deadlineAt: string + showExpired?: boolean +}) { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(timer) + }, []) + + const remainingMs = new Date(deadlineAt).getTime() - now + if (remainingMs <= 0) { + return 已超时 + } + const totalSeconds = Math.floor(remainingMs / 1000) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + const label = minutes > 0 ? `${minutes} 分 ${seconds} 秒` : `${seconds} 秒` + const color = remainingMs < 10 * 60 * 1000 ? 'orange' : 'blue' + return 剩余 {label} +} + +export function formatTimeoutPolicyLabel(policy?: string) { + if (policy === 'cancel_release') return '超时取消退押金' + if (policy === 'cancel_deduct') return '超时取消扣押金' + return '超时退回大厅' +} diff --git a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx index 3f9a6395..45c27835 100644 --- a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx @@ -113,6 +113,8 @@ export default function ProductRulesPanel() { sharingTotalQuantity?: number sharingUnitReward?: number sharingTotalAmount?: number + timeoutMinutes?: number + timeoutPolicy?: string fieldsText?: string enabled?: boolean autoCreate?: boolean @@ -389,6 +391,27 @@ export default function ProductRulesPanel() { > + + + + + + ) : null} diff --git a/apps/frontend/src/pages/admin/panels/shared.tsx b/apps/frontend/src/pages/admin/panels/shared.tsx index 573b16a6..8389f50f 100644 --- a/apps/frontend/src/pages/admin/panels/shared.tsx +++ b/apps/frontend/src/pages/admin/panels/shared.tsx @@ -1,8 +1,14 @@ import { Descriptions, Space, Tag, Typography } from 'antd' import ImagePreviewList from '@/components/files/ImagePreviewList' +import { + DeadlineCountdown, + formatTimeoutPolicyLabel, +} from '@/components/DeadlineCountdown' import type { CollectField, UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform' import { formatAdminDateTime } from '@/utils/admin-time' +export { DeadlineCountdown, formatTimeoutPolicyLabel } + export function getRequirementFields(order: WorkOrder | null): CollectField[] { if (!order) return [] const rawFields = Array.isArray(order.requirement?.fields) diff --git a/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx b/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx index fc89e83d..6468e37e 100644 --- a/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx +++ b/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx @@ -267,17 +267,22 @@ function NotificationPanel({ const [testResult, setTestResult] = useState(null) const source = notificationConfig.source const monitorAccounts = scheduledJobs.cloudtentaclesAccounts || [] - const jobsWithMergedAccounts = scheduledJobs.source.jobs.map((job) => ({ - ...job, - config: { - ...job.config, - accounts: mergeScheduledJobAccounts( - job.config?.accounts || [], - monitorAccounts, - Number(job.config?.assetThreshold ?? 500), - ), - }, - })) + const jobsWithMergedAccounts = scheduledJobs.source.jobs.map((job) => { + if (job.type !== 'cloudtentacles_health') { + return job + } + return { + ...job, + config: { + ...job.config, + accounts: mergeScheduledJobAccounts( + job.config?.accounts || [], + monitorAccounts, + Number(job.config?.assetThreshold ?? 500), + ), + }, + } + }) async function saveNotification() { setSavingNotification(true) @@ -313,18 +318,23 @@ function NotificationPanel({ try { const payload: AdminScheduledJobsConfig = { ...scheduledJobs.source, - jobs: jobsWithMergedAccounts.map((job) => ({ - ...job, - config: { - assetThreshold: Number(job.config?.assetThreshold ?? 500), - accounts: (job.config?.accounts || []).map((account) => ({ - sourceKey: account.sourceKey, - label: account.label, - enabled: account.enabled !== false, - assetThreshold: Number(account.assetThreshold ?? job.config?.assetThreshold ?? 500), - })), - }, - })), + jobs: jobsWithMergedAccounts.map((job) => { + if (job.type !== 'cloudtentacles_health') { + return { ...job } + } + return { + ...job, + config: { + assetThreshold: Number(job.config?.assetThreshold ?? 500), + accounts: (job.config?.accounts || []).map((account) => ({ + sourceKey: account.sourceKey, + label: account.label, + enabled: account.enabled !== false, + assetThreshold: Number(account.assetThreshold ?? job.config?.assetThreshold ?? 500), + })), + }, + } + }), } const response = await saveAdminScheduledJobsConfig(payload) onScheduledJobsChange(response.data) @@ -587,6 +597,72 @@ function ScheduledJobCard({ onRun: () => void onChange: (job: AdminScheduledJobItem) => void }) { + if (job.type === 'work_order_timeout' || job.id === 'work-order-timeout') { + return ( + + onChange({ ...job, enabled })} + /> + {formatScheduledJobTitle(job)} + + {job.enabled ? '已启用' : '已停用'} + + + } + extra={ + + } + > + + 扫描代练中且已过截止时间的工单,按工单超时策略自动处置(退回大厅 / 取消退押金 / + 取消扣押金)。 + +
+ onChange({ ...job, intervalSeconds })} + /> + + onChange({ ...job, config: { ...job.config, scanLimit } }) + } + /> +
+ {runtime ? ( + + ) : null} +
+ ) + } const accounts = job.config?.accounts || [] const defaultThreshold = Number(job.config?.assetThreshold ?? 500) @@ -812,6 +888,9 @@ function formatScheduledJobTitle(job: AdminScheduledJobItem) { if (job.type === 'cloudtentacles_health' || job.id === 'cloudtentacles-health') { return 'kuaishou-lewan 健康检查' } + if (job.type === 'work_order_timeout' || job.id === 'work-order-timeout') { + return '接单工单超时扫描' + } return job.id || job.type || '定时任务' } diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index 27c87f0d..7cee8c4b 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -25,8 +25,9 @@ import { Typography, } from 'antd' import type { TableColumnsType } from 'antd' -import { useState } from 'react' +import { useEffect, useState } from 'react' +import { DeadlineCountdown } from '@/components/DeadlineCountdown' import ImagePreviewList from '@/components/files/ImagePreviewList' import ImageUpload from '@/components/files/ImageUpload' import { @@ -227,6 +228,9 @@ export default function WorkerOrdersPage() { render: (_, row) => (
{formatStatus(row.status)} + {row.status === 'in_progress' && row.deadlineAt ? ( + + ) : null} {getStatusHint(row.status)} diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index d2458214..ea1ea740 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -104,6 +104,8 @@ export function saveAdminWorkProductRule(payload: { sharingTotalQuantity?: number sharingUnitReward?: number sharingTotalAmount?: number + timeoutMinutes?: number + timeoutPolicy?: string fieldsText?: string sortOrder?: number }) { @@ -228,6 +230,8 @@ export function updateAdminWorkOrder( rewardAmount?: number requiredDepositAmount?: number fieldsText?: string + timeoutMinutes?: number + timeoutPolicy?: string }, ) { return apiPut<{ order: WorkOrder }>( diff --git a/apps/frontend/src/types/admin/platform-config/scheduled-jobs.ts b/apps/frontend/src/types/admin/platform-config/scheduled-jobs.ts index 5a0d82b1..a2aeba40 100644 --- a/apps/frontend/src/types/admin/platform-config/scheduled-jobs.ts +++ b/apps/frontend/src/types/admin/platform-config/scheduled-jobs.ts @@ -12,8 +12,10 @@ export interface AdminScheduledJobItem { intervalSeconds: number cooldownSeconds: number config: { - assetThreshold: number - accounts: AdminScheduledJobCloudtentaclesAccount[] + assetThreshold?: number + accounts?: AdminScheduledJobCloudtentaclesAccount[] + scanLimit?: number + [key: string]: unknown } } diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 4e449768..f21709ef 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -148,6 +148,8 @@ export type WorkProductRule = { requirement: { fields: CollectField[] } + timeoutMinutes?: number + timeoutPolicy?: 'reopen' | 'cancel_release' | 'cancel_deduct' | string sortOrder: number createdAt: string updatedAt: string @@ -221,6 +223,9 @@ export type WorkOrder = { joinedQuantity: number pendingSubmissionCount: number } + timeoutMinutes?: number + timeoutPolicy?: 'reopen' | 'cancel_release' | 'cancel_deduct' | string + deadlineAt?: string | null material: Record requirement: Record acceptance: {