feat(consignment): 规格口径定案——互通通配 + 后台全量/发布端按物品展示

业务定案:
- 操作后台全量:系统 苹果/安卓/通用,区服 微信区/QQ区/互通;提报审核与
  库存编辑的系统/区服从自由文本改为受控下拉(单一来源 utils/consignment-spec.ts)
- 打手端发布按物品类型展示:苏尔南系统二选一(必须区分端),区服唯一
  「互通」选项且默认选中;后续其他品类在共享常量按品类加条目
- 匹配引擎通配符对称化:区服通配=互通(通用/全区/不限归一化为别名),
  系统通配=通用(双端/全端同理);订单侧写通配同样放行,只增不减匹配面;
  差异描述不把通配对当冲突
- 提报白名单校验(API 层):系统只收 苹果/安卓,区服只收 微信/QQ/互通,
  旧页面缓存或直调接口绕不过
- 物品名拼接:区服为互通时不拼入(对买家无信息量),前后端同口径

后端 481(+互通双向匹配/通配不算差异/真冲突仍排除/白名单校验)、
前端 54(+选项口径/表单引用/默认值)测试全绿;typecheck/lint/format/check:sql 通过
docs/寄卖系统优化方案.md 记录批次 W
This commit is contained in:
yml2213
2026-09-17 13:01:59 +08:00
parent b666a9c951
commit f8101c1730
10 changed files with 265 additions and 21 deletions
@@ -3,6 +3,7 @@ import test from 'node:test'
import type { ConsignmentInventoryRow } from '../../repositories/worker-platform/consignment-repo.js'
import {
describeConsignmentSpecDifferences,
extractPeacekeeperSpecs,
findMatchingPeacekeeperInventories,
matchPeacekeeperOrderWithInventory,
@@ -336,3 +337,62 @@ test('通用件与明确规格冲突时仍然排除', () => {
const wrongZone = buildInventory({ server_zone: 'QQ区' })
assert.equal(matchPeacekeeperOrderWithInventory(order, wrongZone), null)
})
test('区服「互通」与微信/QQ 订单互相匹配,且不算差异', () => {
const wxOrder = extractPeacekeeperSpecs('安卓微信区 暗潮 1钻')
const qqOrder = extractPeacekeeperSpecs('安卓QQ区 暗潮 1钻')
const interflowInventory = buildInventory({ server_zone: '互通' })
// 互通现货:微信 / QQ 订单都能精确匹配
assert.equal(matchPeacekeeperOrderWithInventory(wxOrder, interflowInventory)?.matchType, 'exact')
assert.equal(matchPeacekeeperOrderWithInventory(qqOrder, interflowInventory)?.matchType, 'exact')
// 订单写「互通」:微信 / QQ 现货也能匹配(卖家选哪个区服的货都能交付)
const interflowOrder = extractPeacekeeperSpecs('安卓互通 暗潮 1钻')
assert.equal(
matchPeacekeeperOrderWithInventory(interflowOrder, buildInventory())?.matchType,
'exact',
)
assert.equal(
matchPeacekeeperOrderWithInventory(interflowOrder, buildInventory({ server_zone: 'QQ区' }))
?.matchType,
'exact',
)
// 差异描述不把「互通」当冲突(手动指定时别吓唬客服)
const diffs = describeConsignmentSpecDifferences(wxOrder, interflowInventory)
assert.equal(
diffs.some((item) => item.startsWith('区服')),
false,
)
})
test('订单写「通用/双端」时买家不限系统,两端现货都匹配;旧数据里的「通用」库存不回归', () => {
const dualOrder = extractPeacekeeperSpecs('通用微信区 暗潮 1钻')
assert.ok(matchPeacekeeperOrderWithInventory(dualOrder, buildInventory()))
assert.ok(matchPeacekeeperOrderWithInventory(dualOrder, buildInventory({ system: '苹果' })))
// 新提报已禁「通用」,但历史库存行仍是「通用」:与任一端订单照常匹配(既有行为)
const legacyUniversal = buildInventory({ system: '通用' })
assert.ok(
matchPeacekeeperOrderWithInventory(
extractPeacekeeperSpecs('苹果微信区 暗潮 1钻'),
legacyUniversal,
),
)
})
test('真冲突仍然排除:互通救不了系统不符,也救不了系列不符', () => {
const order = extractPeacekeeperSpecs('苹果微信区 暗潮 1钻')
assert.equal(
matchPeacekeeperOrderWithInventory(order, buildInventory({ server_zone: '互通' })),
null,
)
assert.equal(
matchPeacekeeperOrderWithInventory(
order,
buildInventory({ server_zone: '互通', series: '怒嚎' }),
),
null,
)
})
@@ -35,6 +35,8 @@ function extractPeacekeeperServerZone(value: string): string {
.trim()
.toLowerCase()
if (!text) return ''
// 互通 = QQ/微信都能收,是与 微信区/QQ区 平级的第三种区服取值
if (text.includes('互通')) return '互通'
if (text.includes('通用') || text.includes('全区') || text.includes('不限')) return '通用'
if (text.includes('微信') || text === 'wx' || text.includes('wx区')) return '微信区'
if (text.includes('qq') || text.includes('手q') || text.includes('企鹅')) return 'QQ区'
@@ -126,18 +128,29 @@ export function extractPeacekeeperSpecs(
/**
* 单项规格比对结论:
* - `match` 双方都有值且一致(或库存侧是"通用"件)
* - `match` 双方都有值且一致,或任一侧是通配值(区服的「互通」、系统的「通用」都表示不限,
* 订单写"通用/双端"意味着买家两端都收,与具体库存同样算 match)
* - `mismatch` 双方都有值且冲突 → 直接排除该库存
* - `unknown` 任一侧没值,无法确认 → **不能算精确匹配**,只能作为"部分匹配"候选
*/
type SpecVerdict = 'match' | 'mismatch' | 'unknown'
function compareSpec(orderValue: string, inventoryValue: string): SpecVerdict {
/** 系统侧通配值:新提报已不允许「通用」,但旧库存行与订单文案里仍可能出现 */
const SYSTEM_WILDCARDS = ['通用']
/** 区服侧通配值:「互通」= QQ/微信都能收;「通用/全区/不限」是订单文案里的旧写法 */
const ZONE_WILDCARDS = ['通用', '互通']
function compareSpec(
orderValue: string,
inventoryValue: string,
wildcards: string[] = SYSTEM_WILDCARDS,
): SpecVerdict {
const order = String(orderValue || '').trim()
const inventory = String(inventoryValue || '').trim()
if (!inventory) return 'unknown'
if (inventory === '通用') return 'match'
if (wildcards.includes(inventory)) return 'match'
if (!order) return 'unknown'
if (wildcards.includes(order)) return 'match'
return order === inventory ? 'match' : 'mismatch'
}
@@ -160,7 +173,7 @@ export function matchPeacekeeperOrderWithInventory(
const verdictSystem = compareSpec(order.system, invSystem)
if (verdictSystem === 'mismatch') return null
const verdictZone = compareSpec(order.serverZone, invZone)
const verdictZone = compareSpec(order.serverZone, invZone, ZONE_WILDCARDS)
if (verdictZone === 'mismatch') return null
const verdictSeries = compareSpec(order.series, invSeries)
if (verdictSeries === 'mismatch') return null
@@ -268,15 +281,27 @@ export function describeConsignmentSpecDifferences(
inventory: ConsignmentInventoryRow,
): string[] {
const diffs: string[] = []
const compare = (label: string, orderValue: string, inventoryValue: string) => {
const compare = (
label: string,
orderValue: string,
inventoryValue: string,
wildcards: string[] = SYSTEM_WILDCARDS,
) => {
const left = String(orderValue || '').trim() || '未指定'
const right = String(inventoryValue || '').trim() || '未填写'
// 通配值(互通/通用)表示"不限",不算差异,别在冲突清单里吓唬客服
if (left === '未指定' || left === right) return
if (wildcards.includes(left) || wildcards.includes(right)) return
diffs.push(`${label}:订单「${left}」/ 现货「${right}」`)
}
compare('系统', order.system, normalizePeacekeeperSystem(inventory.system))
compare('区服', order.serverZone, normalizePeacekeeperServerZone(inventory.server_zone))
compare(
'区服',
order.serverZone,
normalizePeacekeeperServerZone(inventory.server_zone),
ZONE_WILDCARDS,
)
compare('系列', order.series, normalizePeacekeeperSeries(inventory.series))
const orderDiamond = Number(order.diamondLevel || 0)
const invDiamond = Number(inventory.diamond_level || 0)
@@ -55,3 +55,19 @@ test('没有提报单的兜底行不给卖家操作能力,也不暴露内部
assert.ok(serviceSource.includes("consignmentNo: '客服手工入库'"))
assert.ok(serviceSource.includes('canConfirmFriend: false, canSubmitEvidence: false'))
})
test('苏尔南必须区分苹果/安卓(不收「通用」),区服固定 微信区/QQ区/互通 三选', async () => {
const serviceSource = await readServiceSource()
assert.ok(
serviceSource.includes("errorCode: 'consignment_system_invalid'"),
'系统取值不合法要有显式拒绝,防止旧页面缓存绕过前端选项',
)
assert.ok(serviceSource.includes("errorCode: 'consignment_server_zone_invalid'"))
// 校验必须是白名单式(只认 苹果/安卓 与 微信区/QQ区/互通),而不是只挡「通用」
assert.ok(serviceSource.includes("system !== '苹果' && system !== '安卓'"))
assert.ok(
serviceSource.includes(
"serverZone !== '微信区' && serverZone !== 'QQ区' && serverZone !== '互通'",
),
)
})
@@ -41,6 +41,20 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
errorCode: 'specs_required',
})
}
// 苏尔南寄卖必须区分苹果/安卓(不收「通用」);区服支持 微信区/QQ区/互通 三选
if (system !== '苹果' && system !== '安卓') {
throw createHttpError('苏尔南寄卖的操作系统只能选 苹果 或 安卓', {
statusCode: 400,
errorCode: 'consignment_system_invalid',
})
}
// 互通 = QQ/微信都能收(苏尔南发布端默认且唯一展示项)
if (serverZone !== '微信区' && serverZone !== 'QQ区' && serverZone !== '互通') {
throw createHttpError('游戏区服只能选 微信区 / QQ区 / 互通', {
statusCode: 400,
errorCode: 'consignment_server_zone_invalid',
})
}
if (
payload.diamondLevel === undefined ||
@@ -107,7 +121,9 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
const depositAmountFen = getConsignmentPublishDepositFen()
const sellerPhone = String(payload.sellerPhone || worker.phone || '').trim()
const briStr = brillianceCount > 0 ? `${brillianceCount}璀璨` : ''
const defaultItemName = `${system}${serverZone}${series}${diamondLevel}钻${briStr} 苏尔南`
// 区服是「互通」(通配)时不拼进物品名:对买家无信息量,"安卓互通暗潮…"读着也别扭
const zoneLabel = serverZone === '互通' ? '' : serverZone
const defaultItemName = `${system}${zoneLabel}${series}${diamondLevel}钻${briStr} 苏尔南`
const itemName = String(payload.itemName || defaultItemName).trim()
const submissionNo = generateConsignmentSubmissionNo()
const now = nowIso()
@@ -41,6 +41,7 @@ import type { ConsignmentInventory, GiftEvidenceReviewStatus } from '@/types/con
import { fetchAdminSession } from '@/services/admin/auth'
import { resolveAdminPermission } from '@/utils/admin-auth'
import { describeGiftEvidence, resolveGiftEvidenceBadge } from '@/utils/consignment-evidence'
import { CONSIGNMENT_SYSTEM_OPTIONS, CONSIGNMENT_ZONE_OPTIONS } from '@/utils/consignment-spec'
import { formatDateTime } from '@/utils/date-time'
const { Text } = Typography
@@ -56,6 +57,7 @@ const ZONE_OPTIONS = [
{ label: '全部区服', value: '' },
{ label: '微信区', value: '微信区' },
{ label: 'QQ区', value: 'QQ区' },
{ label: '互通', value: '互通' },
]
const SERIES_OPTIONS = [
@@ -842,12 +844,13 @@ export default function ConsignmentInventoryPanel() {
<Row gutter={16}>
<Col span={5}>
<Form.Item name="system" label="系统">
<Input />
{/* 后台全量选项:系统 苹果/安卓/通用,区服 微信/QQ/互通(互通=QQ/微信都能收) */}
<Select options={[...CONSIGNMENT_SYSTEM_OPTIONS]} />
</Form.Item>
</Col>
<Col span={5}>
<Form.Item name="serverZone" label="区服">
<Input />
<Select options={[...CONSIGNMENT_ZONE_OPTIONS]} />
</Form.Item>
</Col>
<Col span={5}>
@@ -40,6 +40,7 @@ import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
import type { ConsignmentSubmission, GiftEvidenceReviewStatus } from '@/types/consignment'
import { resolveAdminPermission } from '@/utils/admin-auth'
import { describeGiftEvidence, resolveGiftEvidenceBadge } from '@/utils/consignment-evidence'
import { CONSIGNMENT_SYSTEM_OPTIONS, CONSIGNMENT_ZONE_OPTIONS } from '@/utils/consignment-spec'
import { formatDateTime } from '@/utils/date-time'
const { Text } = Typography
@@ -512,12 +513,13 @@ export default function ConsignmentSubmissionsPanel() {
<Row gutter={16}>
<Col span={5}>
<Form.Item name="system" label="系统">
<Input />
{/* 审核可修正规格:与打手端发布同一套选项(苹果/安卓;微信/QQ/互通) */}
<Select options={[...CONSIGNMENT_SYSTEM_OPTIONS]} />
</Form.Item>
</Col>
<Col span={5}>
<Form.Item name="serverZone" label="区服">
<Input />
<Select options={[...CONSIGNMENT_ZONE_OPTIONS]} />
</Form.Item>
</Col>
<Col span={5}>
@@ -42,6 +42,11 @@ import {
} from '@/services/worker'
import type { WorkerConsignmentRecord } from '@/services/worker'
import type { UploadedFile } from '@/types/worker-platform'
import {
SURNAN_DEFAULT_ZONE,
SURNAN_PUBLISH_SYSTEM_OPTIONS,
SURNAN_PUBLISH_ZONE_OPTIONS,
} from '@/utils/consignment-spec'
import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown'
import { formatDateTime } from '@/utils/date-time'
import { useIsMobile } from '@/utils/use-is-mobile'
@@ -187,8 +192,10 @@ export default function WorkerConsignmentPage() {
if (sys && zone && ser && dia != null && bri != null) {
const briNum = Number(bri)
const briStr = briNum > 0 ? `${briNum}璀璨` : ''
// 区服是通配值(互通/通用)时不拼进物品名:对买家无信息量,"安卓互通暗潮…"读着也别扭
const zoneLabel = zone === '互通' || zone === '通用' ? '' : zone
form.setFieldsValue({
itemName: `${sys}${zone}${ser}${dia}钻${briStr} 苏尔南`,
itemName: `${sys}${zoneLabel}${ser}${dia}钻${briStr} 苏尔南`,
})
}
}
@@ -499,27 +506,22 @@ export default function WorkerConsignmentPage() {
className="worker-consignment-chips"
optionType="button"
buttonStyle="solid"
options={[
{ label: '苹果', value: '苹果' },
{ label: '安卓', value: '安卓' },
{ label: '通用', value: '通用' },
]}
options={[...SURNAN_PUBLISH_SYSTEM_OPTIONS]}
/>
</Form.Item>
<Form.Item
name="serverZone"
label="游戏区服"
rules={[{ required: true, message: '请选择区服' }]}
// 苏尔南区服固定「通用」(QQ/微信互通),唯一选项默认选中
initialValue={SURNAN_DEFAULT_ZONE}
className="worker-consignment-spec-card"
>
<Radio.Group
className="worker-consignment-chips"
optionType="button"
buttonStyle="solid"
options={[
{ label: '微信区', value: '微信区' },
{ label: 'QQ区', value: 'QQ区' },
]}
options={[...SURNAN_PUBLISH_ZONE_OPTIONS]}
/>
</Form.Item>
<Form.Item
@@ -0,0 +1,36 @@
/**
* 寄卖规格选项(单一来源)。
*
* 两套口径:
* 1. 后台全量(CONSIGNMENT_SYSTEM_OPTIONS / CONSIGNMENT_ZONE_OPTIONS):给客服端
* 提报审核、库存编辑用 —— 系统 苹果/安卓/通用,区服 微信区/QQ区/互通。
* 2. 发布端按物品类型展示(SURNAN_*):打手端「我要寄卖」按物品类型给选项 ——
* 苏尔南必须区分 苹果/安卓(不给通用),区服固定「互通」(QQ/微信互通,默认选中)。
*
* 「互通」在匹配引擎里是区服通配值:互通现货 = 微信/QQ 订单都匹配,反之亦然;
* 订单文案里的「通用/全区/不限」会归一化成通配(见后端 ZONE_WILDCARDS)。
* 系统侧的「通用」同理是系统通配(订单文案里的「双端/全端」也归一化过来)。
* 后续其他品类规则不同(有的不分端),在这里按品类加条目即可。
*/
export const CONSIGNMENT_SYSTEM_OPTIONS = [
{ label: '苹果', value: '苹果' },
{ label: '安卓', value: '安卓' },
{ label: '通用', value: '通用' },
] as const
export const CONSIGNMENT_ZONE_OPTIONS = [
{ label: '微信区', value: '微信区' },
{ label: 'QQ区', value: 'QQ区' },
{ label: '互通(QQ/微信)', value: '互通' },
] as const
/** 打手端发布 · 苏尔南:系统必须二选一,区服固定「互通」 */
export const SURNAN_PUBLISH_SYSTEM_OPTIONS = [
{ label: '苹果', value: '苹果' },
{ label: '安卓', value: '安卓' },
] as const
export const SURNAN_PUBLISH_ZONE_OPTIONS = [{ label: '互通(QQ/微信)', value: '互通' }] as const
/** 苏尔南发布时区服的默认值(唯一选项,自动选中) */
export const SURNAN_DEFAULT_ZONE = '互通'
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
/**
* 寄卖规格口径(2026-09 定案):
* - 操作后台全量:系统 苹果/安卓/通用,区服 微信区/QQ区/互通;
* - 打手端发布按物品类型展示:苏尔南 = 系统二选一(苹果/安卓,不给通用),
* 区服固定「互通」且默认选中(互通 = QQ/微信都能收);
* - 「互通」是区服通配值(互通现货匹配微信/QQ 订单,反之亦然),
* 订单文案里的「通用/全区/不限」由后端归一化成通配别名。
*/
test('后台全量选项:系统含通用,区服含互通', () => {
const source = readFileSync(
new URL('../src/utils/consignment-spec.ts', import.meta.url).pathname,
'utf8',
)
assert.ok(source.includes("{ label: '通用', value: '通用' }"), '系统全量选项必须含「通用」')
assert.ok(source.includes("value: '互通'"), '区服选项必须包含「互通」')
})
test('苏尔南发布选项:系统只有苹果/安卓,区服唯一「互通」且为默认值', () => {
const source = readFileSync(
new URL('../src/utils/consignment-spec.ts', import.meta.url).pathname,
'utf8',
)
const surnanSystem = source.slice(
source.indexOf('SURNAN_PUBLISH_SYSTEM_OPTIONS'),
source.indexOf('SURNAN_PUBLISH_ZONE_OPTIONS'),
)
assert.ok(!surnanSystem.includes("value: '通用'"), '苏尔南发布不允许再选系统「通用」')
assert.ok(source.includes("export const SURNAN_DEFAULT_ZONE = '互通'"), '苏尔南区服默认「互通」')
const surnanZone = source.slice(source.indexOf('SURNAN_PUBLISH_ZONE_OPTIONS'))
assert.ok(
!surnanZone.slice(0, surnanZone.indexOf('\n\n')).includes("value: '微信区'"),
'苏尔南发布端区服只有互通一个选项',
)
})
test('打手端发布表单:用苏尔南选项集 + 区服默认值,不引后台全量', () => {
const source = readFileSync(
new URL('../src/pages/worker/WorkerConsignmentPage.tsx', import.meta.url).pathname,
'utf8',
)
assert.ok(source.includes('SURNAN_PUBLISH_SYSTEM_OPTIONS'))
assert.ok(source.includes('SURNAN_PUBLISH_ZONE_OPTIONS'))
assert.ok(source.includes('initialValue={SURNAN_DEFAULT_ZONE}'), '区服默认值要落到表单项上')
assert.ok(!source.includes('[...CONSIGNMENT_'), '发布端不直接引用后台全量选项')
})
test('客服端两个表单(提报审核 / 库存编辑)用后台全量选项,不再自由填写', () => {
for (const file of ['ConsignmentSubmissionsPanel.tsx', 'ConsignmentInventoryPanel.tsx']) {
const source = readFileSync(
new URL(`../src/pages/admin/panels/${file}`, import.meta.url).pathname,
'utf8',
)
assert.ok(source.includes('CONSIGNMENT_SYSTEM_OPTIONS'), `${file} 应引用全量系统选项`)
assert.ok(source.includes('CONSIGNMENT_ZONE_OPTIONS'), `${file} 应引用全量区服选项`)
}
})