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()