feat(consignment): 寄卖配置价格/匹配/超时/收货字段全面改造

- 价格:固定价格改为模板价固定下调,移除绝对固定价;没有模板价时统一阻止发布
- 配置面板:按下拉选项动态显隐,超时与降价提示拆成两张卡片,超时单位改为小时(默认 0.5)
- 匹配:新增关闭匹配并设为默认,移除自动匹配开关;关闭后新单直接进大厅
- 超时:新增重新匹配退/扣押金,回内部匹配池并排除原卖家与原库存(迁移 147)
- 日志:新增 consignment_match_logs,自动/手动匹配成功、失败、跳过统一留痕,后台新增匹配日志抽屉
- 打手端:顶部分类下拉替换多 tab,默认苏尔南手枪;联系方式与游戏凭据改为动态字段(默认 7 项,手机号必填)
- 降价提示默认开启、默认 24 小时
This commit is contained in:
2026-11-06 13:32:24 +08:00
parent e0a85359b6
commit 3aa01136a5
31 changed files with 2311 additions and 568 deletions
@@ -325,6 +325,14 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) {
const profileConfig = row.profile_snapshot_json
? normalizeConsignmentProfileConfig(row.profile_snapshot_json)
: null
const contentJson =
row.content_json && typeof row.content_json === 'object'
? (row.content_json as Record<string, unknown>)
: safeParseJson(row.content_json)
const intakeValues =
contentJson?.intakeValues && typeof contentJson.intakeValues === 'object'
? (contentJson.intakeValues as Record<string, unknown>)
: {}
return {
id: Number(row.id),
submissionNo: row.submission_no,
@@ -334,6 +342,9 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) {
profileId: row.profile_id ? Number(row.profile_id) : null,
profileVersionId: row.profile_version_id ? Number(row.profile_version_id) : null,
specs: profileConfig?.specs || [],
/** 收货信息字段定义(按发布快照);历史提报没有快照时返回空,前端回退旧列展示。 */
intakeFields: profileConfig?.intake.fields || [],
intakeValues,
specValues: parseConsignmentSpecValues(row.spec_values_json),
specText: row.spec_text || '',
priceCapFen: Number(row.price_cap_fen || 0),
@@ -349,10 +360,7 @@ export function mapConsignmentSubmission(row: ConsignmentSubmissionRow) {
sellerPaymentInfo: row.seller_payment_info || '',
sellerPhone: row.seller_phone || '',
contactInfo: row.contact_info || '',
contentJson:
row.content_json && typeof row.content_json === 'object'
? (row.content_json as Record<string, unknown>)
: safeParseJson(row.content_json),
contentJson,
screenshotsJson: refreshScreenshotList(
Array.isArray(row.screenshots_json)
? row.screenshots_json
@@ -7,6 +7,7 @@ import {
extractPeacekeeperSpecs,
listConsignmentMatchOptions,
} from './consignment-matching-service.js'
import { isRematchExcludedInventory } from './consignment-match-engine-service.js'
import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js'
function inventory(overrides: Partial<ConsignmentInventoryRow> = {}): ConsignmentInventoryRow {
@@ -41,6 +42,37 @@ test('手动指定现货:连不符合规格的也要列出来,但必须标
assert.ok(options[2]!.conflicts.some((item) => item.includes('系列')))
})
test('重新匹配排除:原库存与原卖家都不再参与,且匹配成功后清空', () => {
const excluded = {
rematch_excluded_worker_id: 7,
rematch_excluded_inventory_id: 11,
} as never
assert.equal(isRematchExcludedInventory(excluded, { id: 11, worker_id: 99 }), true)
assert.equal(isRematchExcludedInventory(excluded, { id: 12, worker_id: 7 }), true)
assert.equal(isRematchExcludedInventory(excluded, { id: 12, worker_id: 8 }), false)
assert.equal(isRematchExcludedInventory({} as never, { id: 12, worker_id: 7 }), false)
})
test('超时重新匹配与匹配日志:链路必须接线到调度与后台接口', async () => {
const { readFile } = await import('node:fs/promises')
const [timeoutSrc, engineSrc, routes, logSrc] = await Promise.all([
readFile(new URL('./worker-order-timeout-service.ts', import.meta.url), 'utf8'),
readFile(new URL('./consignment-match-engine-service.ts', import.meta.url), 'utf8'),
readFile(new URL('../../routes/admin/worker-platform/consignment.ts', import.meta.url), 'utf8'),
readFile(new URL('./consignment-match-log-service.ts', import.meta.url), 'utf8'),
])
assert.ok(timeoutSrc.includes('startConsignmentRematch('), '超时服务必须触发重新匹配')
assert.ok(timeoutSrc.includes('isRematchTimeoutPolicy(policy)'), '必须识别重新匹配策略')
assert.ok(
engineSrc.includes('rematch_excluded_worker_id = $3') &&
engineSrc.includes('rematch_excluded_worker_id = NULL'),
'重新匹配必须写入并清空排除字段',
)
assert.ok(engineSrc.includes('recordConsignmentMatchLog('), '自动/手动匹配必须写日志')
assert.ok(routes.includes("'/worker-platform/consignment/match-logs'"), '必须提供匹配日志接口')
assert.ok(logSrc.includes('INSERT INTO consignment_match_logs'), '日志表必须落库')
})
test('字段级差异:订单没写规格时不报差异(那是"非精确",不是冲突)', () => {
const specs = extractPeacekeeperSpecs('和平精英 暗潮 2钻', {})
assert.deepEqual(describeConsignmentSpecDifferences(specs, inventory({})), [])
@@ -37,12 +37,26 @@ import {
extractPeacekeeperSpecs,
} from './consignment-matching-service.js'
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
import { recordConsignmentMatchLog } from './consignment-match-log-service.js'
type MatchCandidate = {
workOrder: WorkOrderRow
profile: ConsignmentProfileSnapshot | null
}
/** 自动匹配的「无候选」跳过日志按工单做 10 分钟节流,避免 30 秒调度刷爆日志表。 */
const matchSkipLogThrottle = new Map<string, number>()
const MATCH_SKIP_LOG_WINDOW_MS = 10 * 60 * 1000
function shouldLogMatchSkip(key: string): boolean {
const now = Date.now()
const last = matchSkipLogThrottle.get(key) || 0
if (now - last < MATCH_SKIP_LOG_WINDOW_MS) return false
matchSkipLogThrottle.set(key, now)
if (matchSkipLogThrottle.size > 5000) matchSkipLogThrottle.clear()
return true
}
function parseMaterial(value: unknown): Record<string, unknown> {
const parsed = safeParseJson(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
@@ -75,6 +89,22 @@ export function inventoryMatchesProfile(
return !profile.config.specs.some((spec) => !isBuiltInConsignmentSpecField(spec))
}
/**
* 超时「重新匹配」排除的原卖家/原库存:
* 同一单重新进入匹配池后,不允许原卖家或原库存立刻再次匹配,避免死循环。
*/
export function isRematchExcludedInventory(
workOrder: Pick<WorkOrderRow, 'rematch_excluded_worker_id' | 'rematch_excluded_inventory_id'>,
inventory: Pick<ConsignmentInventoryRow, 'id' | 'worker_id'>,
): boolean {
const excludedInventoryId = Number(workOrder.rematch_excluded_inventory_id || 0)
const excludedWorkerId = Number(workOrder.rematch_excluded_worker_id || 0)
if (!excludedInventoryId && !excludedWorkerId) return false
if (excludedInventoryId && Number(inventory.id) === excludedInventoryId) return true
if (excludedWorkerId && Number(inventory.worker_id || 0) === excludedWorkerId) return true
return false
}
function compareInventoryCandidates(
a: { inventory: ConsignmentInventoryRow; evaluation: InventoryMatchEvaluation },
b: { inventory: ConsignmentInventoryRow; evaluation: InventoryMatchEvaluation },
@@ -236,6 +266,8 @@ export async function enterConsignmentInternalMatching(
if (!workOrder || workOrder.status !== WORK_ORDER_STATUS.UNASSIGNED) return false
const profile = await resolveProfileForWorkOrder(workOrder)
if (!profile) return false
// 关闭匹配:新单直接进大厅,不进入内部匹配队列。
if (profile.config.matching.mode === 'off') return false
const delayMinutes = Math.max(0, profile.config.internalMatch.delayMinutes)
if (delayMinutes <= 0 && !profile.config.internalMatch.fallbackToHall) return false
const deadline = addMinutesIso(delayMinutes, now)
@@ -283,6 +315,8 @@ export async function reenterConsignmentInternalMatching(
}
const profile = await resolveProfileForWorkOrder(workOrder)
if (!profile) return false
// 关闭匹配:退回大厅后不再重新排队等内部库存。
if (profile.config.matching.mode === 'off') return false
const delayMinutes = Math.max(0, profile.config.internalMatch.delayMinutes)
if (delayMinutes <= 0) return false
const deadline = addMinutesIso(delayMinutes, now)
@@ -318,6 +352,91 @@ export async function reenterConsignmentInternalMatching(
return true
}
/**
* 超时「重新匹配」:把工单重新放回内部匹配池,并排除原卖家/原库存。
* 分类关闭匹配或找不到配置时,退回大厅,避免工单卡在无人处理的状态。
*/
export async function startConsignmentRematch(
workOrderId: number,
input: { excludeWorkerId?: number | null; excludeInventoryId?: number | null } = {},
now = nowIso(),
): Promise<boolean> {
const workOrder = await getWorkOrderById(workOrderId)
if (!workOrder) return false
const profile = await resolveProfileForWorkOrder(workOrder)
const fallbackToHall = async () => {
await query(
`
UPDATE work_orders
SET status = 'open',
assigned_worker_id = NULL,
assigned_at = NULL,
deadline_at = NULL,
published_at = COALESCE(published_at, $1),
internal_match_deadline_at = NULL,
hall_visible_at = NULL,
updated_at = $1
WHERE id = $2 AND status IN ('internal_matching', 'open', 'unassigned', 'in_progress')
`,
[now, workOrderId],
)
return false
}
if (!profile || profile.config.matching.mode === 'off') {
return fallbackToHall()
}
const delayMinutes = Math.max(1, Number(profile.config.internalMatch.delayMinutes || 30))
const deadline = addMinutesIso(delayMinutes, now)
const result = await query<WorkOrderRow>(
`
UPDATE work_orders
SET status = 'internal_matching',
assigned_worker_id = NULL,
assigned_at = NULL,
deadline_at = NULL,
consignment_profile_id = $1,
internal_match_deadline_at = $2,
hall_visible_at = $2,
internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1,
rematch_excluded_worker_id = $3,
rematch_excluded_inventory_id = $4,
updated_at = $5
WHERE id = $6 AND status IN ('internal_matching', 'open', 'unassigned', 'in_progress')
RETURNING *
`,
[
profile.profileId,
deadline,
input.excludeWorkerId ? Number(input.excludeWorkerId) : null,
input.excludeInventoryId ? Number(input.excludeInventoryId) : null,
now,
workOrderId,
],
)
if (!result.rows[0]) return false
await query(
`
INSERT INTO work_order_events (
work_order_id, actor_type, actor_id, event_type, from_status, to_status,
payload_json, created_at
) VALUES ($1, 'system', 'consignment_match', 'consignment_rematch_started', $2, 'internal_matching', $3::jsonb, $4)
`,
[
workOrderId,
workOrder.status,
JSON.stringify({
profileId: profile.profileId,
delayMinutes,
deadline,
excludedWorkerId: input.excludeWorkerId ? Number(input.excludeWorkerId) : null,
excludedInventoryId: input.excludeInventoryId ? Number(input.excludeInventoryId) : null,
}),
now,
],
)
return true
}
function pickCandidateInventories(
workOrder: WorkOrderRow,
profile: ConsignmentProfileSnapshot | null,
@@ -329,6 +448,7 @@ function pickCandidateInventories(
return inventories
.filter((inventory) => inventoryMatchesProfile(inventory, profile))
.filter((inventory) => Number(inventory.remaining_quantity ?? 1) > 0)
.filter((inventory) => !isRematchExcludedInventory(workOrder, inventory))
.map((inventory) => ({
inventory,
evaluation: evaluateInventoryMatch(workOrder, inventory, profile),
@@ -447,6 +567,8 @@ async function applyWholeOrderMatch(
hall_visible_at = NULL,
internal_match_deadline_at = NULL,
internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1,
rematch_excluded_worker_id = NULL,
rematch_excluded_inventory_id = NULL,
updated_at = $3
WHERE id = $9 AND status = 'internal_matching'
RETURNING *
@@ -627,6 +749,8 @@ async function applySharingMatch(
internal_match_deadline_at = CASE WHEN $1 = 'in_progress' THEN NULL ELSE internal_match_deadline_at END,
hall_visible_at = CASE WHEN $1 = 'in_progress' THEN NULL ELSE hall_visible_at END,
internal_match_attempts = COALESCE(internal_match_attempts, 0) + 1,
rematch_excluded_worker_id = CASE WHEN $1 = 'in_progress' THEN NULL ELSE rematch_excluded_worker_id END,
rematch_excluded_inventory_id = CASE WHEN $1 = 'in_progress' THEN NULL ELSE rematch_excluded_inventory_id END,
updated_at = $3
WHERE id = $4
`,
@@ -714,19 +838,41 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
)
let matched = 0
for (const workOrder of orders.rows) {
const startedAt = Date.now()
const baseLog = {
workOrderId: Number(workOrder.id),
workOrderNo: workOrder.work_order_no || '',
triggerType: 'auto' as const,
actorType: 'system',
actorId: 'consignment_match',
}
let profileRef: ConsignmentProfileSnapshot | null = null
try {
const profile = await resolveProfileForWorkOrder(workOrder)
profileRef = profile
if (!profile) continue
if (profile.config.matching.mode !== 'auto' || !profile.config.matching.autoMatchEnabled) {
continue
}
const logBase = { ...baseLog, profileId: profile.profileId, categoryId: profile.categoryId }
if (profile.config.matching.mode !== 'auto') continue
const inventories = await listActiveConsignmentInventoriesForMatching()
const matching = profile.config.matching
const candidates = pickCandidateInventories(workOrder, profile, inventories, {
allowPartial: matching.exactOnly === false,
preferLowReward: matching.preferLowReward !== false,
})
if (candidates.length === 0) continue
if (candidates.length === 0) {
if (shouldLogMatchSkip(`no_candidate:${workOrder.id}`)) {
await recordConsignmentMatchLog({
...logBase,
result: 'skipped',
reasonCode: 'no_candidate',
reasonText: '没有符合条件的在库库存',
candidateCount: inventories.length,
durationMs: Date.now() - startedAt,
payload: { inventoryCount: inventories.length },
})
}
continue
}
if (workOrder.sharing_enabled && Number(workOrder.sharing_total_quantity || 1) > 1) {
const context: ConsignmentMatchContext = {
@@ -738,7 +884,31 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
]),
}
const created = await applySharingMatch({ workOrder, profile }, candidates, context)
if (created > 0) matched += 1
if (created > 0) {
matched += 1
await recordConsignmentMatchLog({
...logBase,
result: 'matched',
reasonCode: 'auto_sharing_matched',
candidateCount: candidates.length,
matchedCount: created,
durationMs: Date.now() - startedAt,
payload: {
partial: context.partial,
specConflicts: context.specConflicts,
inventoryIds: candidates.map(({ inventory }) => Number(inventory.id)),
},
})
} else {
await recordConsignmentMatchLog({
...logBase,
result: 'failed',
reasonCode: 'sharing_not_applied',
reasonText: '拼单匹配未创建任何份额(库存可能已被其它任务占用)',
candidateCount: candidates.length,
durationMs: Date.now() - startedAt,
})
}
} else {
// 整单必须由一条库存一次性满足订单总数量,不能只占 1 件。
const requiredQuantity = Math.max(1, Number(workOrder.sharing_total_quantity || 1))
@@ -747,16 +917,63 @@ export async function autoMatchConsignmentWorkOrders(options: { limit?: number }
const available = Number(candidate.inventory.remaining_quantity ?? 1)
return available >= requiredQuantity && (!friendGift || available === requiredQuantity)
})
if (!chosen) continue
if (!chosen) {
if (shouldLogMatchSkip(`quantity_insufficient:${workOrder.id}`)) {
await recordConsignmentMatchLog({
...logBase,
result: 'skipped',
reasonCode: 'quantity_insufficient',
reasonText: `没有库存能一次满足订单数量 ${requiredQuantity}`,
candidateCount: candidates.length,
durationMs: Date.now() - startedAt,
payload: { requiredQuantity },
})
}
continue
}
const applied = await applyWholeOrderMatch({ workOrder, profile }, chosen, {
matchType: 'auto',
partial: !chosen.evaluation.exact,
specConflicts: [...chosen.evaluation.conflicts, ...chosen.evaluation.unknowns],
})
if (applied) matched += 1
if (applied) {
matched += 1
await recordConsignmentMatchLog({
...logBase,
result: 'matched',
reasonCode: 'auto_matched',
candidateCount: candidates.length,
matchedCount: 1,
durationMs: Date.now() - startedAt,
payload: {
partial: !chosen.evaluation.exact,
specConflicts: [...chosen.evaluation.conflicts, ...chosen.evaluation.unknowns],
inventoryId: Number(chosen.inventory.id),
},
})
} else {
await recordConsignmentMatchLog({
...logBase,
result: 'failed',
reasonCode: 'apply_failed',
reasonText: '匹配事务未生效(工单状态或库存数量已变化)',
candidateCount: candidates.length,
durationMs: Date.now() - startedAt,
payload: { inventoryId: Number(chosen.inventory.id) },
})
}
}
} catch {
} catch (error) {
// 单笔失败不影响同批其它工单;下一次调度会重试。
await recordConsignmentMatchLog({
...baseLog,
profileId: profileRef?.profileId ?? null,
categoryId: profileRef?.categoryId ?? null,
result: 'failed',
reasonCode: 'exception',
reasonText: error instanceof Error ? error.message : String(error),
durationMs: Date.now() - startedAt,
})
}
}
return { scanned: orders.rows.length, matched }
@@ -860,6 +1077,11 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
}
}
function extractConsignmentErrorCode(error: unknown): string {
const record = error as { errorCode?: unknown; code?: unknown } | null
return String(record?.errorCode || record?.code || 'exception').slice(0, 80)
}
/** 后台手动匹配:选中一条库存走整单;拼单工单可多选库存,每份一条份额。 */
export async function matchConsignmentWorkOrderManually(input: {
workOrderId: number
@@ -868,6 +1090,53 @@ export async function matchConsignmentWorkOrderManually(input: {
/** 允许人工选择非精确库存(差异/冲突),需同时填写原因 */
allowNonExact?: boolean
reason?: string
}): Promise<{ matched: boolean; matchedCount: number }> {
const startedAt = Date.now()
const workOrder = await getWorkOrderById(input.workOrderId).catch(() => null)
const logBase = {
workOrderId: Number(input.workOrderId),
workOrderNo: workOrder?.work_order_no || '',
profileId: workOrder?.consignment_profile_id ? Number(workOrder.consignment_profile_id) : null,
categoryId: workOrder?.category_id ? Number(workOrder.category_id) : null,
triggerType: 'manual' as const,
actorType: 'admin',
actorId: String(input.actorName || '').trim() || 'admin',
payload: {
inventoryIds: input.inventoryIds,
allowNonExact: Boolean(input.allowNonExact),
reason: String(input.reason || '').trim(),
},
}
try {
const result = await runManualConsignmentMatch(input)
await recordConsignmentMatchLog({
...logBase,
result: result.matched ? 'matched' : 'failed',
reasonCode: result.matched ? 'manual_matched' : 'manual_not_applied',
reasonText: result.matched ? '' : '手动匹配未生效(工单状态或库存数量已变化)',
matchedCount: result.matchedCount,
durationMs: Date.now() - startedAt,
})
return result
} catch (error) {
await recordConsignmentMatchLog({
...logBase,
result: 'failed',
reasonCode: extractConsignmentErrorCode(error),
reasonText: error instanceof Error ? error.message : String(error),
durationMs: Date.now() - startedAt,
})
throw error
}
}
async function runManualConsignmentMatch(input: {
workOrderId: number
inventoryIds: number[]
actorName?: string
/** 允许人工选择非精确库存(差异/冲突),需同时填写原因 */
allowNonExact?: boolean
reason?: string
}): Promise<{ matched: boolean; matchedCount: number }> {
const workOrder = await getWorkOrderById(input.workOrderId)
if (!workOrder) {
@@ -888,7 +1157,9 @@ export async function matchConsignmentWorkOrderManually(input: {
const profile = await resolveProfileForWorkOrder(workOrder)
const inventories = (await listActiveConsignmentInventoriesForMatching()).filter(
(inventory) =>
inventoryMatchesProfile(inventory, profile) && Number(inventory.remaining_quantity ?? 1) > 0,
inventoryMatchesProfile(inventory, profile) &&
Number(inventory.remaining_quantity ?? 1) > 0 &&
!isRematchExcludedInventory(workOrder, inventory),
)
const selected = input.inventoryIds
.map((id) => inventories.find((inventory) => Number(inventory.id) === Number(id)))
@@ -0,0 +1,189 @@
import { query } from '../../db/client.js'
import { nowIso } from '../../utils/time.js'
export type ConsignmentMatchLogTrigger = 'auto' | 'manual' | 'rematch'
export type ConsignmentMatchLogResult = 'matched' | 'failed' | 'skipped'
export type ConsignmentMatchLogRow = {
id: number
work_order_id: number | null
work_order_no: string
profile_id: number | null
category_id: number | null
trigger_type: string
result: string
reason_code: string
reason_text: string
candidate_count: number
matched_count: number
actor_type: string
actor_id: string
duration_ms: number
payload_json: unknown
created_at: string
}
export type RecordConsignmentMatchLogInput = {
workOrderId?: number | null
workOrderNo?: string
profileId?: number | null
categoryId?: number | null
triggerType: ConsignmentMatchLogTrigger
result: ConsignmentMatchLogResult
reasonCode?: string
reasonText?: string
candidateCount?: number
matchedCount?: number
actorType?: string
actorId?: string
durationMs?: number
payload?: Record<string, unknown>
createdAt?: string
}
/** 写匹配日志:任何失败都不影响主流程。 */
export async function recordConsignmentMatchLog(
input: RecordConsignmentMatchLogInput,
): Promise<void> {
try {
await query(
`
INSERT INTO consignment_match_logs (
work_order_id, work_order_no, profile_id, category_id, trigger_type, result,
reason_code, reason_text, candidate_count, matched_count,
actor_type, actor_id, duration_ms, payload_json, created_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10,
$11, $12, $13, $14::jsonb, $15
)
`,
[
input.workOrderId ?? null,
String(input.workOrderNo || '').slice(0, 80),
input.profileId ?? null,
input.categoryId ?? null,
input.triggerType,
input.result,
String(input.reasonCode || '').slice(0, 80),
String(input.reasonText || '').slice(0, 500),
Math.max(0, Math.round(Number(input.candidateCount || 0))),
Math.max(0, Math.round(Number(input.matchedCount || 0))),
String(input.actorType || 'system').slice(0, 30),
String(input.actorId || '').slice(0, 120),
Math.max(0, Math.round(Number(input.durationMs || 0))),
JSON.stringify(input.payload || {}),
input.createdAt || nowIso(),
],
)
} catch {
// 日志失败不影响匹配主流程。
}
}
function mapLog(row: ConsignmentMatchLogRow) {
let payload: Record<string, unknown> = {}
if (row.payload_json && typeof row.payload_json === 'object') {
payload = row.payload_json as Record<string, unknown>
} else if (typeof row.payload_json === 'string') {
try {
const parsed = JSON.parse(row.payload_json)
payload = parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {}
} catch {
payload = {}
}
}
return {
logId: Number(row.id),
workOrderId: row.work_order_id ? Number(row.work_order_id) : null,
workOrderNo: row.work_order_no || '',
profileId: row.profile_id ? Number(row.profile_id) : null,
categoryId: row.category_id ? Number(row.category_id) : null,
triggerType: row.trigger_type as ConsignmentMatchLogTrigger,
result: row.result as ConsignmentMatchLogResult,
reasonCode: row.reason_code || '',
reasonText: row.reason_text || '',
candidateCount: Number(row.candidate_count || 0),
matchedCount: Number(row.matched_count || 0),
actorType: row.actor_type || '',
actorId: row.actor_id || '',
durationMs: Number(row.duration_ms || 0),
payload,
createdAt: row.created_at,
}
}
export async function listConsignmentMatchLogs(
input: {
page?: unknown
pageSize?: unknown
profileId?: unknown
triggerType?: unknown
result?: unknown
keyword?: unknown
from?: unknown
to?: unknown
} = {},
) {
const page = Math.max(1, Number(input.page || 1))
const pageSize = Math.min(100, Math.max(1, Number(input.pageSize || 20)))
const values: unknown[] = []
const where: string[] = []
const profileId = Number(input.profileId || 0)
if (profileId > 0) {
values.push(profileId)
where.push(`profile_id = $${values.length}`)
}
const triggerType = String(input.triggerType || '').trim()
if (triggerType === 'auto' || triggerType === 'manual' || triggerType === 'rematch') {
values.push(triggerType)
where.push(`trigger_type = $${values.length}`)
}
const result = String(input.result || '').trim()
if (result === 'matched' || result === 'failed' || result === 'skipped') {
values.push(result)
where.push(`result = $${values.length}`)
}
const keyword = String(input.keyword || '').trim()
if (keyword) {
values.push(`%${keyword}%`)
where.push(`(work_order_no ILIKE $${values.length} OR work_order_id::text = $${values.length})`)
}
const from = String(input.from || '').trim()
if (from) {
values.push(from)
where.push(`created_at >= $${values.length}::timestamptz`)
}
const to = String(input.to || '').trim()
if (to) {
values.push(to)
where.push(`created_at <= $${values.length}::timestamptz`)
}
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
const countResult = await query<{ total: number | string }>(
`SELECT COUNT(*)::int AS total FROM consignment_match_logs ${whereSql}`,
values,
)
const total = Number(countResult.rows[0]?.total || 0)
const listValues = [...values, pageSize, (page - 1) * pageSize]
const rows = await query<ConsignmentMatchLogRow>(
`
SELECT *
FROM consignment_match_logs
${whereSql}
ORDER BY id DESC
LIMIT $${values.length + 1} OFFSET $${values.length + 2}
`,
listValues,
)
return {
items: rows.rows.map(mapLog),
total,
page,
pageSize,
}
}
@@ -91,10 +91,10 @@ const suffix = `${Date.now().toString(36)}${Math.floor(Math.random() * 1000)}`
const pricing = {
priceType: 'template_discount' as const,
discount: 10,
fixedPriceFen: 0,
minusFen: 0,
allowWorkerPrice: true,
minPriceFen: 1000,
missingPricePolicy: 'manual' as const,
missingPricePolicy: 'block' as const,
}
const specs = [
@@ -204,7 +204,6 @@ const profileConfig = {
inventoryLimit: { enabled: false, maxActiveQuantity: 0 },
matching: {
mode: 'auto',
autoMatchEnabled: true,
exactOnly: true,
preferLowReward: true,
},
@@ -436,12 +435,18 @@ async function main() {
assert(bad.errors.length > 0, '高于建议价必须报错')
})
await check('价格:固定价 12000 高于模板价 10000 必须拒绝', () => {
const result = resolveWorkerConsignmentPrice({
pricing: { ...pricing, priceType: 'fixed', fixedPriceFen: 12000 },
await check('价格:固定下调 2000 分后售价 8000,高于建议价必须拒绝', () => {
const result = computeConsignmentPrice({
pricing: { ...pricing, priceType: 'template_minus', minusFen: 2000 },
templatePriceFen: 10000,
})
assert(result.errors.length > 0, '固定价高于模板价必须报错')
assert(result.priceFen === 8000, `实际 ${result.priceFen}`)
const above = resolveWorkerConsignmentPrice({
pricing: { ...pricing, priceType: 'template_minus', minusFen: 2000 },
templatePriceFen: 10000,
submittedPriceFen: 9000,
})
assert(above.errors.length > 0, '高于建议价必须报错')
})
await check('价格:allowWorkerPrice=false 时按系统建议价发布不应被拦截', () => {
@@ -506,6 +511,7 @@ async function main() {
brillianceCount: '1',
},
sellerContact: `wx_${suffix}`,
sellerPhone: '13800138000',
characterName: '测试角色',
characterId: '123456',
screenshotsJson: ['https://example.com/screenshot.png'],
@@ -567,13 +573,14 @@ async function main() {
profileId,
quantity: 1,
specValues: {
system: '安卓',
system: '苹果',
serverZone: '微信区',
series: '暗潮',
diamondLevel: '1',
brillianceCount: '1',
},
sellerContact: `wx_${suffix}`,
sellerPhone: '13800138000',
characterName: '测试角色2',
characterId: '654321',
screenshotsJson: ['https://example.com/screenshot2.png'],
@@ -615,6 +622,7 @@ async function main() {
brillianceCount: '1',
},
sellerContact: `wx_${suffix}`,
sellerPhone: '13800138000',
characterName: '穷角色',
characterId: '999999',
screenshotsJson: ['https://example.com/poor.png'],
@@ -1081,6 +1089,7 @@ async function main() {
brillianceCount: '1',
},
sellerContact: `wx_gift_${label}_${suffix}`,
sellerPhone: '13800138000',
characterName: `赠礼卖家${label}`,
characterId: `GIFT${label}${suffix}`.slice(0, 20),
screenshotsJson: ['https://example.com/gift-item.png'],
@@ -13,6 +13,7 @@ import {
import {
evaluateInventoryMatch,
inventoryMatchesProfile,
isRematchExcludedInventory,
resolveProfileForWorkOrder,
} from './consignment-match-engine-service.js'
import type { ConsignmentProfileSnapshot } from './consignment-profile-service.js'
@@ -302,6 +303,8 @@ export function buildPendingConsignmentMatchPairs(input: {
const matchedInventoryIds = new Set<number>()
for (const { order, profile, remainingQuantity, totalQuantity } of preparedOrders) {
for (const inventory of inventories) {
// 超时重新匹配的排除项:看板同样不能把它算成可匹配。
if (isRematchExcludedInventory(order, inventory)) continue
let matchType: 'exact' | 'partial' | 'conflict'
let score: number
let reason: string
@@ -13,9 +13,11 @@ test('寄卖配置:缺省值必须可安全用于发布(保证金/服务费
const config = normalizeConsignmentProfileConfig({})
assert.equal(config.deposit.enabled, false)
assert.equal(config.serviceFee.enabled, false)
assert.equal(config.matching.mode, 'manual')
assert.equal(config.matching.mode, 'off')
assert.equal(config.timeout.minutes, 30)
assert.equal(config.internalMatch.delayMinutes, 30)
assert.equal(config.priceDrop.enabled, true)
assert.equal(config.priceDrop.hours, 24)
assert.equal(config.fields.quantity, true)
})
@@ -32,7 +34,7 @@ test('寄卖配置:非法枚举与越界数字回落到安全值', () => {
assert.equal(config.deposit.unitAmountFen, 0)
assert.equal(config.serviceFee.mode, 'percent')
assert.equal(config.serviceFee.percent, 100)
assert.equal(config.matching.mode, 'manual')
assert.equal(config.matching.mode, 'off')
assert.equal(config.timeout.policy, 'reopen')
assert.equal(config.timeout.minutes, 0)
assert.equal(config.priceDrop.hours, 1)
@@ -4,15 +4,24 @@ import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import { listAllWorkCategories } from '../../repositories/worker-platform/work-category-repo.js'
import {
DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
DEFAULT_CONSIGNMENT_PRICING,
DEFAULT_CONSIGNMENT_SPECS,
normalizeConsignmentIntakeFields,
normalizeConsignmentPricing,
normalizeConsignmentSpecs,
type ConsignmentIntakeField,
type ConsignmentPricingConfig,
type ConsignmentSpecField,
} from './consignment-spec-service.js'
export type { ConsignmentPricingConfig, ConsignmentSpecField } from './consignment-spec-service.js'
export type {
ConsignmentIntakeField,
ConsignmentIntakeFieldType,
ConsignmentIntakeValues,
ConsignmentPricingConfig,
ConsignmentSpecField,
} from './consignment-spec-service.js'
export type ConsignmentDepositConfig = {
enabled: boolean
@@ -34,9 +43,8 @@ export type ConsignmentInventoryLimitConfig = {
}
export type ConsignmentMatchingConfig = {
/** manual = 只允许人工匹配;auto = 调度器自动匹配 */
mode: 'manual' | 'auto'
autoMatchEnabled: boolean
/** off = 完全关闭匹配(新单直接进大厅);manual = 只允许人工匹配;auto = 调度器自动匹配 */
mode: 'off' | 'manual' | 'auto'
exactOnly: boolean
preferLowReward: boolean
}
@@ -48,9 +56,9 @@ export type ConsignmentInternalMatchConfig = {
}
export type ConsignmentTimeoutConfig = {
/** 匹配成功进入代练中后的时限(分钟),默认 30 */
/** 匹配成功进入代练中后的时限(分钟),默认 30(= 0.5 小时) */
minutes: number
policy: 'reopen' | 'cancel_release' | 'cancel_deduct'
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
}
export type ConsignmentPriceDropConfig = {
@@ -87,6 +95,10 @@ export type ConsignmentProfileConfig = {
internalMatch: ConsignmentInternalMatchConfig
timeout: ConsignmentTimeoutConfig
priceDrop: ConsignmentPriceDropConfig
/** 联系方式与游戏凭据:按配置动态渲染;没有该字段时使用默认 7 项 */
intake: {
fields: ConsignmentIntakeField[]
}
}
export type ConsignmentProfileRow = {
@@ -148,8 +160,7 @@ export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = {
maxActiveQuantity: 0,
},
matching: {
mode: 'manual',
autoMatchEnabled: false,
mode: 'off',
exactOnly: true,
preferLowReward: true,
},
@@ -162,11 +173,14 @@ export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = {
policy: 'reopen',
},
priceDrop: {
enabled: false,
hours: 72,
enabled: true,
hours: 24,
mode: 'percent',
value: 10,
},
intake: {
fields: DEFAULT_CONSIGNMENT_INTAKE_FIELDS.map((field) => ({ ...field })),
},
}
function record(value: unknown): Record<string, unknown> {
@@ -221,15 +235,29 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr
const timeout = record(source.timeout)
const priceDrop = record(source.priceDrop)
const matchingMode = matching.mode === 'auto' ? 'auto' : 'manual'
const matchingMode: ConsignmentMatchingConfig['mode'] =
matching.mode === 'auto'
? bool(matching.autoMatchEnabled, true)
? 'auto'
: 'manual'
: matching.mode === 'manual'
? 'manual'
: 'off'
const depositMode = deposit.mode === 'per_quantity' ? 'per_quantity' : 'fixed'
const serviceFeeMode = serviceFee.mode === 'fixed' ? 'fixed' : 'percent'
const priceDropMode = priceDrop.mode === 'fixed' ? 'fixed' : 'percent'
const timeoutPolicy =
timeout.policy === 'cancel_release' || timeout.policy === 'cancel_deduct'
const timeoutPolicy: ConsignmentTimeoutConfig['policy'] =
timeout.policy === 'cancel_release' ||
timeout.policy === 'cancel_deduct' ||
timeout.policy === 'rematch_release' ||
timeout.policy === 'rematch_deduct'
? timeout.policy
: 'reopen'
const specKeys = new Set(specs.map((spec) => spec.key))
const intakeSource = record(source.intake)
const intakeFields = normalizeConsignmentIntakeFields(
intakeSource.fields ?? source.intakeFields ?? source.intake_fields,
)
return {
version: 1,
@@ -267,7 +295,6 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr
},
matching: {
mode: matchingMode,
autoMatchEnabled: matchingMode === 'auto' ? bool(matching.autoMatchEnabled, true) : false,
exactOnly: bool(matching.exactOnly, true),
preferLowReward: bool(matching.preferLowReward, true),
},
@@ -280,11 +307,14 @@ export function normalizeConsignmentProfileConfig(value: unknown): ConsignmentPr
policy: timeoutPolicy,
},
priceDrop: {
enabled: bool(priceDrop.enabled, false),
hours: integer(priceDrop.hours, 72, 1, 24 * 365),
enabled: bool(priceDrop.enabled, true),
hours: integer(priceDrop.hours, 24, 1, 24 * 365),
mode: priceDropMode,
value: integer(priceDrop.value, 10, 0, 100_000_000),
},
intake: {
fields: intakeFields,
},
}
}
@@ -9,6 +9,7 @@ import {
buildConsignmentSpecText,
computeConsignmentPrice,
evaluateConsignmentSpecMatch,
normalizeConsignmentIntakeValues,
normalizeConsignmentSpecValues,
normalizeConsignmentSpecs,
parseConsignmentSpecValues,
@@ -22,10 +23,10 @@ function buildPricing(overrides: Partial<ConsignmentPricingConfig> = {}): Consig
return {
priceType: 'template_discount',
discount: 10,
fixedPriceFen: 0,
minusFen: 0,
allowWorkerPrice: true,
minPriceFen: 0,
missingPricePolicy: 'manual',
missingPricePolicy: 'block',
...overrides,
}
}
@@ -121,7 +122,7 @@ test('动态规格:默认配置没有任何字段,价格默认十折', () =>
assert.equal(config.pricing.priceType, 'template_discount')
assert.equal(config.pricing.discount, 10)
assert.equal(config.pricing.allowWorkerPrice, true)
assert.equal(config.pricing.missingPricePolicy, 'manual')
assert.equal(config.pricing.missingPricePolicy, 'block')
assert.equal(config.fields.quantity, true)
assert.equal(config.fields.system, false)
})
@@ -272,27 +273,29 @@ test('动态价格:模板折扣价与改价上限等于建议价', () => {
assert.equal(result.source, 'template')
})
test('动态价格:固定价格可用,且不能高于接单模板价', () => {
test('动态价格:模板价固定下调,且无模板价时不可寄卖', () => {
const normal = computeConsignmentPrice({
pricing: buildPricing({ priceType: 'fixed', fixedPriceFen: 10000 }),
pricing: buildPricing({ priceType: 'template_minus', minusFen: 3000 }),
templatePriceFen: 13000,
})
assert.equal(normal.priceFen, 10000)
assert.equal(normal.capFen, 10000)
assert.equal(normal.source, 'fixed')
const above = resolveWorkerConsignmentPrice({
pricing: buildPricing({ priceType: 'fixed', fixedPriceFen: 15000 }),
templatePriceFen: 13000,
})
assert.ok(above.errors.some((error) => error.includes('固定价格不能高于接单模板价')))
assert.equal(normal.source, 'template')
const noTemplate = resolveWorkerConsignmentPrice({
pricing: buildPricing({ priceType: 'fixed', fixedPriceFen: 8000 }),
pricing: buildPricing({ priceType: 'template_minus', minusFen: 3000 }),
templatePriceFen: 0,
submittedPriceFen: 8000,
})
assert.deepEqual(noTemplate.errors, [])
assert.equal(noTemplate.priceFen, 8000)
assert.equal(noTemplate.priceFen, 0)
assert.ok(noTemplate.errors.some((error) => error.includes('暂无模板价')))
const overDown = resolveWorkerConsignmentPrice({
pricing: buildPricing({ priceType: 'template_minus', minusFen: 13000 }),
templatePriceFen: 13000,
submittedPriceFen: 1000,
})
assert.ok(overDown.errors.length > 0)
})
test('动态价格:打手改价只能低于系统建议价,且受最低价约束', () => {
@@ -321,21 +324,52 @@ test('动态价格:打手改价只能低于系统建议价,且受最低价
assert.ok(belowMin.errors.some((error) => error.includes('不能少于')))
})
test('动态价格:没有模板价时允许手填,block 策略则拦截', () => {
const manual = resolveWorkerConsignmentPrice({
pricing: buildPricing({ missingPricePolicy: 'manual' }),
templatePriceFen: 0,
submittedPriceFen: 8000,
})
assert.deepEqual(manual.errors, [])
assert.equal(manual.priceFen, 8000)
test('动态价格:没有模板价时一律阻止发布(不再允许手填)', () => {
for (const pricing of [buildPricing(), buildPricing({ missingPricePolicy: 'manual' })]) {
const blocked = resolveWorkerConsignmentPrice({
pricing,
templatePriceFen: 0,
submittedPriceFen: 8000,
})
assert.equal(blocked.priceFen, 0)
assert.ok(blocked.errors.some((error) => error.includes('暂无模板价')))
}
})
const blocked = resolveWorkerConsignmentPrice({
pricing: buildPricing({ missingPricePolicy: 'block' }),
templatePriceFen: 0,
submittedPriceFen: 8000,
test('收货信息:默认 7 个字段,手机号必填校验,显式空数组表示删除全部', () => {
const config = normalizeConsignmentProfileConfig({})
assert.equal(config.intake.fields.length, 7)
assert.ok(
config.intake.fields.some(
(field) => field.key === 'sellerPhone' && field.type === 'phone' && field.required,
),
)
const missing = normalizeConsignmentIntakeValues(config.intake.fields, {})
assert.ok(missing.errors.some((error) => error.includes('对接客服')))
const ok = normalizeConsignmentIntakeValues(config.intake.fields, {
intakeValues: {
contactSupporterId: 3,
sellerContact: 'wx_1',
sellerPhone: '13800138000',
characterName: '角色',
characterId: '123456',
screenshots: ['https://example.com/1.png'],
},
})
assert.ok(blocked.errors.length > 0)
assert.deepEqual(ok.errors, [])
assert.equal(ok.values.sellerPhone, '13800138000')
assert.deepEqual(ok.values.screenshots, ['https://example.com/1.png'])
const badPhone = normalizeConsignmentIntakeValues(config.intake.fields, {
...ok.values,
sellerPhone: '123',
})
assert.ok(badPhone.errors.some((error) => error.includes('手机号')))
const empty = normalizeConsignmentProfileConfig({ intake: { fields: [] } })
assert.equal(empty.intake.fields.length, 0)
})
test('通用匹配:精确、部分、冲突、未知与通配', () => {
@@ -5,7 +5,7 @@
* - 没有任何「内置字段」:配置初始 specs 为空,字段全部由运营在寄卖配置里维护;
* 苏尔南手枪的五个字段由迁移 131 作为种子配置写入;
* - 字段类型支持:文本 / 长文本 / 下拉 / 单选 / 数字 / 日期;
* - 价格只有两种:接单模板价打折、固定价格;打手只能按系统建议价往下调;
* - 价格只有两种:接单模板价打折、模板价固定下调;打手只能按系统建议价往下调;
* - 旧列兼容:字段 key 仍为 system/serverZone/series/diamondLevel/brillianceCount 时,
* 继续同步旧列并沿用旧匹配器,保证存量库存与工单不回归。
*/
@@ -44,17 +44,123 @@ export type ConsignmentSpecField = {
}
export type ConsignmentPricingConfig = {
/** template_discount = 接单模板价的几折;fixed = 固定价格 */
priceType: 'template_discount' | 'fixed'
/** template_discount = 接单模板价的几折;template_minus = 模板价固定下调 */
priceType: 'template_discount' | 'template_minus'
/** 几折,范围 0.1 - 10,例如 9.5 表示 95% */
discount: number
fixedPriceFen: number
/** 固定下调金额(分):售价 = 接单模板价 - minusFen */
minusFen: number
allowWorkerPrice: boolean
minPriceFen: number
/** 没有模板价时:允许打手手填 / 阻止发布 */
/** 历史字段:现在统一按 block 处理(没有模板价一律阻止发布) */
missingPricePolicy: 'block' | 'manual'
}
/** 收货信息(联系方式与游戏凭据)动态字段类型。 */
export type ConsignmentIntakeFieldType =
| 'text'
| 'textarea'
| 'phone'
| 'number'
| 'image'
| 'contact_supporter'
export type ConsignmentIntakeField = {
key: string
label: string
type: ConsignmentIntakeFieldType
required: boolean
placeholder: string
helpText: string
/** 图片字段最多上传张数 */
maxCount: number
sortOrder: number
}
export type ConsignmentIntakeValues = Record<string, string | number | string[]>
export const DEFAULT_CONSIGNMENT_INTAKE_FIELDS: ConsignmentIntakeField[] = [
{
key: 'contactSupporterId',
label: '对接客服',
type: 'contact_supporter',
required: true,
placeholder: '请选择对接客服',
helpText: '用于后续提成归属与联系',
maxCount: 0,
sortOrder: 10,
},
{
key: 'sellerContact',
label: '微信号 / QQ号',
type: 'text',
required: true,
placeholder: '方便派单与紧急联系',
helpText: '',
maxCount: 0,
sortOrder: 20,
},
{
key: 'sellerPhone',
label: '联系电话',
type: 'phone',
required: true,
placeholder: '手机号',
helpText: '默认使用当前接单账号绑定手机号',
maxCount: 0,
sortOrder: 30,
},
{
key: 'characterName',
label: '游戏内角色名',
type: 'text',
required: true,
placeholder: '局内加好友丢枪用',
helpText: '',
maxCount: 0,
sortOrder: 40,
},
{
key: 'characterId',
label: '角色数字ID',
type: 'text',
required: true,
placeholder: '如 1089238472',
helpText: '',
maxCount: 0,
sortOrder: 50,
},
{
key: 'screenshots',
label: '游戏主页截图',
type: 'image',
required: true,
placeholder: '',
helpText: '须包含角色名、ID 及仓库/手枪详情,最多 4 张',
maxCount: 4,
sortOrder: 60,
},
{
key: 'sellerNote',
label: '其他留言与补充',
type: 'textarea',
required: false,
placeholder: '配件、子弹、涂装或在线时段等(选填)',
helpText: '',
maxCount: 0,
sortOrder: 70,
},
]
const INTAKE_FIELD_TYPES: ConsignmentIntakeFieldType[] = [
'text',
'textarea',
'phone',
'number',
'image',
'contact_supporter',
]
export type ConsignmentSpecValues = Record<string, string | number>
export type ConsignmentSpecValidationResult = {
@@ -133,10 +239,10 @@ export const DEFAULT_CONSIGNMENT_SPECS: ConsignmentSpecField[] = []
export const DEFAULT_CONSIGNMENT_PRICING: ConsignmentPricingConfig = {
priceType: 'template_discount',
discount: 10,
fixedPriceFen: 0,
minusFen: 0,
allowWorkerPrice: true,
minPriceFen: 0,
missingPricePolicy: 'manual',
missingPricePolicy: 'block',
}
function record(value: unknown): Record<string, unknown> {
@@ -301,30 +407,136 @@ export function normalizeConsignmentSpecs(
export function normalizeConsignmentPricing(value: unknown): ConsignmentPricingConfig {
const source = record(value)
const rawPriceType = text(source.priceType ?? source.price_type, 30)
// 兼容上一版:source=template 视为 10 折;source=profile 尽量映射成固定价。
const legacySource = text(source.source, 20)
// 绝对固定价已下线:旧配置(fixed / source=profile)统一按 10 折兜底,
// 历史快照里的旧价格不会被改写,只影响后续新保存/新发布的配置。
const priceType: ConsignmentPricingConfig['priceType'] =
rawPriceType === 'fixed' || legacySource === 'profile' ? 'fixed' : 'template_discount'
const legacyFixedPriceFen = integer(
source.fixedPriceFen ?? source.fixed_price_fen ?? source.basePriceFen ?? source.base_price_fen,
0,
0,
100_000_000,
)
const missingPricePolicyRaw = text(source.missingPricePolicy ?? source.missing_price_policy, 30)
rawPriceType === 'template_minus' ? 'template_minus' : 'template_discount'
return {
priceType,
discount: decimal(source.discount, 10, 0.1, 10),
fixedPriceFen: legacyFixedPriceFen,
minusFen: integer(source.minusFen ?? source.minus_fen, 0, 0, 100_000_000),
allowWorkerPrice: bool(
source.allowWorkerPrice ?? source.allow_worker_price,
DEFAULT_CONSIGNMENT_PRICING.allowWorkerPrice,
),
minPriceFen: integer(source.minPriceFen ?? source.min_price_fen, 0, 0, 100_000_000),
missingPricePolicy: missingPricePolicyRaw === 'block' ? 'block' : 'manual',
// 没有模板价一律阻止发布,历史 manual 配置也会被收敛为 block。
missingPricePolicy: 'block',
}
}
function normalizeIntakeFieldType(value: unknown): ConsignmentIntakeFieldType {
const raw = text(value, 30).toLowerCase()
return INTAKE_FIELD_TYPES.includes(raw as ConsignmentIntakeFieldType)
? (raw as ConsignmentIntakeFieldType)
: 'text'
}
function normalizeIntakeField(value: unknown, index: number): ConsignmentIntakeField | null {
const source = record(value)
const key = text(source.key, 40)
if (!key || !/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(key)) return null
const type = normalizeIntakeFieldType(source.type)
return {
key,
label: text(source.label, 80) || key,
type,
required: bool(source.required, true),
placeholder: text(source.placeholder, 120),
helpText: text(source.helpText ?? source.help_text, 300),
maxCount: type === 'image' ? integer(source.maxCount ?? source.max_count, 4, 1, 12) : 0,
sortOrder: integer(source.sortOrder ?? source.sort_order, (index + 1) * 10, 0, 100_000),
}
}
/**
* 归一化「联系方式与游戏凭据」动态字段。
* - 配置里没有 intake 字段(历史配置)→ 使用 7 个默认字段;
* - 显式传空数组 → 视为运营删光了所有字段。
*/
export function normalizeConsignmentIntakeFields(value: unknown): ConsignmentIntakeField[] {
if (!Array.isArray(value)) {
return DEFAULT_CONSIGNMENT_INTAKE_FIELDS.map((field) => ({ ...field }))
}
const seen = new Set<string>()
const result: ConsignmentIntakeField[] = []
value.forEach((item, index) => {
const field = normalizeIntakeField(item, index)
if (!field || seen.has(field.key)) return
seen.add(field.key)
result.push(field)
})
return result.sort((a, b) => a.sortOrder - b.sortOrder || a.key.localeCompare(b.key))
}
export type ConsignmentIntakeValidationResult = {
values: ConsignmentIntakeValues
errors: string[]
}
/** 校验并归一化打手填写的收货信息;必填与手机号格式在这里统一收口。 */
export function normalizeConsignmentIntakeValues(
fields: ConsignmentIntakeField[],
input: unknown,
): ConsignmentIntakeValidationResult {
const source = record(input)
const nested = record(source.intakeValues ?? source.intake_values ?? source.intakeValuesJson)
const values: ConsignmentIntakeValues = {}
const errors: string[] = []
for (const field of fields) {
const nestedValue = nested[field.key]
const raw =
nestedValue !== undefined && nestedValue !== null && String(nestedValue).trim() !== ''
? nestedValue
: source[field.key]
if (field.type === 'image') {
const list = Array.isArray(raw) ? raw : []
const urls = list
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
.map((item) => item.trim())
.slice(0, Math.max(1, field.maxCount || 4))
if (urls.length === 0) {
if (field.required) errors.push(`请上传「${field.label}」`)
continue
}
values[field.key] = urls
continue
}
const textValue = raw === undefined || raw === null ? '' : String(raw).trim()
if (!textValue) {
if (field.required) errors.push(`请填写「${field.label}」`)
continue
}
if (field.type === 'phone') {
if (!/^1[3-9]\d{9}$/.test(textValue)) {
errors.push(`「${field.label}」请填写正确的 11 位手机号`)
continue
}
values[field.key] = textValue
continue
}
if (field.type === 'number' || field.type === 'contact_supporter') {
const numeric = Number(textValue)
if (!Number.isFinite(numeric) || numeric <= 0) {
errors.push(`「${field.label}」必须填写有效数字`)
continue
}
values[field.key] = Math.round(numeric)
continue
}
values[field.key] =
field.type === 'textarea' ? textValue.slice(0, 2000) : textValue.slice(0, 200)
}
return { values, errors }
}
function builtInAliases(key: string): string[] {
if (key === 'system') return ['system', 'os']
if (key === 'serverZone') return ['serverZone', 'server_zone', 'zone']
@@ -549,7 +761,8 @@ export function buildConsignmentItemName(input: {
/**
* 计算系统建议价与改价上限。
* - template_discount:接单模板价 × 折扣;
* - fixed:固定价格;
* - template_minus:接单模板价 − 固定下调金额;
* - 没有模板价时一律返回 0(发布侧会直接阻止)。
* - 改价上限 = 配置后的建议价(打手只能往下调)。
*/
export function computeConsignmentPrice(input: {
@@ -562,20 +775,23 @@ export function computeConsignmentPrice(input: {
let priceFen = 0
let source: 'template' | 'fixed' | 'none' = 'none'
if (pricing.priceType === 'fixed') {
priceFen = Math.max(0, Math.round(Number(pricing.fixedPriceFen || 0)))
if (priceFen > 0) {
source = 'fixed'
breakdown.push({ label: '固定价格', amountFen: priceFen })
}
} else if (templatePriceFen > 0) {
const discount = Math.min(10, Math.max(0.1, Number(pricing.discount || 10)))
priceFen = Math.round((templatePriceFen * discount) / 10)
if (templatePriceFen > 0) {
source = 'template'
breakdown.push({ label: '接单模板价', amountFen: templatePriceFen })
if (discount !== 10) {
breakdown.push({ label: `${discount} 折`, amountFen: priceFen - templatePriceFen })
if (pricing.priceType === 'template_minus') {
const minusFen = Math.max(0, Math.round(Number(pricing.minusFen || 0)))
priceFen = Math.max(0, templatePriceFen - minusFen)
if (minusFen > 0) {
breakdown.push({ label: '固定下调', amountFen: -minusFen })
}
} else {
const discount = Math.min(10, Math.max(0.1, Number(pricing.discount || 10)))
priceFen = Math.round((templatePriceFen * discount) / 10)
if (discount !== 10) {
breakdown.push({ label: `${discount} 折`, amountFen: priceFen - templatePriceFen })
}
}
if (priceFen <= 0) source = 'none'
}
priceFen = Math.max(0, Math.round(priceFen))
@@ -590,9 +806,9 @@ export function computeConsignmentPrice(input: {
/**
* 打手提交价格时决定最终售价:
* - 默认用系统建议价(模板折扣价 / 固定价);
* - 默认用系统建议价(模板折扣价 / 模板价固定下调);
* - 允许改价时只能下调,不能高于建议价;
* - 没有建议价时按 missingPricePolicy 处理(manual=手填,仍受最低价约束)。
* - 没有模板价时一律阻止发布(不再允许手填兜底)。
*/
export function resolveWorkerConsignmentPrice(input: {
pricing: ConsignmentPricingConfig
@@ -605,33 +821,19 @@ export function resolveWorkerConsignmentPrice(input: {
const errors: string[] = []
let priceFen = computed.priceFen
if (
input.pricing.priceType === 'fixed' &&
computed.templatePriceFen > 0 &&
computed.priceFen > computed.templatePriceFen
) {
errors.push(`固定价格不能高于接单模板价 ¥${(computed.templatePriceFen / 100).toFixed(2)}`)
}
if (priceFen > 0) {
if (submittedPriceFen > 0) {
// 前端发布时会回填系统建议价;禁用改价只应拦截“偏离建议价”的提交,
// 不能把按建议价直接发布也一起拦掉。
const usesSystemSuggestedPrice = submittedPriceFen === computed.capFen
if (!input.pricing.allowWorkerPrice && !usesSystemSuggestedPrice) {
errors.push('当前寄卖配置不允许修改售价')
} else if (submittedPriceFen > computed.capFen) {
errors.push(`寄售价格不能高于系统建议价 ¥${(computed.capFen / 100).toFixed(2)}`)
} else {
priceFen = submittedPriceFen
}
if (priceFen <= 0) {
errors.push('该规格暂无模板价,暂不能寄卖,请联系客服补充模板价格')
} else if (submittedPriceFen > 0) {
// 前端发布时会回填系统建议价;禁用改价只应拦截“偏离建议价”的提交,
// 不能把按建议价直接发布也一起拦掉。
const usesSystemSuggestedPrice = submittedPriceFen === computed.capFen
if (!input.pricing.allowWorkerPrice && !usesSystemSuggestedPrice) {
errors.push('当前寄卖配置不允许修改售价')
} else if (submittedPriceFen > computed.capFen) {
errors.push(`寄售价格不能高于系统建议价 ¥${(computed.capFen / 100).toFixed(2)}`)
} else {
priceFen = submittedPriceFen
}
} else if (input.pricing.missingPricePolicy === 'block') {
errors.push('该规格暂无价格,请联系客服补充价格后再提报')
} else if (submittedPriceFen <= 0) {
errors.push('该规格暂无模板价,请填写寄售价格')
} else {
priceFen = submittedPriceFen
}
if (priceFen > 0 && priceFen < minPriceFen) {
@@ -36,10 +36,10 @@ function radioField(
const PRICING: ConsignmentPricingConfig = {
priceType: 'template_discount',
discount: 10,
fixedPriceFen: 0,
minusFen: 0,
allowWorkerPrice: true,
minPriceFen: 0,
missingPricePolicy: 'manual',
missingPricePolicy: 'block',
}
function baseSpecs(): ConsignmentSpecField[] {
@@ -33,11 +33,13 @@ import {
} from './consignment-profile-service.js'
import { suggestConsignmentPriceFromRules } from './consignment-matching-service.js'
import {
DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
DEFAULT_CONSIGNMENT_PRICING,
buildConsignmentItemName,
buildConsignmentSpecKey,
buildConsignmentSpecText,
computeConsignmentPrice,
normalizeConsignmentIntakeValues,
normalizeConsignmentSpecValues,
resolveLegacyConsignmentColumns,
resolveWorkerConsignmentPrice,
@@ -294,9 +296,6 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
})
}
// 对接客服必填:发布时选定,后续按人统计提成 / 方便联系。
const contactSupporter = await resolveEnabledConsignmentContact(payload.contactSupporterId)
// 分类级寄卖配置:新链路必须带 profileId;存量调用不带则沿用旧逻辑。
const profileIdInput = Number(payload.profileId ?? payload.profile_id ?? 0)
const profile =
@@ -348,39 +347,50 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
const { system, serverZone, series, diamondLevel, brillianceCount } =
resolveLegacyConsignmentColumns(specValues)
const contactInfo = String(payload.sellerContact || payload.contactInfo || '').trim()
if (!contactInfo) {
throw createHttpError('请填写微信号或QQ号以便联系与核实', {
// 联系方式与游戏凭据:完全按寄卖配置动态渲染/校验;历史调用回退默认 7 项。
const intakeFields = profile?.config.intake.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS
const intakeInput = {
...payload,
sellerPhone: String(payload.sellerPhone || worker.phone || '').trim(),
intakeValues: {
...(payload.intakeValues &&
typeof payload.intakeValues === 'object' &&
!Array.isArray(payload.intakeValues)
? (payload.intakeValues as Record<string, unknown>)
: {}),
...(Array.isArray(payload.screenshotsJson) ? { screenshots: payload.screenshotsJson } : {}),
},
}
const intakeValidation = normalizeConsignmentIntakeValues(intakeFields, intakeInput)
if (intakeValidation.errors.length > 0) {
throw createHttpError(intakeValidation.errors[0]!, {
statusCode: 400,
errorCode: 'contact_required',
errorCode: 'consignment_intake_invalid',
})
}
const intakeValues = intakeValidation.values
const contactSupporterIdInput = Number(
intakeValues.contactSupporterId || payload.contactSupporterId || 0,
)
// 对接客服字段被运营删掉时不校验提成归属。
const contactSupporter =
contactSupporterIdInput > 0
? await resolveEnabledConsignmentContact(contactSupporterIdInput)
: null
const contactInfo = String(
intakeValues.sellerContact ?? payload.sellerContact ?? payload.contactInfo ?? '',
).trim()
const characterName = String(intakeValues.characterName ?? payload.characterName ?? '').trim()
const characterId = String(intakeValues.characterId ?? payload.characterId ?? '').trim()
const screenshots = Array.isArray(intakeValues.screenshots)
? intakeValues.screenshots
: Array.isArray(payload.screenshotsJson)
? payload.screenshotsJson
: []
const sellerNote = String(intakeValues.sellerNote ?? payload.sellerNote ?? '').trim()
const characterName = String(payload.characterName || '').trim()
const characterId = String(payload.characterId || '').trim()
if (!characterName) {
throw createHttpError('请填写游戏内角色名', {
statusCode: 400,
errorCode: 'character_name_required',
})
}
if (!characterId) {
throw createHttpError('请填写角色数字ID', {
statusCode: 400,
errorCode: 'character_id_required',
})
}
const screenshots = Array.isArray(payload.screenshotsJson) ? payload.screenshotsJson : []
if (screenshots.length === 0) {
throw createHttpError('请上传游戏主页截图以供核验', {
statusCode: 400,
errorCode: 'screenshot_required',
})
}
// 寄售价:按配置算出系统建议价(模板折扣价 / 固定价),打手只能往下调。
// 没有建议价时按 missingPricePolicy 处理(manual = 允许打手手填,仍受最低价约束)。
// 寄售价:按配置算出系统建议价(模板折扣价 / 模板价固定下调),打手只能往下调。
// 没有模板价时一律阻止发布。
const priceSuggestion = await getWorkerConsignmentPriceSuggestion({
profileId: profile?.profileId,
specs,
@@ -408,7 +418,9 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
const depositAmountFen = profile?.config.deposit.enabled
? buildDepositAmountFen(profile.config, quantity)
: getConsignmentPublishDepositFen()
const sellerPhone = String(payload.sellerPhone || worker.phone || '').trim()
const sellerPhone = String(
intakeValues.sellerPhone ?? payload.sellerPhone ?? worker.phone ?? '',
).trim()
const defaultItemName = buildConsignmentItemName({
template: profile?.config.nameTemplate || '',
specs,
@@ -441,7 +453,8 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
contentJson: {
characterName,
characterId,
sellerNote: String(payload.sellerNote || '').trim(),
sellerNote,
intakeValues,
channel: 'worker_portal',
publishDepositFen: depositAmountFen,
profileId: profile?.profileId ?? null,
@@ -476,7 +489,7 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
workerName: worker.display_name || worker.username,
workerPhone: sellerPhone || worker.phone || '',
depositAmountFen,
contactSupporterId: Number(contactSupporter.id),
contactSupporterId: contactSupporter ? Number(contactSupporter.id) : null,
now,
})
@@ -521,7 +534,7 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
contactSupporterId: submission.contact_supporter_id
? Number(submission.contact_supporter_id)
: null,
contactSupporterName: contactSupporter.name,
contactSupporterName: contactSupporter?.name || '',
createdAt: submission.created_at,
}
} catch (error) {
@@ -1,4 +1,10 @@
export const WORK_ORDER_TIMEOUT_POLICIES = ['reopen', 'cancel_release', 'cancel_deduct'] as const
export const WORK_ORDER_TIMEOUT_POLICIES = [
'reopen',
'cancel_release',
'cancel_deduct',
'rematch_release',
'rematch_deduct',
] as const
export type WorkOrderTimeoutPolicy = (typeof WORK_ORDER_TIMEOUT_POLICIES)[number]
@@ -6,6 +12,10 @@ export function isWorkOrderTimeoutPolicy(value: unknown): value is WorkOrderTime
return WORK_ORDER_TIMEOUT_POLICIES.includes(value as WorkOrderTimeoutPolicy)
}
export function isRematchTimeoutPolicy(value: unknown): boolean {
return value === 'rematch_release' || value === 'rematch_deduct'
}
export function normalizeWorkOrderTimeoutPolicy(value: unknown): WorkOrderTimeoutPolicy {
return isWorkOrderTimeoutPolicy(value) ? value : 'reopen'
}
@@ -10,8 +10,31 @@ import {
publishWorkerWalletRealtimeChange,
publishWorkOrderRealtimeChange,
} from '../realtime/realtime-event-service.js'
import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js'
import { reenterConsignmentInternalMatching } from './consignment-match-engine-service.js'
import {
isRematchTimeoutPolicy,
normalizeWorkOrderTimeoutPolicy,
} from './work-order-timeout-policy.js'
import {
reenterConsignmentInternalMatching,
startConsignmentRematch,
} from './consignment-match-engine-service.js'
/** 从工单物料里取本次匹配占用的寄卖库存;重新匹配时用于排除原库存。 */
function resolveConsignmentInventoryId(material: unknown): number | null {
let source = material
if (typeof material === 'string') {
try {
source = JSON.parse(material)
} catch {
return null
}
}
if (!source || typeof source !== 'object' || Array.isArray(source)) return null
const consignment = (source as Record<string, unknown>).consignment
if (!consignment || typeof consignment !== 'object' || Array.isArray(consignment)) return null
const inventoryId = Number((consignment as Record<string, unknown>).inventoryId || 0)
return inventoryId > 0 ? inventoryId : null
}
export async function settleOverdueWorkOrders(options: { limit?: number; workerId?: number } = {}) {
const limit = Math.max(1, Number(options.limit || 50))
@@ -29,8 +52,18 @@ export async function settleOverdueWorkOrders(options: { limit?: number; workerI
})
if (result.order) {
processedCount += 1
// 退回大厅的寄卖单先尝试重新进入内部匹配;匹配不到再由内部匹配超时任务发回大厅。
if (policy === 'reopen') {
if (isRematchTimeoutPolicy(policy)) {
// 重新匹配:回内部匹配池,并排除原卖家/原库存,避免同一卖家立刻接回同一单。
await startConsignmentRematch(
Number(workOrder.id),
{
excludeWorkerId: Number(workOrder.assigned_worker_id || 0) || null,
excludeInventoryId: resolveConsignmentInventoryId(workOrder.material_json),
},
nowIso(),
).catch(() => false)
} else if (policy === 'reopen') {
// 退回大厅的寄卖单先尝试重新进入内部匹配;匹配不到再由内部匹配超时任务发回大厅。
await reenterConsignmentInternalMatching(Number(workOrder.id)).catch(() => false)
}
publishWorkOrderRealtimeChange({
@@ -51,14 +84,26 @@ export async function settleOverdueWorkOrders(options: { limit?: number; workerI
// 处置超时未提交材料的拼单份额(status=joined)
const overdueShares = await listOverdueWorkOrderShares({ limit, workerId })
for (const shareItem of overdueShares) {
const policy = normalizeWorkOrderTimeoutPolicy(shareItem.timeoutPolicy)
const result = await settleOverdueWorkOrderShare({
workOrderId: shareItem.workOrderId,
shareId: shareItem.shareId,
policy: normalizeWorkOrderTimeoutPolicy(shareItem.timeoutPolicy),
policy,
now: nowIso(),
})
if (result.share) {
processedCount += 1
if (isRematchTimeoutPolicy(policy)) {
// 份额超时后空出的份数重新进入匹配池;排除该份额的卖家和库存。
await startConsignmentRematch(
Number(shareItem.workOrderId),
{
excludeWorkerId: Number(shareItem.workerId || 0) || null,
excludeInventoryId: shareItem.consignmentInventoryId,
},
nowIso(),
).catch(() => false)
}
publishWorkOrderRealtimeChange({
workOrderId: Number(shareItem.workOrderId),
workerIds: Number(shareItem.workerId) ? [Number(shareItem.workerId)] : [],