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

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
@@ -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)),
}
}