From 3aa01136a5ddf2a243f85b60d13d7a8087ce682f Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Fri, 6 Nov 2026 13:32:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(consignment):=20=E5=AF=84=E5=8D=96?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=BB=B7=E6=A0=BC/=E5=8C=B9=E9=85=8D/?= =?UTF-8?q?=E8=B6=85=E6=97=B6/=E6=94=B6=E8=B4=A7=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E5=85=A8=E9=9D=A2=E6=94=B9=E9=80=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 价格:固定价格改为模板价固定下调,移除绝对固定价;没有模板价时统一阻止发布 - 配置面板:按下拉选项动态显隐,超时与降价提示拆成两张卡片,超时单位改为小时(默认 0.5) - 匹配:新增关闭匹配并设为默认,移除自动匹配开关;关闭后新单直接进大厅 - 超时:新增重新匹配退/扣押金,回内部匹配池并排除原卖家与原库存(迁移 147) - 日志:新增 consignment_match_logs,自动/手动匹配成功、失败、跳过统一留痕,后台新增匹配日志抽屉 - 打手端:顶部分类下拉替换多 tab,默认苏尔南手枪;联系方式与游戏凭据改为动态字段(默认 7 项,手机号必填) - 降价提示默认开启、默认 24 小时 --- .../147_consignment_match_and_rematch.sql | 52 ++ .../src/repositories/worker-platform/types.ts | 3 + .../worker-platform/work-order-issue-repo.ts | 16 +- .../worker-platform/work-order-query-repo.ts | 9 +- .../work-order-settlement-repo.ts | 4 +- .../admin/worker-platform/consignment.ts | 11 + .../consignment-admin-service.ts | 16 +- .../consignment-manual-match.test.ts | 32 + .../consignment-match-engine-service.ts | 289 ++++++- .../consignment-match-log-service.ts | 189 +++++ .../consignment-matching.integration.test.ts | 25 +- .../consignment-pending-match-service.ts | 3 + .../consignment-profile-service.test.ts | 6 +- .../consignment-profile-service.ts | 62 +- .../consignment-spec-service.test.ts | 90 ++- .../consignment-spec-service.ts | 320 ++++++-- .../consignment-worker-service.test.ts | 4 +- .../consignment-worker-service.ts | 85 +- .../work-order-timeout-policy.ts | 12 +- .../worker-order-timeout-service.ts | 55 +- .../admin/ConsignmentMatchLogsModal.tsx | 196 +++++ .../admin/panels/ConsignmentConfigPanel.tsx | 745 +++++++++++++----- .../panels/ConsignmentInventoryPanel.tsx | 9 + .../panels/ConsignmentSubmissionsPanel.tsx | 56 +- .../pages/worker/WorkerConsignmentPage.tsx | 359 +++++---- .../src/services/admin/consignment.ts | 18 + apps/frontend/src/services/worker.ts | 14 +- .../src/types/consignment-match-log.ts | 32 + apps/frontend/src/types/consignment.ts | 45 +- apps/frontend/src/utils/consignment-spec.ts | 111 ++- .../tests/consignment-matching-config.test.ts | 11 +- 31 files changed, 2311 insertions(+), 568 deletions(-) create mode 100644 apps/backend/src/db/migrations/147_consignment_match_and_rematch.sql create mode 100644 apps/backend/src/services/worker-platform/consignment-match-log-service.ts create mode 100644 apps/frontend/src/components/admin/ConsignmentMatchLogsModal.tsx create mode 100644 apps/frontend/src/types/consignment-match-log.ts diff --git a/apps/backend/src/db/migrations/147_consignment_match_and_rematch.sql b/apps/backend/src/db/migrations/147_consignment_match_and_rematch.sql new file mode 100644 index 00000000..29946f7a --- /dev/null +++ b/apps/backend/src/db/migrations/147_consignment_match_and_rematch.sql @@ -0,0 +1,52 @@ +-- 147_consignment_match_and_rematch.sql +-- 1) 超时「重新匹配」排除原卖家/原库存; +-- 2) 手动/自动匹配统一留痕,供后台排查问题。 +-- 全部语句幂等,可安全重跑;不改动历史迁移。 + +ALTER TABLE work_orders + ADD COLUMN IF NOT EXISTS rematch_excluded_worker_id BIGINT, + ADD COLUMN IF NOT EXISTS rematch_excluded_inventory_id BIGINT; + +COMMENT ON COLUMN work_orders.rematch_excluded_worker_id IS + '超时重新匹配时排除的原接单打手;匹配成功后清空'; +COMMENT ON COLUMN work_orders.rematch_excluded_inventory_id IS + '超时重新匹配时排除的原寄卖库存;匹配成功后清空'; + +CREATE TABLE IF NOT EXISTS consignment_match_logs ( + id BIGSERIAL PRIMARY KEY, + work_order_id BIGINT, + work_order_no TEXT NOT NULL DEFAULT '', + profile_id BIGINT, + category_id BIGINT, + trigger_type TEXT NOT NULL, + result TEXT NOT NULL, + reason_code TEXT NOT NULL DEFAULT '', + reason_text TEXT NOT NULL DEFAULT '', + candidate_count INTEGER NOT NULL DEFAULT 0, + matched_count INTEGER NOT NULL DEFAULT 0, + actor_type TEXT NOT NULL DEFAULT 'system', + actor_id TEXT NOT NULL DEFAULT '', + duration_ms INTEGER NOT NULL DEFAULT 0, + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT consignment_match_logs_trigger_check + CHECK (trigger_type IN ('auto', 'manual', 'rematch')), + CONSTRAINT consignment_match_logs_result_check + CHECK (result IN ('matched', 'failed', 'skipped')) +); + +CREATE INDEX IF NOT EXISTS idx_consignment_match_logs_created + ON consignment_match_logs (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_consignment_match_logs_work_order + ON consignment_match_logs (work_order_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_consignment_match_logs_profile + ON consignment_match_logs (profile_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_consignment_match_logs_type_result + ON consignment_match_logs (trigger_type, result, created_at DESC); + +COMMENT ON TABLE consignment_match_logs IS + '寄卖匹配日志:手动/自动/重新匹配的每次尝试(成功、失败、跳过)与原因,用于留痕和问题分析'; +COMMENT ON COLUMN consignment_match_logs.trigger_type IS + '触发方式:auto 自动调度 / manual 后台手动 / rematch 超时重新匹配'; +COMMENT ON COLUMN consignment_match_logs.reason_code IS + '机器可读原因码,例如 no_candidate / matching_disabled / spec_mismatch / exception'; diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 27585c0c..8b1e6fde 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -425,6 +425,9 @@ export type WorkOrderRow = { internal_match_deadline_at?: string | null hall_visible_at?: string | null internal_match_attempts?: number + /** 超时「重新匹配」时排除的原卖家与原库存,避免同一卖家立刻再次接回同一单。 */ + rematch_excluded_worker_id?: number | null + rematch_excluded_inventory_id?: number | null acceptance_json: string | Record draft_acceptance_json: string | Record admin_note: string diff --git a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts index 4bb6ccdb..5495e53b 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts @@ -573,7 +573,7 @@ export async function disposeWorkOrderShareProblem(input: { export async function settleOverdueWorkOrder(input: { workOrderId: number - policy: 'reopen' | 'cancel_release' | 'cancel_deduct' + policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct' now: string }): Promise<{ order: WorkOrderRow | null @@ -613,13 +613,15 @@ export async function settleOverdueWorkOrder(input: { now: input.now, }) } - const releasePolicies = ['reopen', 'cancel_release'] + const releasePolicies = ['reopen', 'cancel_release', 'rematch_release'] + const isRematch = input.policy === 'rematch_release' || input.policy === 'rematch_deduct' const shouldReleaseDeposit = releasePolicies.includes(input.policy) && !forfeitGiftDeposit const shouldDeductDeposit = input.policy === 'cancel_deduct' || + input.policy === 'rematch_deduct' || (releasePolicies.includes(input.policy) && forfeitGiftDeposit) const effectivePolicy = - input.policy === 'cancel_release' && forfeitGiftDeposit ? 'cancel_deduct' : input.policy + releasePolicies.includes(input.policy) && forfeitGiftDeposit ? 'cancel_deduct' : input.policy const workerId = Number(workOrder.assigned_worker_id || 0) let resolvedDepositAmount = 0 @@ -672,7 +674,11 @@ export async function settleOverdueWorkOrder(input: { } } - const targetStatus = input.policy === 'reopen' ? 'open' : 'cancelled' + const targetStatus = isRematch + ? 'internal_matching' + : input.policy === 'reopen' + ? 'open' + : 'cancelled' await client.query( ` @@ -685,6 +691,8 @@ export async function settleOverdueWorkOrder(input: { ${CLEAR_DELIVERY_RESIDUE_SQL}, 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, + internal_match_deadline_at = CASE WHEN $1 = 'internal_matching' THEN NULL ELSE internal_match_deadline_at END, + hall_visible_at = CASE WHEN $1 = 'internal_matching' THEN NULL ELSE hall_visible_at END, ${RESET_GIFT_FLOW_SQL}, updated_at = $2 WHERE id = $3 diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 0a679a55..24973f27 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -893,6 +893,8 @@ export interface OverdueWorkOrderShareItem { timeoutMinutes: number timeoutPolicy: string shareCreatedAt: string + /** 寄卖份额对应的库存;重新匹配时用于排除原库存 */ + consignmentInventoryId: number | null } /** @@ -921,6 +923,7 @@ export async function listOverdueWorkOrderShares({ timeout_minutes: number timeout_policy: string share_created_at: string + consignment_inventory_id: string | null }>( `SELECT wos.id AS share_id, @@ -930,7 +933,8 @@ export async function listOverdueWorkOrderShares({ wos.share_deposit, wos.timeout_minutes, wos.timeout_policy, - wos.created_at AS share_created_at + wos.created_at AS share_created_at, + wos.consignment_inventory_id FROM work_order_shares wos INNER JOIN work_orders wo ON wo.id = wos.work_order_id WHERE wos.status = 'joined' @@ -951,6 +955,9 @@ export async function listOverdueWorkOrderShares({ timeoutMinutes: Number(row.timeout_minutes), timeoutPolicy: String(row.timeout_policy || 'reopen'), shareCreatedAt: String(row.share_created_at), + consignmentInventoryId: row.consignment_inventory_id + ? Number(row.consignment_inventory_id) + : null, })) } diff --git a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts index 0df2ddf9..185d1d2f 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts @@ -990,7 +990,7 @@ export async function cancelWorkOrderShare(input: { export async function settleOverdueWorkOrderShare(input: { workOrderId: number shareId: number - policy: 'reopen' | 'cancel_release' | 'cancel_deduct' + policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct' now: string }): Promise<{ order: WorkOrderRow | null @@ -1065,7 +1065,7 @@ export async function settleOverdueWorkOrderShare(input: { Number(wallet?.frozen_deposit_amount || 0), ) if (frozenDeposit > 0) { - if (input.policy === 'cancel_deduct') { + if (input.policy === 'cancel_deduct' || input.policy === 'rematch_deduct') { deductedDepositAmount = frozenDeposit const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - frozenDeposit) await client.query( diff --git a/apps/backend/src/routes/admin/worker-platform/consignment.ts b/apps/backend/src/routes/admin/worker-platform/consignment.ts index ba398412..cfcab6bc 100644 --- a/apps/backend/src/routes/admin/worker-platform/consignment.ts +++ b/apps/backend/src/routes/admin/worker-platform/consignment.ts @@ -43,6 +43,7 @@ import { listWorkOrderInventoryMatchOptions, } from '../../../services/worker-platform/consignment-fulfillment-service.js' import { listPendingConsignmentMatches } from '../../../services/worker-platform/consignment-pending-match-service.js' +import { listConsignmentMatchLogs } from '../../../services/worker-platform/consignment-match-log-service.js' import { listConsignmentMatchCandidatesForWorkOrder, matchConsignmentWorkOrderManually, @@ -140,6 +141,16 @@ router.post( ), ) +router.get( + '/worker-platform/consignment/match-logs', + requireAdminPermission('consignment.view'), + createJsonHandler((req) => listConsignmentMatchLogs(req.query), { + successMessage: 'ok', + errorMessage: '读取匹配日志失败', + scope: '[admin/consignment/match-logs]', + }), +) + // ---------------- 库存管理与统计 ---------------- router.get( '/worker-platform/consignment/inventories', diff --git a/apps/backend/src/services/worker-platform/consignment-admin-service.ts b/apps/backend/src/services/worker-platform/consignment-admin-service.ts index 3fa300c7..b8cf32c4 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -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) + : safeParseJson(row.content_json) + const intakeValues = + contentJson?.intakeValues && typeof contentJson.intakeValues === 'object' + ? (contentJson.intakeValues as Record) + : {} 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) - : safeParseJson(row.content_json), + contentJson, screenshotsJson: refreshScreenshotList( Array.isArray(row.screenshots_json) ? row.screenshots_json diff --git a/apps/backend/src/services/worker-platform/consignment-manual-match.test.ts b/apps/backend/src/services/worker-platform/consignment-manual-match.test.ts index 7c232ec2..7e3c2ef0 100644 --- a/apps/backend/src/services/worker-platform/consignment-manual-match.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-manual-match.test.ts @@ -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 { @@ -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({})), []) diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts index 0b565b7c..d6d44f96 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts @@ -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() +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 { 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, + inventory: Pick, +): 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 { + 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( + ` + 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))) diff --git a/apps/backend/src/services/worker-platform/consignment-match-log-service.ts b/apps/backend/src/services/worker-platform/consignment-match-log-service.ts new file mode 100644 index 00000000..7257b8f2 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-match-log-service.ts @@ -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 + createdAt?: string +} + +/** 写匹配日志:任何失败都不影响主流程。 */ +export async function recordConsignmentMatchLog( + input: RecordConsignmentMatchLogInput, +): Promise { + 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 = {} + if (row.payload_json && typeof row.payload_json === 'object') { + payload = row.payload_json as Record + } else if (typeof row.payload_json === 'string') { + try { + const parsed = JSON.parse(row.payload_json) + payload = parsed && typeof parsed === 'object' ? (parsed as Record) : {} + } 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( + ` + 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, + } +} diff --git a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts index 70ea1a38..e7dc9db0 100644 --- a/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-matching.integration.test.ts @@ -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'], diff --git a/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts index 70be396d..0c73b2ea 100644 --- a/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts @@ -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() 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 diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts index d87ee100..15eb50f2 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts @@ -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) diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.ts index 8587c614..391814c1 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.ts @@ -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 { @@ -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, + }, } } diff --git a/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts b/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts index 334cc78a..75166de2 100644 --- a/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-spec-service.test.ts @@ -9,6 +9,7 @@ import { buildConsignmentSpecText, computeConsignmentPrice, evaluateConsignmentSpecMatch, + normalizeConsignmentIntakeValues, normalizeConsignmentSpecValues, normalizeConsignmentSpecs, parseConsignmentSpecValues, @@ -22,10 +23,10 @@ function buildPricing(overrides: Partial = {}): 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('通用匹配:精确、部分、冲突、未知与通配', () => { diff --git a/apps/backend/src/services/worker-platform/consignment-spec-service.ts b/apps/backend/src/services/worker-platform/consignment-spec-service.ts index 580a1524..0f5b753f 100644 --- a/apps/backend/src/services/worker-platform/consignment-spec-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-spec-service.ts @@ -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 + +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 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 { @@ -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() + 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) { diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts index 16806e88..c8004ba2 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.test.ts @@ -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[] { diff --git a/apps/backend/src/services/worker-platform/consignment-worker-service.ts b/apps/backend/src/services/worker-platform/consignment-worker-service.ts index 7f972086..2d62b58f 100644 --- a/apps/backend/src/services/worker-platform/consignment-worker-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-worker-service.ts @@ -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) + : {}), + ...(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) { diff --git a/apps/backend/src/services/worker-platform/work-order-timeout-policy.ts b/apps/backend/src/services/worker-platform/work-order-timeout-policy.ts index fbf2d7af..3e0c371b 100644 --- a/apps/backend/src/services/worker-platform/work-order-timeout-policy.ts +++ b/apps/backend/src/services/worker-platform/work-order-timeout-policy.ts @@ -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' } diff --git a/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts b/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts index bdeb6380..71bce148 100644 --- a/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts +++ b/apps/backend/src/services/worker-platform/worker-order-timeout-service.ts @@ -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).consignment + if (!consignment || typeof consignment !== 'object' || Array.isArray(consignment)) return null + const inventoryId = Number((consignment as Record).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)] : [], diff --git a/apps/frontend/src/components/admin/ConsignmentMatchLogsModal.tsx b/apps/frontend/src/components/admin/ConsignmentMatchLogsModal.tsx new file mode 100644 index 00000000..6d215fe7 --- /dev/null +++ b/apps/frontend/src/components/admin/ConsignmentMatchLogsModal.tsx @@ -0,0 +1,196 @@ +import { ReloadOutlined } from '@ant-design/icons' +import { useQuery } from '@tanstack/react-query' +import { Input, Modal, Select, Space, Tag, Typography } from 'antd' +import { useState } from 'react' + +import PercentTable from '@/components/common/PercentTable' +import { fetchAdminConsignmentMatchLogs } from '@/services/admin/consignment' +import type { ConsignmentMatchLogItem } from '@/types/consignment-match-log' +import { formatDateTime } from '@/utils/date-time' + +const { Text } = Typography + +const TRIGGER_LABELS: Record = { + auto: '自动匹配', + manual: '手动匹配', + rematch: '重新匹配', +} + +const RESULT_LABELS: Record = { + matched: { label: '已匹配', color: 'green' }, + failed: { label: '失败', color: 'red' }, + skipped: { label: '跳过', color: 'default' }, +} + +const PAGE_SIZE = 20 + +export default function ConsignmentMatchLogsModal({ + open, + onClose, +}: { + open: boolean + onClose: () => void +}) { + const [page, setPage] = useState(1) + const [triggerType, setTriggerType] = useState() + const [result, setResult] = useState() + const [keyword, setKeyword] = useState('') + const [keywordInput, setKeywordInput] = useState('') + + const logsQuery = useQuery({ + queryKey: ['admin-consignment-match-logs', page, triggerType, result, keyword], + queryFn: () => + fetchAdminConsignmentMatchLogs({ + page, + pageSize: PAGE_SIZE, + triggerType, + result, + keyword: keyword || undefined, + }), + enabled: open, + }) + + const data = logsQuery.data?.data + const items = data?.items || [] + const total = Number(data?.total || 0) + + const columns = [ + { + title: '时间', + dataIndex: 'createdAt', + width: 160, + render: (value: string) => (value ? formatDateTime(value) : '-'), + }, + { + title: '工单', + dataIndex: 'workOrderNo', + width: 150, + render: (_: string, record: ConsignmentMatchLogItem) => + record.workOrderNo || (record.workOrderId ? `#${record.workOrderId}` : '-'), + }, + { + title: '触发', + dataIndex: 'triggerType', + width: 100, + render: (value: string) => ( + + {TRIGGER_LABELS[value] || value} + + ), + }, + { + title: '结果', + dataIndex: 'result', + width: 90, + render: (value: string) => { + const config = RESULT_LABELS[value] || { label: value, color: 'default' } + return {config.label} + }, + }, + { + title: '候选 / 成功', + width: 100, + render: (_: unknown, record: ConsignmentMatchLogItem) => + `${record.candidateCount} / ${record.matchedCount}`, + }, + { + title: '原因', + dataIndex: 'reasonText', + render: (value: string, record: ConsignmentMatchLogItem) => ( +
+ {value || '-'} + {record.reasonCode ? ( + + {record.reasonCode} + + ) : null} +
+ ), + }, + { + title: '操作人', + dataIndex: 'actorId', + width: 120, + render: (value: string, record: ConsignmentMatchLogItem) => + record.actorType === 'system' ? '系统' : value || '-', + }, + { + title: '耗时', + dataIndex: 'durationMs', + width: 80, + render: (value: number) => `${Number(value || 0)} ms`, + }, + ] + + return ( + + + { + setResult(value) + setPage(1) + }} + options={[ + { value: 'matched', label: '已匹配' }, + { value: 'failed', label: '失败' }, + { value: 'skipped', label: '跳过' }, + ]} + /> + setKeywordInput(event.target.value)} + onSearch={(value) => { + setKeyword(value.trim()) + setPage(1) + }} + /> + 共 {total} 条 + void logsQuery.refetch()} /> + + + size="small" + rowKey="logId" + loading={logsQuery.isFetching} + dataSource={items} + columns={columns} + scroll={{ x: 820 }} + pagination={{ + current: page, + pageSize: PAGE_SIZE, + total, + showSizeChanger: false, + onChange: (nextPage) => setPage(nextPage), + }} + /> + + ) +} diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx index e277e826..f498daed 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx @@ -39,12 +39,18 @@ import { } from '@/services/admin/consignment' import type { AdminConsignmentProfilesResponse, + ConsignmentIntakeField, ConsignmentProfile, ConsignmentSpecField, ConsignmentSpecFieldType, ConsignmentSpecOption, } from '@/types/consignment' -import { SPEC_FIELD_TYPE_OPTIONS, isOptionSpecField } from '@/utils/consignment-spec' +import { + DEFAULT_CONSIGNMENT_INTAKE_FIELDS, + INTAKE_FIELD_TYPE_OPTIONS, + SPEC_FIELD_TYPE_OPTIONS, + isOptionSpecField, +} from '@/utils/consignment-spec' const { Text, Title } = Typography @@ -58,9 +64,9 @@ type ConfigFormValues = { tabTitle: string fields: { quantity: boolean } pricing: { - priceType: 'template_discount' | 'fixed' + priceType: 'template_discount' | 'template_minus' discount: number - fixedPriceYuan: number + minusYuan: number allowWorkerPrice: boolean minPriceYuan: number missingPricePolicy: 'block' | 'manual' @@ -75,13 +81,15 @@ type ConfigFormValues = { serviceFee: { enabled: boolean; mode: 'percent' | 'fixed'; percent: number; amountYuan: number } inventoryLimit: { enabled: boolean; maxActiveQuantity: number } matching: { - mode: 'manual' | 'auto' - autoMatchEnabled: boolean + mode: 'off' | 'manual' | 'auto' exactOnly: boolean preferLowReward: boolean } internalMatch: { delayMinutes: number; fallbackToHall: boolean } - timeout: { minutes: number; policy: 'reopen' | 'cancel_release' | 'cancel_deduct' } + timeout: { + hours: number + policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct' + } priceDrop: { enabled: boolean; hours: number; mode: 'percent' | 'fixed'; value: number } } @@ -106,6 +114,29 @@ function cloneSpecs(specs: ConsignmentSpecField[]): ConsignmentSpecField[] { })) } +function cloneIntakeFields(fields: ConsignmentIntakeField[]): ConsignmentIntakeField[] { + return fields.map((field) => ({ ...field })) +} + +function nextIntakeKey(fields: ConsignmentIntakeField[]): string { + let index = 1 + while (fields.some((field) => field.key === `custom_${index}`)) index += 1 + return `custom_${index}` +} + +function newIntakeField(fields: ConsignmentIntakeField[]): ConsignmentIntakeField { + return { + key: nextIntakeKey(fields), + label: '新字段', + type: 'text', + required: false, + placeholder: '', + helpText: '', + maxCount: 0, + sortOrder: (fields.length + 1) * 10, + } +} + function nextCustomSpecKey(specs: ConsignmentSpecField[]): string { let index = 1 while (specs.some((spec) => spec.key === `custom_${index}`)) index += 1 @@ -137,12 +168,13 @@ function toFormValues(profile: ConsignmentProfile): ConfigFormValues { tabTitle: profile.tabTitle, fields: { quantity: config.fields.quantity }, pricing: { - priceType: config.pricing.priceType || 'template_discount', + priceType: + config.pricing.priceType === 'template_minus' ? 'template_minus' : 'template_discount', discount: Number(config.pricing.discount ?? 10), - fixedPriceYuan: yuan(config.pricing.fixedPriceFen), + minusYuan: yuan(config.pricing.minusFen), allowWorkerPrice: config.pricing.allowWorkerPrice !== false, minPriceYuan: yuan(config.pricing.minPriceFen), - missingPricePolicy: config.pricing.missingPricePolicy || 'manual', + missingPricePolicy: 'block', }, nameTemplate: config.nameTemplate || '', deposit: { @@ -158,9 +190,16 @@ function toFormValues(profile: ConsignmentProfile): ConfigFormValues { amountYuan: yuan(config.serviceFee.amountFen), }, inventoryLimit: { ...config.inventoryLimit }, - matching: { ...config.matching }, + matching: { + mode: config.matching.mode || 'off', + exactOnly: config.matching.exactOnly, + preferLowReward: config.matching.preferLowReward, + }, internalMatch: { ...config.internalMatch }, - timeout: { ...config.timeout }, + timeout: { + hours: Math.max(0, Number(config.timeout.minutes ?? 30) / 60), + policy: config.timeout.policy, + }, priceDrop: { ...config.priceDrop }, } } @@ -175,28 +214,32 @@ function emptyFormValues(): ConfigFormValues { pricing: { priceType: 'template_discount', discount: 10, - fixedPriceYuan: 0, + minusYuan: 0, allowWorkerPrice: true, minPriceYuan: 0, - missingPricePolicy: 'manual', + missingPricePolicy: 'block', }, nameTemplate: '', deposit: { enabled: false, mode: 'fixed', fixedAmountYuan: 0, unitAmountYuan: 0 }, serviceFee: { enabled: false, mode: 'percent', percent: 0, amountYuan: 0 }, inventoryLimit: { enabled: false, maxActiveQuantity: 0 }, matching: { - mode: 'manual', - autoMatchEnabled: false, + mode: 'off', exactOnly: true, preferLowReward: true, }, internalMatch: { delayMinutes: 30, fallbackToHall: true }, - timeout: { minutes: 30, policy: 'reopen' }, - priceDrop: { enabled: false, hours: 72, mode: 'percent', value: 10 }, + timeout: { hours: 0.5, policy: 'reopen' }, + priceDrop: { enabled: true, hours: 24, mode: 'percent', value: 10 }, } } -function buildPayload(categoryId: number, values: ConfigFormValues, specs: ConsignmentSpecField[]) { +function buildPayload( + categoryId: number, + values: ConfigFormValues, + specs: ConsignmentSpecField[], + intakeFields: ConsignmentIntakeField[], +) { const hasSpec = (key: string) => specs.some((spec) => spec.key === key) return { categoryId, @@ -217,32 +260,57 @@ function buildPayload(categoryId: number, values: ConfigFormValues, specs: Consi quantity: values.fields.quantity, }, specs, + intake: { fields: intakeFields }, nameTemplate: values.nameTemplate, pricing: { priceType: values.pricing.priceType, discount: Number(values.pricing.discount || 0), - fixedPriceFen: fen(values.pricing.fixedPriceYuan), + minusFen: fen(values.pricing.minusYuan), allowWorkerPrice: values.pricing.allowWorkerPrice, minPriceFen: fen(values.pricing.minPriceYuan), - missingPricePolicy: values.pricing.missingPricePolicy, + // 没有模板价一律阻止发布,不再提供手填兜底。 + missingPricePolicy: 'block', }, deposit: { enabled: values.deposit.enabled, - mode: values.deposit.mode, - fixedAmountFen: fen(values.deposit.fixedAmountYuan), - unitAmountFen: fen(values.deposit.unitAmountYuan), + mode: values.deposit.enabled ? values.deposit.mode : 'fixed', + fixedAmountFen: values.deposit.enabled ? fen(values.deposit.fixedAmountYuan) : 0, + unitAmountFen: values.deposit.enabled ? fen(values.deposit.unitAmountYuan) : 0, }, serviceFee: { enabled: values.serviceFee.enabled, - mode: values.serviceFee.mode, - percent: values.serviceFee.percent, - amountFen: fen(values.serviceFee.amountYuan), + mode: values.serviceFee.enabled ? values.serviceFee.mode : 'percent', + percent: values.serviceFee.enabled ? values.serviceFee.percent : 0, + amountFen: values.serviceFee.enabled ? fen(values.serviceFee.amountYuan) : 0, + }, + inventoryLimit: { + enabled: values.inventoryLimit.enabled, + maxActiveQuantity: values.inventoryLimit.enabled + ? Number(values.inventoryLimit.maxActiveQuantity || 0) + : 0, + }, + matching: { + mode: values.matching.mode, + exactOnly: values.matching.exactOnly, + preferLowReward: values.matching.preferLowReward, + }, + internalMatch: { + // 关闭匹配时不再等待内部库存,直接进大厅。 + delayMinutes: + values.matching.mode === 'off' ? 0 : Number(values.internalMatch.delayMinutes || 0), + fallbackToHall: + values.matching.mode === 'off' ? false : values.internalMatch.fallbackToHall, + }, + timeout: { + minutes: Math.max(0, Math.round(Number(values.timeout.hours || 0) * 60)), + policy: values.timeout.policy, + }, + priceDrop: { + enabled: values.priceDrop.enabled, + hours: Number(values.priceDrop.hours || 24), + mode: values.priceDrop.enabled ? values.priceDrop.mode : 'percent', + value: values.priceDrop.enabled ? Number(values.priceDrop.value || 0) : 0, }, - inventoryLimit: values.inventoryLimit, - matching: values.matching, - internalMatch: values.internalMatch, - timeout: values.timeout, - priceDrop: values.priceDrop, }, } } @@ -329,6 +397,7 @@ export default function ConsignmentConfigPanel() { const [expandedCategoryIds, setExpandedCategoryIds] = useState>(() => new Set()) const [saving, setSaving] = useState(false) const [specDrafts, setSpecDrafts] = useState([]) + const [intakeDrafts, setIntakeDrafts] = useState([]) const [form] = Form.useForm() const profilesQuery = useQuery({ @@ -406,6 +475,11 @@ export default function ConsignmentConfigPanel() { if (selectedProfile) { form.setFieldsValue(toFormValues(selectedProfile)) setSpecDrafts(cloneSpecs(selectedProfile.config.specs)) + setIntakeDrafts( + cloneIntakeFields( + selectedProfile.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS, + ), + ) } else { form.setFieldsValue({ ...emptyFormValues(), @@ -413,6 +487,7 @@ export default function ConsignmentConfigPanel() { tabTitle: selectedCategory?.name || '', }) setSpecDrafts([]) + setIntakeDrafts(cloneIntakeFields(DEFAULT_CONSIGNMENT_INTAKE_FIELDS)) } }, [selectedCategoryId, selectedProfile, selectedCategory?.name, form]) @@ -424,6 +499,11 @@ export default function ConsignmentConfigPanel() { if (selectedProfile) { form.setFieldsValue(toFormValues(selectedProfile)) setSpecDrafts(cloneSpecs(selectedProfile.config.specs)) + setIntakeDrafts( + cloneIntakeFields( + selectedProfile.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS, + ), + ) return } form.setFieldsValue({ @@ -432,6 +512,7 @@ export default function ConsignmentConfigPanel() { tabTitle: selectedCategory?.name || '', }) setSpecDrafts([]) + setIntakeDrafts(cloneIntakeFields(DEFAULT_CONSIGNMENT_INTAKE_FIELDS)) } async function handleSave() { @@ -463,10 +544,24 @@ export default function ConsignmentConfigPanel() { return } } + const intakeKeys = new Set() + for (const field of intakeDrafts) { + if (!/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(field.key)) { + message.error(`收货字段 Key 不合法:${field.key || '(空)'}`) + return + } + if (intakeKeys.has(field.key)) { + message.error(`收货字段 Key 重复:${field.key}`) + return + } + intakeKeys.add(field.key) + } const values = await form.validateFields() setSaving(true) try { - await saveAdminConsignmentProfile(buildPayload(selectedCategoryId, values, specs)) + await saveAdminConsignmentProfile( + buildPayload(selectedCategoryId, values, specs, intakeDrafts), + ) message.success('寄卖配置已保存') await refresh() } catch (error) { @@ -560,7 +655,45 @@ export default function ConsignmentConfigPanel() { setSpecDrafts((current) => [...current, newCustomSpec(current)]) } + function updateIntakeField(index: number, patch: Partial) { + setIntakeDrafts((current) => + current.map((field, fieldIndex) => (fieldIndex === index ? { ...field, ...patch } : field)), + ) + } + + function removeIntakeField(index: number) { + setIntakeDrafts((current) => + current + .filter((_, fieldIndex) => fieldIndex !== index) + .map((field, fieldIndex) => ({ ...field, sortOrder: (fieldIndex + 1) * 10 })), + ) + } + + function moveIntakeField(index: number, direction: -1 | 1) { + setIntakeDrafts((current) => { + const next = [...current] + const target = index + direction + if (target < 0 || target >= next.length) return current + const [item] = next.splice(index, 1) + if (!item) return current + next.splice(target, 0, item) + return next.map((field, fieldIndex) => ({ ...field, sortOrder: (fieldIndex + 1) * 10 })) + }) + } + + function addIntakeField() { + setIntakeDrafts((current) => [...current, newIntakeField(current)]) + } + const priceType = Form.useWatch(['pricing', 'priceType'], form) + const depositEnabled = Form.useWatch(['deposit', 'enabled'], form) + const depositMode = Form.useWatch(['deposit', 'mode'], form) + const serviceFeeEnabled = Form.useWatch(['serviceFee', 'enabled'], form) + const serviceFeeMode = Form.useWatch(['serviceFee', 'mode'], form) + const inventoryLimitEnabled = Form.useWatch(['inventoryLimit', 'enabled'], form) + const matchingMode = Form.useWatch(['matching', 'mode'], form) + const priceDropEnabled = Form.useWatch(['priceDrop', 'enabled'], form) + const priceDropMode = Form.useWatch(['priceDrop', 'mode'], form) const headerStatus: ProfileStatusInfo = selectedProfile ? resolveProfileStatus(selectedProfile) : runtimeInheritsParent @@ -984,9 +1117,15 @@ export default function ConsignmentConfigPanel() { +
- {priceType === 'fixed' ? ( + {priceType === 'template_minus' ? ( - - - - - - - - - + {depositEnabled ? ( + + - - - - - - - + {serviceFeeEnabled ? ( + + - - - - - - - - - - - - - - - + {matchingMode !== 'off' ? ( + <> + + + + + + + + + + + + + + ) : null}
+ {matchingMode === 'off' ? ( + + ) : null}
- - - - + {priceDropEnabled ? ( + <> + + + + + + updateIntakeField(index, { key: event.target.value }) + } + /> +
+
+ 显示名称 + + updateIntakeField(index, { label: event.target.value }) + } + /> +
+
+ 类型 + + updateIntakeField(index, { placeholder: event.target.value }) + } + /> +
+
+ 填写说明 + + updateIntakeField(index, { helpText: event.target.value }) + } + /> +
+ {field.type === 'image' ? ( +
+ 最多上传张数 + + updateIntakeField(index, { maxCount: Number(value || 1) }) + } + /> +
+ ) : null} + + + ))} + + )} +
+
保存只更新草稿,发布后才会影响新工单。 diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index fc9ca4ba..202b81d5 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -1,5 +1,6 @@ import { EyeOutlined, + FileSearchOutlined, ReloadOutlined, RollbackOutlined, StopOutlined, @@ -39,6 +40,7 @@ import { useState } from 'react' import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery' import ConsignmentPendingMatchModal from '@/components/admin/ConsignmentPendingMatchModal' +import ConsignmentMatchLogsModal from '@/components/admin/ConsignmentMatchLogsModal' import { archiveAdminConsignmentInventory, fetchAdminConsignmentContacts, @@ -163,6 +165,7 @@ export default function ConsignmentInventoryPanel() { const [returnItem, setReturnItem] = useState(null) const [manualMatchItem, setManualMatchItem] = useState(null) const [pendingMatchOpen, setPendingMatchOpen] = useState(false) + const [matchLogsOpen, setMatchLogsOpen] = useState(false) const [returnReason, setReturnReason] = useState('') const [editForm] = Form.useForm() @@ -891,6 +894,10 @@ export default function ConsignmentInventoryPanel() { 一键查询待匹配订单 + + @@ -1409,6 +1416,8 @@ export default function ConsignmentInventoryPanel() { open={pendingMatchOpen} onClose={() => setPendingMatchOpen(false)} /> + + setMatchLogsOpen(false)} />
) } diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx index 029399d5..fede7336 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx @@ -897,15 +897,53 @@ export default function ConsignmentSubmissionsPanel() { ¥{(viewingSubmission.askingPriceFen / 100).toFixed(2)} - - {viewingSubmission.contactInfo} - - - {viewingSubmission.sellerPhone || '-'} - - - {viewingSubmission.sellerGameId || '-'} - + {viewingSubmission.intakeFields && viewingSubmission.intakeFields.length > 0 ? ( + <> + {viewingSubmission.intakeFields.map((field) => { + const raw = viewingSubmission.intakeValues?.[field.key] + if (field.type === 'image') { + const images = Array.isArray(raw) + ? raw.filter((item): item is string => typeof item === 'string') + : [] + return ( + + + + ) + } + const display = + raw === undefined || raw === null || String(raw).trim() === '' + ? '-' + : String(raw) + return ( + + {display} + + ) + })} + + ) : ( + <> + + {viewingSubmission.contactInfo} + + + {viewingSubmission.sellerPhone || '-'} + + + {viewingSubmission.sellerGameId || '-'} + + + )} {viewingSubmission.workerId ? ( diff --git a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx index 0b79466f..bd8f15a3 100644 --- a/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx @@ -55,12 +55,13 @@ import { } from '@/services/worker' import type { WorkerConsignmentRecord } from '@/services/worker' import type { + ConsignmentIntakeField, ConsignmentSpecField, ConsignmentSpecValues, WorkerConsignmentProfile, } from '@/types/consignment' import type { UploadedFile } from '@/types/worker-platform' -import { buildSpecItemName } from '@/utils/consignment-spec' +import { DEFAULT_CONSIGNMENT_INTAKE_FIELDS, buildSpecItemName } from '@/utils/consignment-spec' import { formatConsignmentContactSupporter } from '@/utils/consignment-contact' import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown' import { canWorkerPublishConsignment } from '@/utils/consignment-whitelist' @@ -151,8 +152,9 @@ export default function WorkerConsignmentPage() { } } } - const [screenshots, setScreenshots] = useState([]) const [submitting, setSubmitting] = useState(false) + /** 收货信息图片字段:key -> 已上传文件 */ + const [intakeImages, setIntakeImages] = useState>({}) const [evidenceItem, setEvidenceItem] = useState(null) const [evidenceFiles, setEvidenceFiles] = useState([]) const [evidenceNote, setEvidenceNote] = useState('') @@ -203,9 +205,16 @@ export default function WorkerConsignmentPage() { consignmentProfiles[0] || null const selectedSpecs: ConsignmentSpecField[] = selectedProfile?.config.specs ?? [] + const intakeFields: ConsignmentIntakeField[] = + selectedProfile?.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS useEffect(() => { if (selectedProfileId === null && consignmentProfiles.length > 0) { - setSelectedProfileId(consignmentProfiles[0]!.profileId) + // 默认苏尔南手枪;没有该分类时退回第一个已发布配置。 + const preferred = + consignmentProfiles.find((profile) => + /苏尔南|苏里南/.test(`${profile.categoryName}${profile.tabTitle}${profile.name}`), + ) || consignmentProfiles[0] + if (preferred) setSelectedProfileId(preferred.profileId) } }, [consignmentProfiles, selectedProfileId]) @@ -339,6 +348,18 @@ export default function WorkerConsignmentPage() { return values } + function handleProfileChange(profileId: number) { + setSelectedProfileId(profileId) + form.resetFields() + setPriceState({ status: 'idle' }) + lastPriceQueryKeyRef.current = '' + setIntakeImages({}) + if (worker?.phone) form.setFieldsValue({ sellerPhone: worker.phone }) + if (firstContactSupporterId != null) { + form.setFieldsValue({ contactSupporterId: firstContactSupporterId }) + } + } + function handleFormValuesChange(changed: Record) { // 钻级改变时:璀璨数不能超过钻石等级(如 5钻调成 2钻,原 5璀自动收敛到 2璀) if (changed && changed.diamondLevel !== undefined) { @@ -417,30 +438,67 @@ export default function WorkerConsignmentPage() { return } - if (contactSupporters.length === 0) { - message.error('暂无可选的对接客服,请联系管理员在「配置 - 对接客服」中添加后再发布') - return + // 联系方式与游戏凭据完全按寄卖配置动态收集;图片字段用独立 state 保存。 + const activeIntakeFields = + selectedProfile?.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS + const intakeValues: Record = {} + for (const field of activeIntakeFields) { + if (field.type === 'image') { + const urls = (intakeImages[field.key] || []).map((file) => file.url).filter(Boolean) + if (urls.length > 0) { + intakeValues[field.key] = urls + } else if (field.required) { + message.error(`请上传「${field.label}」`) + return + } + continue + } + const raw = values[field.key] + if (raw === undefined || raw === null || String(raw).trim() === '') { + if (field.required) { + message.error(`请填写「${field.label}」`) + return + } + continue + } + intakeValues[field.key] = + field.type === 'number' || field.type === 'contact_supporter' + ? Number(raw) + : String(raw).trim() } - if (values.contactSupporterId == null) { - message.error('请选择对接客服') - return + const phoneField = activeIntakeFields.find((field) => field.type === 'phone') + if (phoneField) { + const phoneValue = String(intakeValues[phoneField.key] || '').trim() + if (phoneValue && !/^1[3-9]\d{9}$/.test(phoneValue)) { + message.error(`「${phoneField.label}」请填写正确的 11 位手机号`) + return + } } + const sellerContact = String(intakeValues.sellerContact ?? '').trim() + const sellerPhone = String(intakeValues.sellerPhone ?? worker?.phone ?? '').trim() + const characterName = String(intakeValues.characterName ?? '').trim() + const characterId = String(intakeValues.characterId ?? '').trim() + const sellerNote = String(intakeValues.sellerNote ?? '').trim() + const publishScreenshots = Array.isArray(intakeValues.screenshots) + ? (intakeValues.screenshots as string[]) + : [] - const urls = screenshots.map((f) => f.url).filter(Boolean) - if (urls.length === 0) { - message.error('请上传游戏主页截图以供核验') - return - } - - // 寄售价:按后台配置带出系统建议价;允许改价时可下调,但不能高于建议价。 + // 寄售价:必须命中接单模板价;允许改价时可下调,但不能高于建议价。 const minPriceFen = Math.max(0, selectedProfile?.config.pricing.minPriceFen || 0) - const suggestedPriceFen = priceState.status === 'matched' ? priceState.fen : 0 - const capPriceFen = priceState.status === 'matched' ? priceState.capFen : 0 - let askingPriceFen = suggestedPriceFen + if (priceState.status === 'loading') { + message.warning('正在计算系统建议价,请稍候') + return + } + if (priceState.status !== 'matched' || priceState.fen <= 0) { + message.error('该规格暂无模板价,暂不能寄卖,请联系客服补充模板价格') + return + } + const capPriceFen = priceState.capFen > 0 ? priceState.capFen : priceState.fen + let askingPriceFen = priceState.fen if (manualPriceAllowed) { const submittedPriceFen = Math.max(0, Math.round(Number(values.askingPrice || 0) * 100)) if (submittedPriceFen > 0) { - if (capPriceFen > 0 && submittedPriceFen > capPriceFen) { + if (submittedPriceFen > capPriceFen) { message.error(`寄售价格不能高于系统建议价 ¥${(capPriceFen / 100).toFixed(2)}`) return } @@ -449,24 +507,12 @@ export default function WorkerConsignmentPage() { return } askingPriceFen = submittedPriceFen - } else if (suggestedPriceFen <= 0) { - message.error('该规格暂无价格,请填写寄售价格') - return - } - } else { - if (priceState.status === 'loading') { - message.warning('正在计算系统建议价,请稍候') - return - } - if (priceState.status !== 'matched') { - message.error('该规格暂无价格,请联系客服补充配置后再提报') - return - } - if (askingPriceFen < 100) { - message.error('寄售价格不能少于1元') - return } } + if (askingPriceFen < 100) { + message.error('寄售价格不能少于1元') + return + } const specValues = collectSpecValues() const itemName = @@ -491,14 +537,17 @@ export default function WorkerConsignmentPage() { diamondLevel: Number(specValues.diamondLevel || 1), brillianceCount: Number(specValues.brillianceCount || 0), askingPriceFen, - sellerContact: values.sellerContact, - sellerPhone: values.sellerPhone || worker?.phone || '', - characterName: values.characterName, - characterId: values.characterId, - screenshotsJson: urls, - sellerNote: values.sellerNote, + sellerContact, + sellerPhone, + characterName, + characterId, + screenshotsJson: publishScreenshots, + sellerNote, + intakeValues, depositAmountFen: publishDepositFen, - contactSupporterId: Number(values.contactSupporterId), + contactSupporterId: intakeValues.contactSupporterId + ? Number(intakeValues.contactSupporterId) + : null, }) message.success(`寄卖提报成功,已暂扣履约押金 ¥${publishDepositYuan.toFixed(0)}`) @@ -508,7 +557,7 @@ export default function WorkerConsignmentPage() { } setPriceState({ status: 'idle' }) lastPriceQueryKeyRef.current = '' - setScreenshots([]) + setIntakeImages({}) await Promise.all([myConsignmentsQuery.refetch(), profileQuery.refetch()]) setRecordFilter('reviewing') setActiveTab('records') @@ -654,6 +703,31 @@ export default function WorkerConsignmentPage() { onFinish={handlePublishSubmit} onValuesChange={handleFormValuesChange} > +
+ 寄卖分类 + ({ - value: item.id, - label: item.name, - }))} - notFoundContent="暂无启用中的对接客服" - showSearch - optionFilterProp="label" - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + const rules = [ + ...(field.required ? [{ required: true, message: `请填写${field.label}` }] : []), + ...(field.type === 'phone' + ? [{ pattern: /^1[3-9]\d{9}$/, message: '请填写正确的 11 位手机号' }] + : []), + ] + return ( + + + {field.type === 'contact_supporter' ? ( + + )} + + + ) + })} + {intakeFields.some((field) => field.type === 'contact_supporter') && + !consignmentContactsQuery.isLoading && + contactSupporters.length === 0 ? ( + + + + ) : null} + + )}