新增好友赠送验收模式支持打手资料提交加好友冷却与模板配置

This commit is contained in:
yml2213
2026-08-22 11:19:49 +08:00
parent ba35d27976
commit 6aec4a7b40
36 changed files with 1646 additions and 52 deletions
@@ -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 天),建单时拷贝到工单';
@@ -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
}
@@ -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'
@@ -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<string, unknown>
match_json: string | Record<string, unknown>
sort_order: number
@@ -360,6 +362,15 @@ export type WorkOrderRow = {
deadline_at: string | null
timeout_minutes: number
timeout_policy: string
/** 验收模式:standard / friend_giftfriend_gift 建单时从接单模板拷贝。 */
acceptance_mode: string
/** 好友赠送阶段:material_required / material_submitted / friend_countdowngift_ready 实时计算。 */
gift_phase: string
/** 打手本人资料(昵称/ID/区服/系统 + 主页截图),仅 friend_gift 模式使用。 */
booster_material_json: string | Record<string, unknown>
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
@@ -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
`,
@@ -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<WorkOrderRow>(
'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<WorkOrderRow>(
'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<WorkOrderRow>(
'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,
}
})
}
@@ -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
`,
@@ -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
`,
@@ -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<WorkOrderRow | null> {
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<Work
product_name, category_id, status, reward_amount, required_deposit_amount,
deposit_threshold_amount, sharing_enabled, sharing_total_quantity,
sharing_unit_reward, timeout_minutes, timeout_policy,
acceptance_mode, gift_phase, gift_cooldown_hours,
material_json, requirement_json, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
$12, $13, $14, $15, $16, $17, $18::jsonb, $19::jsonb, $20, $21
$12, $13, $14, $15, $16, $17,
$18, $19, $20,
$21::jsonb, $22::jsonb, $23, $24
)
RETURNING id
`,
@@ -36,6 +47,9 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
toPositiveInteger(input.sharingUnitReward, 0),
toPositiveInteger(input.timeoutMinutes, 0),
String(input.timeoutPolicy || 'reopen').trim() || 'reopen',
acceptanceMode,
isFriendGift ? WORK_ORDER_GIFT_PHASE.MATERIAL_REQUIRED : '',
normalizeWorkOrderGiftCooldownHours(input.giftCooldownHours),
input.materialJson,
input.requirementJson,
input.now,
@@ -1,6 +1,10 @@
import { query } from '../../db/client.js'
import type { ListInput, WorkOrderRow, WorkOrderStatistics } from './types.js'
import type { PoolClient } from 'pg'
import {
normalizeWorkOrderAcceptanceMode,
WORK_ORDER_GIFT_PHASE,
} from '../../domain/work-order-acceptance-mode.js'
export const WORK_ORDER_SELECT = `
SELECT
wo.*,
@@ -167,6 +171,8 @@ export async function listWorkOrders({
visibleAfterIso = '',
excludeFilledSharing = false,
vipEvidencePending = false,
acceptanceMode = '',
giftPhase = '',
useOperationalStatus = false,
pinnedFirst = false,
sort = 'id_desc',
@@ -186,6 +192,8 @@ export async function listWorkOrders({
visibleAfterIso,
excludeFilledSharing,
vipEvidencePending,
acceptanceMode,
giftPhase,
useOperationalStatus,
createdFrom,
createdTo,
@@ -382,7 +390,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
}))
}
/** 查询已超过接单时限、仍待处置的工单。 */
/** 查询已超过接单时限、仍待处置的工单。好友赠送单在“待确认好友”阶段等待后台操作,不计打手超时。 */
export async function listOverdueWorkOrders({
limit = 50,
workerId = 0,
@@ -399,6 +407,7 @@ export async function listOverdueWorkOrders({
WHERE wo.status = 'in_progress'
AND wo.deadline_at IS NOT NULL
AND wo.deadline_at < NOW()
AND NOT (wo.acceptance_mode = 'friend_gift' AND wo.gift_phase = 'material_submitted')
${workerClause}
ORDER BY wo.deadline_at ASC
LIMIT $${params.length}`,
@@ -522,6 +531,8 @@ export function buildWorkOrderWhere({
visibleAfterIso = '',
excludeFilledSharing = false,
vipEvidencePending = false,
acceptanceMode = '',
giftPhase = '',
useOperationalStatus = false,
createdFrom = '',
createdTo = '',
@@ -685,6 +696,25 @@ export function buildWorkOrderWhere({
params.push(categoryId)
filters.push(`wo.category_id = $${params.length}`)
}
if (acceptanceMode) {
params.push(normalizeWorkOrderAcceptanceMode(acceptanceMode))
filters.push(`wo.acceptance_mode = $${params.length}`)
}
if (giftPhase) {
const normalizedGiftPhase = String(giftPhase).trim()
if (normalizedGiftPhase === WORK_ORDER_GIFT_PHASE.GIFT_READY) {
// gift_ready 是冷却到点后的实时阶段,由时间条件计算而不是落库状态。
filters.push(`(
wo.acceptance_mode = 'friend_gift'
AND wo.gift_phase = 'friend_countdown'
AND wo.gift_available_at IS NOT NULL
AND wo.gift_available_at <= NOW()
)`)
} else if (Object.values(WORK_ORDER_GIFT_PHASE).includes(normalizedGiftPhase as never)) {
params.push(normalizedGiftPhase)
filters.push(`wo.acceptance_mode = 'friend_gift' AND wo.gift_phase = $${params.length}`)
}
}
if (hallCandidateLimit > 0) {
params.push(hallCandidateLimit)
filters.push(`wo.id IN (${buildHallCandidateIdsSql(`$${params.length}`)})`)
@@ -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,
@@ -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<string, unknown>) : {},
}),
},
),
)
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<string, unknown>) : {},
}),
},
),
)
router.get(
'/worker-platform/orders/:workOrderId/sharing',
requireAdminRoles(['admin', 'operator', 'support']),
+19
View File
@@ -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,
@@ -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,
@@ -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) }
}
@@ -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 } : {}),
@@ -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),
@@ -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'
@@ -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),
@@ -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<string, string>)
: {},
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,
@@ -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),
},
@@ -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,
@@ -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<string, unknown>)
: {}
const fields: Record<string, string> = {}
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)),
}
}