diff --git a/apps/backend/src/db/migrations/054_work_order_gift_acceptance.sql b/apps/backend/src/db/migrations/054_work_order_gift_acceptance.sql new file mode 100644 index 00000000..38f20b34 --- /dev/null +++ b/apps/backend/src/db/migrations/054_work_order_gift_acceptance.sql @@ -0,0 +1,29 @@ +-- 好友赠送验收模式:打手接单后提交本人资料 → 后台确认加好友 → 冷却 N 小时后才能赠送并提交验收。 +-- 发布、核销、大厅、抢单、押金、结算链路保持不变,仅验收前置流程不同。 + +ALTER TABLE work_orders + ADD COLUMN IF NOT EXISTS acceptance_mode TEXT NOT NULL DEFAULT 'standard', + ADD COLUMN IF NOT EXISTS gift_phase TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS booster_material_json JSONB NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS friend_added_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS gift_available_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS gift_cooldown_hours INTEGER NOT NULL DEFAULT 72; + +COMMENT ON COLUMN work_orders.acceptance_mode IS '验收模式:standard 标准验收 / friend_gift 好友赠送(建单时从接单模板拷贝)'; +COMMENT ON COLUMN work_orders.gift_phase IS '好友赠送阶段:material_required 待打手提交资料 / material_submitted 待后台确认好友 / friend_countdown 冷却中;gift_ready 为 gift_available_at 到点后的实时计算值,不落库'; +COMMENT ON COLUMN work_orders.booster_material_json IS '打手本人资料:昵称/ID/区服/系统 + 主页截图'; +COMMENT ON COLUMN work_orders.friend_added_at IS '后台确认加好友时间(冷却起点)'; +COMMENT ON COLUMN work_orders.gift_available_at IS '可赠送时间 = 确认好友时间 + 冷却小时数'; +COMMENT ON COLUMN work_orders.gift_cooldown_hours IS '好友赠送冷却小时数,建单时从模板拷贝'; + +-- 后台按“可赠送”筛选待办时走该部分索引,避免全表扫描。 +CREATE INDEX IF NOT EXISTS idx_work_orders_gift_available + ON work_orders (gift_available_at) + WHERE acceptance_mode = 'friend_gift' AND status = 'in_progress'; + +ALTER TABLE work_product_rules + ADD COLUMN IF NOT EXISTS acceptance_mode TEXT NOT NULL DEFAULT 'standard', + ADD COLUMN IF NOT EXISTS gift_cooldown_hours INTEGER NOT NULL DEFAULT 72; + +COMMENT ON COLUMN work_product_rules.acceptance_mode IS '验收模式:standard 标准验收 / friend_gift 好友赠送;启用 friend_gift 时不允许拼单'; +COMMENT ON COLUMN work_product_rules.gift_cooldown_hours IS '好友赠送冷却小时数(默认 72 即 3 天),建单时拷贝到工单'; diff --git a/apps/backend/src/domain/work-order-acceptance-mode.ts b/apps/backend/src/domain/work-order-acceptance-mode.ts new file mode 100644 index 00000000..7175b6b1 --- /dev/null +++ b/apps/backend/src/domain/work-order-acceptance-mode.ts @@ -0,0 +1,63 @@ +export const WORK_ORDER_ACCEPTANCE_MODE = { + STANDARD: 'standard', + FRIEND_GIFT: 'friend_gift', +} as const + +export type WorkOrderAcceptanceMode = + | (typeof WORK_ORDER_ACCEPTANCE_MODE)[keyof typeof WORK_ORDER_ACCEPTANCE_MODE] + | (string & {}) + +export const DEFAULT_GIFT_COOLDOWN_HOURS = 72 +export const MAX_GIFT_COOLDOWN_HOURS = 24 * 30 + +/** 好友赠送模式的落库阶段;gift_ready 由 gift_available_at 到点后实时计算,不落库。 */ +export const WORK_ORDER_GIFT_PHASE = { + MATERIAL_REQUIRED: 'material_required', + MATERIAL_SUBMITTED: 'material_submitted', + FRIEND_COUNTDOWN: 'friend_countdown', + GIFT_READY: 'gift_ready', +} as const + +export type WorkOrderGiftPhase = + | (typeof WORK_ORDER_GIFT_PHASE)[keyof typeof WORK_ORDER_GIFT_PHASE] + | (string & {}) + +export function normalizeWorkOrderAcceptanceMode(value: unknown): WorkOrderAcceptanceMode { + const mode = String(value || '').trim() + return mode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT + ? WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT + : WORK_ORDER_ACCEPTANCE_MODE.STANDARD +} + +export function isFriendGiftAcceptanceMode(value: unknown): boolean { + return normalizeWorkOrderAcceptanceMode(value) === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT +} + +/** 冷却小时数收敛到 [0, 30 天],默认 3 天。 */ +export function normalizeWorkOrderGiftCooldownHours(value: unknown): number { + const hours = Number(value) + if (!Number.isFinite(hours) || hours <= 0) return DEFAULT_GIFT_COOLDOWN_HOURS + return Math.min(MAX_GIFT_COOLDOWN_HOURS, Math.max(1, Math.round(hours))) +} + +export type WorkOrderGiftSource = { + acceptance_mode?: string | null + gift_phase?: string | null + gift_available_at?: string | null +} + +export function isWorkOrderGiftAvailable(source: WorkOrderGiftSource): boolean { + if (!isFriendGiftAcceptanceMode(source.acceptance_mode)) return false + const availableAt = Date.parse(String(source.gift_available_at || '')) + return Number.isFinite(availableAt) && availableAt <= Date.now() +} + +/** 返回当前赠送阶段;冷却到点后返回实时计算的 gift_ready。 */ +export function resolveWorkOrderGiftPhase(source: WorkOrderGiftSource): string { + if (!isFriendGiftAcceptanceMode(source.acceptance_mode)) return '' + const phase = String(source.gift_phase || '').trim() + if (phase === WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN && isWorkOrderGiftAvailable(source)) { + return WORK_ORDER_GIFT_PHASE.GIFT_READY + } + return phase +} diff --git a/apps/backend/src/repositories/worker-platform/index.ts b/apps/backend/src/repositories/worker-platform/index.ts index f8f8fad8..4941e6a3 100644 --- a/apps/backend/src/repositories/worker-platform/index.ts +++ b/apps/backend/src/repositories/worker-platform/index.ts @@ -12,6 +12,7 @@ export * from './work-order-management-repo.js' export * from './work-order-grab-repo.js' export * from './work-order-cancel-repo.js' export * from './work-order-issue-repo.js' +export * from './work-order-gift-repo.js' export * from './work-order-deposit-query-repo.js' export * from './work-order-deposit-repo.js' export * from './work-order-event-repo.js' diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index ef2b5018..81af4c5c 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -262,6 +262,8 @@ export type WorkProductRuleRow = { sharing_auto_from_order: boolean timeout_minutes: number timeout_policy: string + acceptance_mode: string + gift_cooldown_hours: number requirement_json: string | Record match_json: string | Record sort_order: number @@ -360,6 +362,15 @@ export type WorkOrderRow = { deadline_at: string | null timeout_minutes: number timeout_policy: string + /** 验收模式:standard / friend_gift;friend_gift 建单时从接单模板拷贝。 */ + acceptance_mode: string + /** 好友赠送阶段:material_required / material_submitted / friend_countdown;gift_ready 实时计算。 */ + gift_phase: string + /** 打手本人资料(昵称/ID/区服/系统 + 主页截图),仅 friend_gift 模式使用。 */ + booster_material_json: string | Record + friend_added_at: string | null + gift_available_at: string | null + gift_cooldown_hours: number created_at: string updated_at: string /** 拼单聚合字段,由工单查询统一计算。 */ @@ -411,6 +422,10 @@ export type ListInput = { excludeFilledSharing?: boolean /** VIP 免审核后仍在补图期限内的订单;与状态筛选取并集 */ vipEvidencePending?: boolean + /** 验收模式筛选:standard / friend_gift。 */ + acceptanceMode?: string + /** 好友赠送阶段筛选:material_required / material_submitted / friend_countdown / gift_ready。 */ + giftPhase?: string /** 置顶订单优先展示(抢单大厅使用) */ pinnedFirst?: boolean sort?: 'id_desc' | 'worker_claimed_at_desc' @@ -444,6 +459,8 @@ export type WorkOrderCapabilities = { canCancel: boolean canResolveProblem: boolean canDeduct: boolean + canConfirmFriend: boolean + canResetGiftFlow: boolean } export type ProductRuleListInput = { @@ -510,6 +527,8 @@ export type CreateWorkOrderInput = { sharingUnitReward?: number timeoutMinutes?: number timeoutPolicy?: string + acceptanceMode?: string + giftCooldownHours?: number materialJson: string requirementJson: string now: string diff --git a/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts index 128232db..09212461 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-cancel-repo.ts @@ -3,6 +3,7 @@ import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './share import { createWorkOrderEventWithClient } from './work-order-event-repo.js' import { getWorkOrderByIdWithClient } from './work-order-query-repo.js' import { getOutstandingDepositAmountWithClient } from './work-order-deposit-repo.js' +import { RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js' import type { WorkOrderRow } from './types.js' export async function cancelWorkerWorkOrder(input: { @@ -47,6 +48,7 @@ export async function cancelWorkerWorkOrder(input: { assigned_at = NULL, deadline_at = NULL, hall_queued_at = $1, + ${RESET_GIFT_FLOW_SQL}, updated_at = $1 WHERE id = $2 `, @@ -137,6 +139,7 @@ export async function unassignWorkOrder(input: { assigned_worker_id = NULL, assigned_at = NULL, deadline_at = NULL, + ${RESET_GIFT_FLOW_SQL}, updated_at = $1 WHERE id = $2 `, @@ -228,6 +231,7 @@ export async function returnAssignedWorkOrderToHall(input: { deadline_at = NULL, published_at = $1, hall_queued_at = $1, + ${RESET_GIFT_FLOW_SQL}, updated_at = $1 WHERE id = $2 `, @@ -424,6 +428,7 @@ export async function reopenCancelledWorkOrder(input: { problem_note = '', published_at = NULL, pinned_at = NULL, + ${RESET_GIFT_FLOW_SQL}, updated_at = $1 WHERE id = $2 `, diff --git a/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts new file mode 100644 index 00000000..1da8367c --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/work-order-gift-repo.ts @@ -0,0 +1,236 @@ +import { withTransaction } from '../../db/client.js' +import { createWorkOrderEventWithClient } from './work-order-event-repo.js' +import { getWorkOrderByIdWithClient } from './work-order-query-repo.js' +import type { WorkOrderRow } from './types.js' +import { + normalizeWorkOrderAcceptanceMode, + normalizeWorkOrderGiftCooldownHours, + WORK_ORDER_ACCEPTANCE_MODE, + WORK_ORDER_GIFT_PHASE, +} from '../../domain/work-order-acceptance-mode.js' + +/** + * 工单脱离打手(回大厅、取消指派、恢复未分配等)时重置好友赠送进度, + * 下一个打手重新走“提交资料 → 确认好友 → 冷却”流程。 + * 标准模式工单这些字段本就为空,重置无副作用。 + */ +export const RESET_GIFT_FLOW_SQL = ` + gift_phase = CASE WHEN acceptance_mode = 'friend_gift' THEN 'material_required' ELSE '' END, + booster_material_json = '{}'::jsonb, + friend_added_at = NULL, + gift_available_at = NULL +` + +/** 打手提交本人资料(昵称/ID/区服/系统 + 主页截图),进入待确认好友阶段。 */ +export async function submitWorkOrderGiftMaterial(input: { + workOrderId: number + workerId: number + boosterMaterialJson: string + now: string +}): Promise<{ + order: WorkOrderRow | null + failureReason: + | 'work_order_not_found' + | 'work_order_mode_invalid' + | 'work_order_owner_required' + | 'work_order_status_invalid' + | 'work_order_gift_phase_invalid' + | null +}> { + return withTransaction(async (client) => { + const currentResult = await client.query( + 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', + [input.workOrderId], + ) + const workOrder = currentResult.rows[0] || null + if (!workOrder) return { order: null, failureReason: 'work_order_not_found' } + if ( + normalizeWorkOrderAcceptanceMode(workOrder.acceptance_mode) !== + WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT + ) { + return { order: null, failureReason: 'work_order_mode_invalid' } + } + if (Number(workOrder.assigned_worker_id || 0) !== input.workerId) { + return { order: null, failureReason: 'work_order_owner_required' } + } + if (!['in_progress', 'problem'].includes(workOrder.status)) { + return { order: null, failureReason: 'work_order_status_invalid' } + } + if (workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED) { + return { order: null, failureReason: 'work_order_gift_phase_invalid' } + } + + await client.query( + ` + UPDATE work_orders + SET + gift_phase = 'material_submitted', + booster_material_json = $1::jsonb, + friend_added_at = NULL, + gift_available_at = NULL, + updated_at = $2 + WHERE id = $3 + `, + [input.boosterMaterialJson, input.now, input.workOrderId], + ) + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'worker', + actorId: String(input.workerId), + eventType: 'gift_material_submitted', + fromStatus: workOrder.status, + toStatus: workOrder.status, + payloadJson: input.boosterMaterialJson, + now: input.now, + }) + return { + order: await getWorkOrderByIdWithClient(client, input.workOrderId), + failureReason: null, + } + }) +} + +/** 后台确认已加好友:记录冷却起点并顺延任务时限到冷却结束后再起算。 */ +export async function confirmWorkOrderFriendAdded(input: { + workOrderId: number + actorName?: string + now: string +}): Promise<{ + order: WorkOrderRow | null + failureReason: + | 'work_order_not_found' + | 'work_order_mode_invalid' + | 'work_order_gift_phase_invalid' + | null +}> { + return withTransaction(async (client) => { + const currentResult = await client.query( + 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', + [input.workOrderId], + ) + const workOrder = currentResult.rows[0] || null + if (!workOrder) return { order: null, failureReason: 'work_order_not_found' } + if ( + normalizeWorkOrderAcceptanceMode(workOrder.acceptance_mode) !== + WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT + ) { + return { order: null, failureReason: 'work_order_mode_invalid' } + } + if (workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED) { + return { order: null, failureReason: 'work_order_gift_phase_invalid' } + } + + const cooldownHours = normalizeWorkOrderGiftCooldownHours(workOrder.gift_cooldown_hours) + // 参数必须显式转型:未知类型参数与 interval 做运算时 PostgreSQL 无法唯一解析操作符。 + await client.query( + ` + UPDATE work_orders + SET + gift_phase = 'friend_countdown', + friend_added_at = $1::timestamptz, + gift_available_at = $1::timestamptz + ($2::int * INTERVAL '1 hour'), + deadline_at = CASE + WHEN timeout_minutes > 0 + THEN $1::timestamptz + ($2::int * INTERVAL '1 hour') + (timeout_minutes * INTERVAL '1 minute') + ELSE NULL + END, + updated_at = $1::timestamptz + WHERE id = $3 + `, + [input.now, cooldownHours, input.workOrderId], + ) + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'admin', + actorId: input.actorName || '', + eventType: 'friend_confirmed', + fromStatus: workOrder.status, + toStatus: workOrder.status, + payloadJson: JSON.stringify({ + cooldownHours, + friendAddedAt: input.now, + }), + now: input.now, + }) + return { + order: await getWorkOrderByIdWithClient(client, input.workOrderId), + failureReason: null, + } + }) +} + +/** 后台重置打手资料(资料有误或误确认好友):清空好友状态并让打手重新提交。 */ +export async function resetWorkOrderGiftMaterial(input: { + workOrderId: number + note?: string + actorName?: string + now: string +}): Promise<{ + order: WorkOrderRow | null + failureReason: + | 'work_order_not_found' + | 'work_order_mode_invalid' + | 'work_order_status_invalid' + | 'work_order_gift_phase_invalid' + | null +}> { + return withTransaction(async (client) => { + const currentResult = await client.query( + 'SELECT * FROM work_orders WHERE id = $1 FOR UPDATE', + [input.workOrderId], + ) + const workOrder = currentResult.rows[0] || null + if (!workOrder) return { order: null, failureReason: 'work_order_not_found' } + if ( + normalizeWorkOrderAcceptanceMode(workOrder.acceptance_mode) !== + WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT + ) { + return { order: null, failureReason: 'work_order_mode_invalid' } + } + if (!['in_progress', 'problem'].includes(workOrder.status)) { + return { order: null, failureReason: 'work_order_status_invalid' } + } + if ( + workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED && + workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN + ) { + return { order: null, failureReason: 'work_order_gift_phase_invalid' } + } + + await client.query( + ` + UPDATE work_orders + SET + gift_phase = 'material_required', + booster_material_json = '{}'::jsonb, + friend_added_at = NULL, + gift_available_at = NULL, + deadline_at = CASE + WHEN timeout_minutes > 0 + THEN $1::timestamptz + (timeout_minutes * INTERVAL '1 minute') + ELSE NULL + END, + updated_at = $1::timestamptz + WHERE id = $2 + `, + [input.now, input.workOrderId], + ) + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'admin', + actorId: input.actorName || '', + eventType: 'gift_material_reset', + fromStatus: workOrder.status, + toStatus: workOrder.status, + payloadJson: JSON.stringify({ + previousPhase: workOrder.gift_phase, + note: String(input.note || '').trim(), + }), + now: input.now, + }) + return { + order: await getWorkOrderByIdWithClient(client, input.workOrderId), + failureReason: null, + } + }) +} diff --git a/apps/backend/src/repositories/worker-platform/work-order-grab-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-grab-repo.ts index e64d3e32..37ec9c12 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-grab-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-grab-repo.ts @@ -60,6 +60,10 @@ export async function grabWorkOrder(input: { last_assigned_worker_id = $1, assigned_at = $2, deadline_at = $3, + gift_phase = CASE WHEN acceptance_mode = 'friend_gift' THEN 'material_required' ELSE '' END, + booster_material_json = '{}'::jsonb, + friend_added_at = NULL, + gift_available_at = NULL, updated_at = $2 WHERE id = $4 AND status = 'open' @@ -180,6 +184,10 @@ export async function assignWorkOrderToWorker(input: { last_assigned_worker_id = $1, assigned_at = $2, deadline_at = $3, + gift_phase = CASE WHEN acceptance_mode = 'friend_gift' THEN 'material_required' ELSE '' END, + booster_material_json = '{}'::jsonb, + friend_added_at = NULL, + gift_available_at = NULL, updated_at = $2 WHERE id = $4 `, diff --git a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts index 10b94663..33fd4786 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts @@ -3,6 +3,7 @@ import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './share import { createWorkOrderEventWithClient } from './work-order-event-repo.js' import { getWorkOrderByIdWithClient } from './work-order-query-repo.js' import { getOutstandingDepositAmountWithClient } from './work-order-deposit-repo.js' +import { RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js' import type { ProblemWorkOrderResolutionAction, WorkOrderRow } from './types.js' export async function resolveProblemWorkOrder(input: { @@ -100,11 +101,34 @@ export async function resolveProblemWorkOrder(input: { 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 = 'in_progress' AND timeout_minutes > 0 THEN + CASE + WHEN acceptance_mode = 'friend_gift' + AND gift_available_at IS NOT NULL + AND gift_available_at > NOW() + THEN gift_available_at + (timeout_minutes * INTERVAL '1 minute') + ELSE NOW() + (timeout_minutes * INTERVAL '1 minute') + END WHEN $1 = 'open' THEN NULL ELSE deadline_at END, + gift_phase = CASE + WHEN $1 = 'open' OR $1 = 'cancelled' + THEN CASE WHEN acceptance_mode = 'friend_gift' THEN 'material_required' ELSE '' END + ELSE gift_phase + END, + booster_material_json = CASE + WHEN $1 = 'open' OR $1 = 'cancelled' THEN '{}'::jsonb + ELSE booster_material_json + END, + friend_added_at = CASE + WHEN $1 = 'open' OR $1 = 'cancelled' THEN NULL + ELSE friend_added_at + END, + gift_available_at = CASE + WHEN $1 = 'open' OR $1 = 'cancelled' THEN NULL + ELSE gift_available_at + END, updated_at = $4 WHERE id = $5 `, @@ -237,6 +261,7 @@ export async function settleOverdueWorkOrder(input: { problem_note = CASE WHEN $1 = 'open' THEN '' ELSE problem_note END, published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END, hall_queued_at = CASE WHEN $1 = 'open' THEN $2 ELSE hall_queued_at END, + ${RESET_GIFT_FLOW_SQL}, updated_at = $2 WHERE id = $3 `, diff --git a/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts index d40ee866..98043718 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts @@ -1,9 +1,17 @@ import { query } from '../../db/client.js' import { toJsonString, toPositiveInteger } from './shared.js' import { getWorkOrderById } from './work-order-query-repo.js' +import { + normalizeWorkOrderAcceptanceMode, + normalizeWorkOrderGiftCooldownHours, + WORK_ORDER_ACCEPTANCE_MODE, + WORK_ORDER_GIFT_PHASE, +} from '../../domain/work-order-acceptance-mode.js' import type { CreateWorkOrderInput, WorkOrderRow } from './types.js' export async function createWorkOrder(input: CreateWorkOrderInput): Promise { + const acceptanceMode = normalizeWorkOrderAcceptanceMode(input.acceptanceMode) + const isFriendGift = acceptanceMode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT const result = await query<{ id: number }>( ` INSERT INTO work_orders ( @@ -11,10 +19,13 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise 0) { params.push(hallCandidateLimit) filters.push(`wo.id IN (${buildHallCandidateIdsSql(`$${params.length}`)})`) diff --git a/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts b/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts index 244deb04..f818d6a4 100644 --- a/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts @@ -1,5 +1,9 @@ import { query, withTransaction } from '../../db/client.js' import { toPositiveInteger } from './shared.js' +import { + normalizeWorkOrderAcceptanceMode, + normalizeWorkOrderGiftCooldownHours, +} from '../../domain/work-order-acceptance-mode.js' import type { ProductRuleListInput, WorkOrderRow, @@ -336,6 +340,8 @@ export async function upsertWorkProductRule(input: { sharingAutoFromOrder?: boolean timeoutMinutes?: number timeoutPolicy?: string + acceptanceMode?: string + giftCooldownHours?: number requirementJson: string matchJson?: string sortOrder: number @@ -350,6 +356,7 @@ export async function upsertWorkProductRule(input: { required_deposit_amount, deposit_threshold_amount, sharing_enabled, sharing_total_quantity, sharing_unit_reward, sharing_auto_from_order, timeout_minutes, timeout_policy, + acceptance_mode, gift_cooldown_hours, requirement_json, match_json, sort_order, created_at, updated_at ) VALUES ( @@ -359,8 +366,9 @@ export async function upsertWorkProductRule(input: { $13, $14, $15, $16, $17, $18, $19, $20, - $21::jsonb, $22::jsonb, - $23, $24, $25 + $21, $22, + $23::jsonb, $24::jsonb, + $25, $26, $27 ) ON CONFLICT (rule_key) DO UPDATE SET @@ -383,6 +391,8 @@ export async function upsertWorkProductRule(input: { sharing_auto_from_order = EXCLUDED.sharing_auto_from_order, timeout_minutes = EXCLUDED.timeout_minutes, timeout_policy = EXCLUDED.timeout_policy, + acceptance_mode = EXCLUDED.acceptance_mode, + gift_cooldown_hours = EXCLUDED.gift_cooldown_hours, requirement_json = EXCLUDED.requirement_json, match_json = EXCLUDED.match_json, sort_order = EXCLUDED.sort_order, @@ -410,6 +420,8 @@ export async function upsertWorkProductRule(input: { input.sharingAutoFromOrder === true, toPositiveInteger(input.timeoutMinutes, 0), String(input.timeoutPolicy || 'reopen').trim(), + normalizeWorkOrderAcceptanceMode(input.acceptanceMode), + normalizeWorkOrderGiftCooldownHours(input.giftCooldownHours), input.requirementJson, input.matchJson || '{}', input.sortOrder, diff --git a/apps/backend/src/routes/admin/worker-platform/orders.ts b/apps/backend/src/routes/admin/worker-platform/orders.ts index d8696594..c2f867d0 100644 --- a/apps/backend/src/routes/admin/worker-platform/orders.ts +++ b/apps/backend/src/routes/admin/worker-platform/orders.ts @@ -4,6 +4,7 @@ import { acceptAdminWorkOrders, assignAdminWorkOrderToWorker, cancelAdminWorkOrder, + confirmAdminWorkOrderFriendAdded, createAdminMockWorkOrder, deleteAdminWorkOrder, getAdminWorkOrderEvents, @@ -13,6 +14,7 @@ import { publishAdminWorkOrder, reopenAdminCancelledWorkOrder, reprocessAdminKuaishouSendCodeWorkOrders, + resetAdminWorkOrderGiftMaterial, submitAdminWorkOrderMaterial, unassignAdminWorkOrder, unpublishAdminWorkOrder, @@ -292,6 +294,53 @@ router.post( ), ) +router.post( + '/worker-platform/orders/:workOrderId/confirm-friend', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler( + (req) => + confirmAdminWorkOrderFriendAdded( + String(req.params.workOrderId || ''), + req.adminSession?.username || '', + ), + { + successMessage: '已确认加好友,赠送冷却开始', + errorMessage: '确认加好友失败', + scope: '[admin/worker-platform/orders/:workOrderId/confirm-friend]', + audit: (req, data) => ({ + action: 'work_order_friend_confirmed', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + +router.post( + '/worker-platform/orders/:workOrderId/reset-gift-material', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler( + (req) => + resetAdminWorkOrderGiftMaterial( + String(req.params.workOrderId || ''), + req.body || {}, + req.adminSession?.username || '', + ), + { + successMessage: '已重置打手资料,等待打手重新提交', + errorMessage: '重置打手资料失败', + scope: '[admin/worker-platform/orders/:workOrderId/reset-gift-material]', + audit: (req, data) => ({ + action: 'work_order_gift_material_reset', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.get( '/worker-platform/orders/:workOrderId/sharing', requireAdminRoles(['admin', 'operator', 'support']), diff --git a/apps/backend/src/routes/worker/orders.ts b/apps/backend/src/routes/worker/orders.ts index e529fe19..d993eae7 100644 --- a/apps/backend/src/routes/worker/orders.ts +++ b/apps/backend/src/routes/worker/orders.ts @@ -16,6 +16,7 @@ import { submitWorkerCancelRequest, submitWorkerOrderAcceptance, submitWorkerOrderFeedback, + submitWorkerOrderGiftMaterial, supplementWorkerAcceptedOrderEvidence, } from '../../services/worker-platform/index.js' import { getRequiredWorkerSession, requireActiveWorker } from './session.js' @@ -201,6 +202,24 @@ router.post( ), ) +router.post( + '/orders/:workOrderId/gift-material', + requireActiveWorker, + createRouteHandler( + (req) => + submitWorkerOrderGiftMaterial( + String(req.params.workOrderId || ''), + req.body || {}, + getRequiredWorkerSession(req), + ), + { + successMessage: '好友赠送资料已提交,等待后台确认加好友', + errorMessage: '提交好友赠送资料失败', + scope: '[worker/orders/:workOrderId/gift-material]', + }, + ), +) + router.post( '/orders/:workOrderId/remind-acceptance', requireActiveWorker, diff --git a/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts b/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts index f93a6589..bed17a70 100644 --- a/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts +++ b/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts @@ -1,4 +1,8 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { + normalizeWorkOrderAcceptanceMode, + normalizeWorkOrderGiftCooldownHours, +} from '../../domain/work-order-acceptance-mode.js' import { createWorkOrder, createWorkOrderEvent } from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' @@ -78,6 +82,8 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { depositThresholdAmount, timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)), timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy), + acceptanceMode: normalizeWorkOrderAcceptanceMode(payload.acceptanceMode), + giftCooldownHours: normalizeWorkOrderGiftCooldownHours(payload.giftCooldownHours), materialJson: JSON.stringify(material), requirementJson: JSON.stringify({ fields }), now, diff --git a/apps/backend/src/services/worker-platform/admin-work-order-gift-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-gift-service.ts new file mode 100644 index 00000000..c92f28fc --- /dev/null +++ b/apps/backend/src/services/worker-platform/admin-work-order-gift-service.ts @@ -0,0 +1,80 @@ +import { + confirmWorkOrderFriendAdded, + resetWorkOrderGiftMaterial, +} from '../../repositories/worker-platform/index.js' +import { publishWorkOrderRealtimeChange } from '../realtime/realtime-event-service.js' +import { mapWorkOrderAdmin } from './mappers.js' +import { getRequiredWorkOrder } from './worker-session-context-service.js' +import { nowIso } from '../../utils/time.js' +import { createHttpError } from '../../utils/http.js' + +/** 后台核对打手资料并添加游戏好友后确认,开始 N 小时赠送冷却。 */ +export async function confirmAdminWorkOrderFriendAdded( + workOrderId: number | string, + actorName = '', +) { + const workOrder = await getRequiredWorkOrder(workOrderId) + const { order, failureReason } = await confirmWorkOrderFriendAdded({ + workOrderId: Number(workOrder.id), + actorName, + now: nowIso(), + }) + if (failureReason === 'work_order_gift_phase_invalid') { + throw createHttpError('当前阶段不能确认加好友,请刷新后重试', { + statusCode: 409, + errorCode: 'work_order_friend_confirm_phase_invalid', + }) + } + if (!order) { + throw createHttpError('确认加好友失败,订单可能已变化', { + statusCode: 409, + errorCode: 'work_order_friend_confirm_failed', + }) + } + const workerId = Number(order.assigned_worker_id || 0) + publishWorkOrderRealtimeChange({ + workOrderId: Number(order.id), + workerIds: workerId > 0 ? [workerId] : [], + }) + return { order: mapWorkOrderAdmin(order) } +} + +/** 打手资料有误或误确认好友时,重置资料让打手重新提交(冷却与好友时间清零)。 */ +export async function resetAdminWorkOrderGiftMaterial( + workOrderId: number | string, + payload: { note?: unknown } = {}, + actorName = '', +) { + const workOrder = await getRequiredWorkOrder(workOrderId) + const note = String(payload.note || '').trim() + const { order, failureReason } = await resetWorkOrderGiftMaterial({ + workOrderId: Number(workOrder.id), + note, + actorName, + now: nowIso(), + }) + if (failureReason === 'work_order_gift_phase_invalid') { + throw createHttpError('当前阶段没有可重置的打手资料', { + statusCode: 409, + errorCode: 'work_order_gift_reset_phase_invalid', + }) + } + if (failureReason === 'work_order_status_invalid') { + throw createHttpError('当前订单状态不能重置资料', { + statusCode: 409, + errorCode: 'work_order_gift_reset_status_invalid', + }) + } + if (!order) { + throw createHttpError('重置打手资料失败,订单可能已变化', { + statusCode: 409, + errorCode: 'work_order_gift_reset_failed', + }) + } + const workerId = Number(order.assigned_worker_id || 0) + publishWorkOrderRealtimeChange({ + workOrderId: Number(order.id), + workerIds: workerId > 0 ? [workerId] : [], + }) + return { order: mapWorkOrderAdmin(order) } +} diff --git a/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts index 0d7b4be9..80d8c0bd 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts @@ -38,6 +38,8 @@ export async function listAdminWorkOrders(query: JsonObject = {}) { const vipEvidencePending = normalizeBoolean(query.vipEvidencePending, false) const createdFrom = normalizeDateString(query.createdFrom) const createdTo = normalizeDateString(query.createdTo) + const acceptanceMode = String(query.acceptanceMode || '').trim() + const giftPhase = String(query.giftPhase || '').trim() const filters = { statuses, workOrderId, @@ -45,6 +47,8 @@ export async function listAdminWorkOrders(query: JsonObject = {}) { workerSharingId: workerId, workerLevelIds, vipEvidencePending, + ...(acceptanceMode ? { acceptanceMode } : {}), + ...(giftPhase ? { giftPhase } : {}), useOperationalStatus: true, ...(createdFrom ? { createdFrom } : {}), ...(createdTo ? { createdTo } : {}), diff --git a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts index 872882af..526b300e 100644 --- a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts +++ b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts @@ -23,6 +23,11 @@ import { upsertWorkProductRule, } from '../../repositories/worker-platform/work-product-rule-repo.js' import type { WorkOrderRow, WorkProductRuleRow } from '../../repositories/worker-platform/types.js' +import { + normalizeWorkOrderAcceptanceMode, + normalizeWorkOrderGiftCooldownHours, + WORK_ORDER_ACCEPTANCE_MODE, +} from '../../domain/work-order-acceptance-mode.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' import { randomId } from '../../utils/random.js' @@ -257,6 +262,17 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { ? 0 : normalizeAmountFen(payload.unitPrice ?? payload.unitPriceYuan, 0) const sharingEnabled = normalizeBoolean(payload.sharingEnabled ?? payload.sharing_enabled, false) + const acceptanceMode = normalizeWorkOrderAcceptanceMode( + hasOwnPayload(payload, 'acceptanceMode') || hasOwnPayload(payload, 'acceptance_mode') + ? payload.acceptanceMode || payload.acceptance_mode + : existingRule?.acceptance_mode, + ) + if (acceptanceMode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT && sharingEnabled) { + throw createHttpError('好友赠送验收模式不支持拼单,请先关闭拼单', { + statusCode: 400, + errorCode: 'work_product_rule_gift_sharing_conflict', + }) + } const hasManualSharingConfig = hasConfiguredSharingValue(payload.sharingTotalQuantity ?? payload.sharing_total_quantity) || hasConfiguredSharingValue(payload.sharingUnitReward ?? payload.sharingUnitRewardYuan) || @@ -351,6 +367,13 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { sharingAutoFromOrder, timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)), timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy), + acceptanceMode, + giftCooldownHours: + hasOwnPayload(payload, 'giftCooldownHours') || hasOwnPayload(payload, 'gift_cooldown_hours') + ? normalizeWorkOrderGiftCooldownHours( + payload.giftCooldownHours ?? payload.gift_cooldown_hours, + ) + : normalizeWorkOrderGiftCooldownHours(existingRule?.gift_cooldown_hours), requirementJson: JSON.stringify({ fields }), matchJson: JSON.stringify(match), sortOrder: normalizeInteger(payload.sortOrder, 100), diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index 6e85b38f..553b41de 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -15,6 +15,7 @@ export * from './worker-order-timeout-service.js' export * from './worker-order-realtime-service.js' export * from './worker-collect-service.js' export * from './worker-acceptance-service.js' +export * from './worker-gift-material-service.js' export * from './worker-my-order-service.js' export * from './worker-hall-action-service.js' export * from './worker-platform-defaults.js' @@ -33,6 +34,7 @@ export * from './admin-worker-cancel-service.js' export * from './admin-work-order-sharing-service.js' export * from './admin-work-order-realtime-service.js' export * from './admin-work-order-material-service.js' +export * from './admin-work-order-gift-service.js' export * from './admin-mock-work-order-service.js' export * from './admin-work-order-management-service.js' export * from './admin-worker-platform-summary-service.js' diff --git a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts index 16372578..e0f26980 100644 --- a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts +++ b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts @@ -1,4 +1,9 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { + isFriendGiftAcceptanceMode, + normalizeWorkOrderAcceptanceMode, + normalizeWorkOrderGiftCooldownHours, +} from '../../domain/work-order-acceptance-mode.js' import { createWorkProductMatchLog, createWorkOrder, @@ -95,10 +100,9 @@ export async function syncWorkerOrdersForSourceOrder( sharingTotalQuantity: Number(rule.sharing_total_quantity || 1), sharingUnitReward: Number(rule.sharing_unit_reward || 0), }) - const sharingEnabled = resolveWorkOrderSharingEnabled( - rule.sharing_enabled === true, - sharingTotalQuantity, - ) + const sharingEnabled = isFriendGiftAcceptanceMode(rule.acceptance_mode) + ? false + : resolveWorkOrderSharingEnabled(rule.sharing_enabled === true, sharingTotalQuantity) const now = nowIso() const materialComplete = fields.every((field) => !field.required) const workOrder = await createWorkOrder({ @@ -119,6 +123,8 @@ export async function syncWorkerOrdersForSourceOrder( sharingUnitReward, timeoutMinutes: Number(rule.timeout_minutes || 0), timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(), + acceptanceMode: normalizeWorkOrderAcceptanceMode(rule.acceptance_mode), + giftCooldownHours: normalizeWorkOrderGiftCooldownHours(rule.gift_cooldown_hours), materialJson: JSON.stringify({ source: { orderId: Number(order.id), diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index 5f3b8dcb..ccee2547 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -1,4 +1,12 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { + isFriendGiftAcceptanceMode, + normalizeWorkOrderAcceptanceMode, + normalizeWorkOrderGiftCooldownHours, + resolveWorkOrderGiftPhase, + WORK_ORDER_ACCEPTANCE_MODE, + WORK_ORDER_GIFT_PHASE, +} from '../../domain/work-order-acceptance-mode.js' import { type WorkCategoryRow, type WorkOrderCapabilities, @@ -49,6 +57,20 @@ export function mapWorkOrderMaterial(value: unknown) { } } +/** 读取好友赠送模式的打手本人资料;主页截图访问链接需要读取时重签。 */ +export function mapWorkOrderBoosterMaterial(value: unknown) { + const material = safeParseJson(value) + return { + fields: + material.fields && typeof material.fields === 'object' && !Array.isArray(material.fields) + ? (material.fields as Record) + : {}, + screenshots: normalizeUploadedFiles(material.screenshots).map(refreshUploadedFileUrls), + note: String(material.note || ''), + submittedAt: String(material.submittedAt || ''), + } +} + /** 统一计算后台使用的工单业务状态,避免拼单订单继续直接暴露母工单 open。 */ export function resolveWorkOrderOperationalStatus(workOrder: WorkOrderRow): string { const activeShareQuantity = Math.max(0, Number(workOrder.active_share_quantity || 0)) @@ -100,6 +122,9 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha const sharingFilled = workOrder.sharing_enabled === true && activeShareQuantity >= sharingTotalQuantity const status = resolveWorkOrderOperationalStatus(workOrder) + const acceptanceMode = normalizeWorkOrderAcceptanceMode(workOrder.acceptance_mode) + const isFriendGift = acceptanceMode === WORK_ORDER_ACCEPTANCE_MODE.FRIEND_GIFT + const giftPhase = String(workOrder.gift_phase || '').trim() const hallAvailable = workOrder.status === WORK_ORDER_STATUS.OPEN && (!workOrder.sharing_enabled || activeShareQuantity < sharingTotalQuantity) @@ -142,6 +167,18 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha canCancel: status === WORK_ORDER_STATUS.IN_PROGRESS && Boolean(workOrder.assigned_worker_id), canResolveProblem: workOrder.status === WORK_ORDER_STATUS.PROBLEM, canDeduct: workOrder.status === WORK_ORDER_STATUS.ACCEPTED, + canConfirmFriend: + isFriendGift && + workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS && + giftPhase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED, + canResetGiftFlow: + isFriendGift && + [WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes( + workOrder.status as never, + ) && + [WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED, WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN].includes( + giftPhase as never, + ), } return { workOrderId: Number(workOrder.id), @@ -155,6 +192,19 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow, shares?: WorkOrderSha status, rawStatus: workOrder.status, hallAvailable, + acceptanceMode, + ...(isFriendGift + ? { + gift: { + phase: resolveWorkOrderGiftPhase(workOrder), + rawPhase: giftPhase, + cooldownHours: normalizeWorkOrderGiftCooldownHours(workOrder.gift_cooldown_hours), + friendAddedAt: workOrder.friend_added_at, + giftAvailableAt: workOrder.gift_available_at, + boosterMaterial: mapWorkOrderBoosterMaterial(workOrder.booster_material_json), + }, + } + : {}), sharingProgress: { joinedQuantity: activeShareQuantity, pendingSubmissionCount: pendingShareCount, diff --git a/apps/backend/src/services/worker-platform/work-product-match-mappers.ts b/apps/backend/src/services/worker-platform/work-product-match-mappers.ts index 59859916..92af8c3c 100644 --- a/apps/backend/src/services/worker-platform/work-product-match-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-product-match-mappers.ts @@ -2,6 +2,10 @@ import { type WorkProductRuleMappingRow, type WorkProductRuleRow, } from '../../repositories/worker-platform/index.js' +import { + normalizeWorkOrderAcceptanceMode, + normalizeWorkOrderGiftCooldownHours, +} from '../../domain/work-order-acceptance-mode.js' import type { JsonObject } from '../../types/json.js' import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' import { safeParseJson } from '../admin/admin-query-utils.js' @@ -45,6 +49,8 @@ export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined) }, timeoutMinutes: Number(rule.timeout_minutes || 0), timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(), + acceptanceMode: normalizeWorkOrderAcceptanceMode(rule.acceptance_mode), + giftCooldownHours: normalizeWorkOrderGiftCooldownHours(rule.gift_cooldown_hours), requirement: { fields: normalizeRequirementFields(requirement.fields), }, diff --git a/apps/backend/src/services/worker-platform/worker-acceptance-service.ts b/apps/backend/src/services/worker-platform/worker-acceptance-service.ts index 98ae527c..1b94df64 100644 --- a/apps/backend/src/services/worker-platform/worker-acceptance-service.ts +++ b/apps/backend/src/services/worker-platform/worker-acceptance-service.ts @@ -1,4 +1,9 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { + isFriendGiftAcceptanceMode, + isWorkOrderGiftAvailable, + WORK_ORDER_GIFT_PHASE, +} from '../../domain/work-order-acceptance-mode.js' import { acceptWorkOrderAndSettle, acceptWorkOrderShareAndSettle, @@ -61,13 +66,20 @@ export async function submitWorkerOrderAcceptance( if (sharingShare) { return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session) } - const canAutoAcceptWithoutEvidence = canWorkerAutoAcceptWholeOrder({ - worker, - hasPersonalSharingShare: false, - }) + const canAutoAcceptWithoutEvidence = isFriendGiftAcceptanceMode(workOrder.acceptance_mode) + ? false + : canWorkerAutoAcceptWholeOrder({ + worker, + hasPersonalSharingShare: false, + }) const now = nowIso() + // 好友赠送单在“待确认好友”阶段是等后台操作,等待期不计打手超时。 + const awaitingFriendConfirm = + isFriendGiftAcceptanceMode(workOrder.acceptance_mode) && + workOrder.gift_phase === WORK_ORDER_GIFT_PHASE.MATERIAL_SUBMITTED if ( workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS && + !awaitingFriendConfirm && workOrder.deadline_at && new Date(workOrder.deadline_at).getTime() <= Date.now() ) { @@ -95,6 +107,18 @@ export async function submitWorkerOrderAcceptance( errorCode: 'work_order_submit_status_invalid', }) } + // 好友赠送模式:必须等后台确认加好友且冷却结束后,才能提交赠送凭证。 + if (isFriendGiftAcceptanceMode(workOrder.acceptance_mode)) { + if ( + workOrder.gift_phase !== WORK_ORDER_GIFT_PHASE.FRIEND_COUNTDOWN || + !isWorkOrderGiftAvailable(workOrder) + ) { + throw createHttpError('好友赠送冷却未结束,暂不能提交验收', { + statusCode: 409, + errorCode: 'work_order_gift_cooldown_active', + }) + } + } const { files, imageUrls, note } = resolveAcceptanceImages( payload, @@ -141,7 +165,11 @@ export async function submitWorkerOrderAcceptance( }), now, }) - if (worker.worker_type === 'internal' || canAutoAcceptWithoutEvidence) { + // 好友赠送模式必须由后台人工验收赠送凭证,内部打手与 VIP 免审核直通均不适用。 + if ( + !isFriendGiftAcceptanceMode(workOrder.acceptance_mode) && + (worker.worker_type === 'internal' || canAutoAcceptWithoutEvidence) + ) { const { order: acceptedOrder, failureReason } = await acceptWorkOrderAndSettle({ workOrderId: workOrder.id, unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays, diff --git a/apps/backend/src/services/worker-platform/worker-gift-material-service.ts b/apps/backend/src/services/worker-platform/worker-gift-material-service.ts new file mode 100644 index 00000000..b66a6899 --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-gift-material-service.ts @@ -0,0 +1,123 @@ +import { isFriendGiftAcceptanceMode } from '../../domain/work-order-acceptance-mode.js' +import { submitWorkOrderGiftMaterial } from '../../repositories/worker-platform/index.js' +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { + mapWorkOrderForWorker, + normalizeUploadedFiles, + resolveWorkerPermissions, +} from './mappers.js' +import { publishWorkOrderParticipantChange } from './worker-order-realtime-service.js' +import { + getRequiredWorkOrder, + getRequiredWorker, + requireActiveWorkerSession, + type WorkerSession, +} from './worker-session-context-service.js' + +/** 打手本人资料的固定字段:昵称/ID/区服必填,系统选填(安卓/苹果)。 */ +const BOOSTER_MATERIAL_REQUIRED_FIELDS = [ + { key: 'gameNickname', label: '游戏昵称' }, + { key: 'gameId', label: '游戏ID' }, + { key: 'serverZone', label: '区服' }, +] as const + +/** 好友赠送模式:打手接单后提交本人游戏资料与主页截图,等待后台核对并添加好友。 */ +export async function submitWorkerOrderGiftMaterial( + workOrderId: number | string, + payload: JsonObject = {}, + session: WorkerSession, +) { + requireActiveWorkerSession(session) + const workOrder = await getRequiredWorkOrder(workOrderId) + const worker = await getRequiredWorker(session.workerId) + if (!isFriendGiftAcceptanceMode(workOrder.acceptance_mode)) { + throw createHttpError('该订单不需要提交好友赠送资料', { + statusCode: 409, + errorCode: 'work_order_gift_mode_invalid', + }) + } + if (Number(workOrder.assigned_worker_id || 0) !== Number(session.workerId)) { + throw createHttpError('只能操作自己的订单', { + statusCode: 403, + errorCode: 'work_order_owner_required', + }) + } + + const now = nowIso() + const fieldsPayload = + payload.fields && typeof payload.fields === 'object' && !Array.isArray(payload.fields) + ? (payload.fields as Record) + : {} + const fields: Record = {} + for (const field of BOOSTER_MATERIAL_REQUIRED_FIELDS) { + const value = String(fieldsPayload[field.key] ?? '').trim() + if (!value) { + throw createHttpError(`请填写${field.label}`, { + statusCode: 400, + errorCode: 'work_order_gift_material_field_required', + }) + } + fields[field.key] = value + } + const system = String(fieldsPayload.system ?? '').trim() + if (system) fields.system = system + + const note = String(payload.note || '').trim() + if (note.length > 200) { + throw createHttpError('备注不能超过 200 个字符', { + statusCode: 400, + errorCode: 'work_order_gift_material_note_too_long', + }) + } + const screenshots = normalizeUploadedFiles(payload.screenshots) + if (screenshots.length === 0) { + throw createHttpError('请上传主页截图', { + statusCode: 400, + errorCode: 'work_order_gift_material_screenshot_required', + }) + } + + const { order, failureReason } = await submitWorkOrderGiftMaterial({ + workOrderId: Number(workOrder.id), + workerId: Number(session.workerId), + boosterMaterialJson: JSON.stringify({ + fields, + screenshots, + note, + submittedAt: now, + }), + now, + }) + if (failureReason === 'work_order_gift_phase_invalid') { + throw createHttpError('当前阶段不能提交资料,请刷新后重试', { + statusCode: 409, + errorCode: 'work_order_gift_phase_invalid', + }) + } + if (failureReason === 'work_order_status_invalid') { + throw createHttpError('当前订单状态不能提交资料', { + statusCode: 409, + errorCode: 'work_order_gift_status_invalid', + }) + } + if (failureReason === 'work_order_owner_required') { + throw createHttpError('只能操作自己的订单', { + statusCode: 403, + errorCode: 'work_order_owner_required', + }) + } + if (!order) { + throw createHttpError('提交好友赠送资料失败,请稍后重试', { + statusCode: 409, + errorCode: 'work_order_gift_material_submit_failed', + }) + } + await publishWorkOrderParticipantChange(Number(workOrder.id), { + workerIds: [Number(session.workerId)], + }) + return { + order: mapWorkOrderForWorker(order, resolveWorkerPermissions(worker)), + } +} diff --git a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx index d93da374..7fc21d05 100644 --- a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx @@ -208,6 +208,8 @@ export default function ProductRulesPanel() { : undefined, timeoutMinutes: rule.timeoutMinutes || 0, timeoutPolicy: rule.timeoutPolicy || 'reopen', + acceptanceMode: rule.acceptanceMode || 'standard', + giftCooldownHours: rule.giftCooldownHours || 72, fieldsText: formatRuleFields(rule.requirement?.fields || []), sortOrder: rule.sortOrder, enabled: rule.enabled, @@ -244,6 +246,8 @@ export default function ProductRulesPanel() { sharingTotalAmount?: number timeoutMinutes?: number timeoutPolicy?: string + acceptanceMode?: string + giftCooldownHours?: number fieldsText?: string enabled?: boolean autoCreate?: boolean @@ -251,6 +255,11 @@ export default function ProductRulesPanel() { }) { try { const unitMode = values.pricingMode === 'unit' + const isFriendGift = values.acceptanceMode === 'friend_gift' + if (isFriendGift && values.sharingEnabled === true) { + message.error('好友赠送验收模式不支持拼单,请先关闭拼单') + return + } const hasManualSharingOverride = form.isFieldTouched('sharingTotalQuantity') || form.isFieldTouched('sharingUnitReward') ? Number(values.sharingTotalQuantity || 0) > 0 || @@ -262,6 +271,8 @@ export default function ProductRulesPanel() { ruleId: editingRule?.ruleId, ruleKey: editingRule?.ruleKey || '', unitPrice: unitMode ? Number(values.unitPrice || 0) : 0, + acceptanceMode: isFriendGift ? 'friend_gift' : 'standard', + sharingEnabled: isFriendGift ? false : values.sharingEnabled === true, sharingAutoFromOrder: unitMode && values.sharingEnabled === true && !hasManualSharingOverride, }) @@ -638,6 +649,11 @@ export default function ProductRulesPanel() { 拼单 ) : null} + {row.acceptanceMode === 'friend_gift' ? ( + + 好友赠送 + + ) : null} ), @@ -728,6 +744,8 @@ export default function ProductRulesPanel() { enabled: true, autoCreate: true, sharingEnabled: false, + acceptanceMode: 'standard', + giftCooldownHours: 72, fieldsText: 'gameId:游戏编号\ngameNickname:游戏昵称\nsystem:系统#安卓,苹果\nserverZone:区服#QQ区,微信区', }} @@ -794,6 +812,53 @@ export default function ProductRulesPanel() { + {/* 验收模式:好友赠送模式与拼单互斥 */} +
+ + { + if (event.target.value === 'friend_gift') { + form.setFieldsValue({ sharingEnabled: false }) + } + }} + /> + + + + {({ getFieldValue }) => + getFieldValue('acceptanceMode') === 'friend_gift' ? ( + + + + ) : ( + } /> + ) + } + +
+ {/* Row 3: 超时策略 + 来源 + 平台 */}
@@ -825,6 +890,7 @@ export default function ProductRulesPanel() { {({ getFieldValue }) => { const sharingEnabled = getFieldValue('sharingEnabled') const unitMode = getFieldValue('pricingMode') === 'unit' + const friendGiftMode = getFieldValue('acceptanceMode') === 'friend_gift' return (
@@ -834,8 +900,13 @@ export default function ProductRulesPanel() { - - + + {sharingEnabled ? ( diff --git a/apps/frontend/src/pages/admin/panels/WorkOrderPanelParts.tsx b/apps/frontend/src/pages/admin/panels/WorkOrderPanelParts.tsx index f6eb426e..0c7c17c8 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrderPanelParts.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrderPanelParts.tsx @@ -72,10 +72,14 @@ export function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise Promise + + { + setAcceptanceMode(nextMode) + setPage(1) + }} + /> + {acceptanceMode === 'friend_gift' ? ( + + + + + + + + + + + 不区分 + 安卓 + 苹果 + + + + + + + + + + ) } diff --git a/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx b/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx index 773e7538..e559ce30 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx @@ -19,19 +19,21 @@ import { canRequestCancel, canSubmitAcceptance, canSubmitFeedback, + canSubmitGiftMaterial, formatMoney, - formatStatus, getAcceptanceSubmittedAt, getCopyOrderInfoItems, getDisplayOrderNo, getMaterialDetailItems, getMaterialScreenshots, + getOrderStatusDisplay, getUniqueAcceptanceFiles, getWorkerAcceptance, getWorkerDraft, + GiftCountdownTag, hasSubmittedAcceptance, + isFriendGiftOrder, isVipEvidencePending, - resolveStatusColor, } from './worker-order-utils' type NoteTheme = 'success' | 'processing' | 'danger' | 'warning' | 'purple' | 'default' @@ -39,6 +41,7 @@ type NoteTheme = 'success' | 'processing' | 'danger' | 'warning' | 'purple' | 'd export function buildWorkerOrderTableColumns({ onOpenDetail, onOpenAcceptance, + onOpenGiftMaterial, onRemindAcceptance, onOpenCancelRequest, onOpenFeedback, @@ -48,6 +51,7 @@ export function buildWorkerOrderTableColumns({ }: { onOpenDetail: (order: WorkOrder) => void onOpenAcceptance: (order: WorkOrder) => void + onOpenGiftMaterial: (order: WorkOrder) => void onRemindAcceptance: (order: WorkOrder) => void onOpenCancelRequest: (order: WorkOrder) => void onOpenFeedback: (order: WorkOrder) => void @@ -96,25 +100,33 @@ export function buildWorkerOrderTableColumns({ { title: '状态', width: 130, - render: (_, row) => ( -
- - {formatStatus(row.status)} - {row.status === 'in_progress' && row.deadlineAt ? ( - + render: (_, row) => { + const statusDisplay = getOrderStatusDisplay(row) + return ( +
+ + {statusDisplay.label} + {row.status === 'in_progress' && row.deadlineAt ? ( + + ) : null} + {isFriendGiftOrder(row) && + row.status === 'in_progress' && + row.gift?.phase === 'friend_countdown' ? ( + + ) : null} + + {row.status === 'problem' && String(row.problemNote || '').trim() ? ( + + {row.problemNote} + ) : null} - - {row.status === 'problem' && String(row.problemNote || '').trim() ? ( - - {row.problemNote} - - ) : null} -
- ), +
+ ) + }, }, { title: '奖励 / 押金', @@ -205,6 +217,11 @@ export function buildWorkerOrderTableColumns({ onClick={() => onOpenDetail(row)} /> + {canSubmitGiftMaterial(row) ? ( + + ) : null} {canEditAcceptanceImages(row) ? (