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

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
@@ -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_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<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,