feat(worker-platform): 寄卖价格改由接单模板只读定价,我的寄卖支持服务端搜索与分页

1. 寄售价不再由卖家填写:打手端改为只读展示模板价(与履约押金同款只读框),服务端在发布时按同一套规则复算金额,保证展示口径与落库口径一致,直连接口也无法随意定价;
2. 模板缺档按「最接近的已配置档位」报价、距离相同取更低档:后台只配了 1/3/7 钻时,号主出 2 钻报 1 钻价、出 6 钻报 7 钻价,不再因为中间档没配价而拿不到价格;
3. 我的寄卖列表把分组、关键字、翻页全部下沉到 SQL:新增 listWorkerConsignmentPage 用 UNION ALL 合成「提报单行 + 无提报单库存兜底行」,CASE 推导 待审核/履约中/已完成 分组,返回当前分组 total 与三个分组的角标计数;
4. 关键字覆盖寄卖单号、规格名、系统/区服/系列/钻级、卖家角色名与数字 ID、联系方式、买家昵称与游戏 ID(读取 work_orders.material_json.collect.fields 的多种历史键名);
5. 前端搜索框 300ms 防抖后才请求服务端、换分组或换关键字自动回到第 1 页、列表底部增加分页器,并移除原来固定 pageSize 200 的隐形截断;
6. 新增仓库层与匹配层单测,其中包含 SQL 分组 CASE 与 domain/consignment-item-stage.ts 的 group 映射交叉断言,防止两处状态机漂移;后端 516 项、前端 66 项测试全部通过。
This commit is contained in:
yml2213
2026-10-01 11:07:28 +08:00
parent ef491660e7
commit 1636616ec1
10 changed files with 853 additions and 265 deletions
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import { deriveConsignmentItemStage } from '../../domain/consignment-item-stage.js'
test('寄卖仓库按关键字搜索必须能搜到"寄卖单号"(SUB…),且计数查询带上同一个 join', async () => {
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8')
// 卖家端与客服端现在只把提报单号(SUB…)当主标识展示,搜不到它客服等于瞎了
@@ -55,3 +57,118 @@ test('占库写入的 allocation_status 默认值必须在 080 CHECK 枚举(
`默认值「${match![1]}」违反 080 的 CHECK 约束,真实库必 500`,
)
})
function readWorkerPageQuery(source: string) {
const start = source.indexOf('export async function listWorkerConsignmentPage')
assert.ok(start > 0, '未找到「我的寄卖」分页查询')
return source.slice(start, source.indexOf('\n}\n', start))
}
test('我的寄卖:关键字必须覆盖卖家找单的口径(单号/规格/区服/钻级/角色/买家)', async () => {
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8')
const body = readWorkerPageQuery(source)
for (const field of [
'cs.submission_no',
'cs.item_name',
'cs.system',
'cs.server_zone',
'cs.series',
"cs.diamond_level::text || '钻'",
'cs.seller_game_id',
'cs.contact_info',
'ci.inventory_no',
"wo.material_json -> 'collect' -> 'fields' ->> 'gameNickname'",
"wo.material_json -> 'collect' -> 'fields' ->> 'gameId'",
]) {
assert.ok(body.includes(field), `关键字查询缺少可搜字段:${field}`)
}
// 兜底行(没有归属提报单的库存)里不能引用提报单别名,否则真库直接 42703 报错
const orphanStart = body.indexOf('NULL AS submission_id')
const orphanBody = body.slice(orphanStart, body.indexOf(')\n `', orphanStart))
assert.ok(!orphanBody.includes('cs.'), '兜底库存行的关键字条件引用了提报单别名 cs.')
})
test('我的寄卖:分页与分组过滤必须走 SQL,不能再把全量记录捞回前端', async () => {
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8')
const body = readWorkerPageQuery(source)
assert.ok(body.includes('ORDER BY sort_at DESC'), '排序必须在 SQL 完成')
assert.ok(body.includes('LIMIT $'), '必须在 SQL 分页(LIMIT/OFFSET)')
assert.ok(body.includes('OFFSET $'), '必须在 SQL 分页(LIMIT/OFFSET)')
assert.ok(body.includes('COUNT(*)::integer AS total'), '必须返回总数供分页器使用')
// 分组参数用独立的占位符,不能和关键字占位符撞号(撞号会把关键字当分组、条件全错)
assert.ok(body.includes('WHERE group_key = $${groupValues.length}'), '分组过滤必须使用独立占位符')
})
test('我的寄卖:SQL 里的分组口径与状态机的 group 映射保持一致', async () => {
const source = await readFile(new URL('./consignment-repo.ts', import.meta.url), 'utf8')
const body = readWorkerPageQuery(source)
// 真源是 domain/consignment-item-stage.ts 的 STAGE_META.group:
// pending_review → reviewing;rejected → finished;
// 库存 archived → finished;工单 cancelled/accepted/completed → finished;其余 active
const reviewerWorkOrder = {
status: 'in_progress',
acceptance_mode: 'standard',
gift_phase: '',
gift_available_at: null,
friend_added_at: null,
}
const cases: Array<{ expect: string; patch: Record<string, unknown> }> = [
{ expect: 'reviewing', patch: { submissionStatus: 'pending_review', inventoryStatus: null } },
{ expect: 'finished', patch: { submissionStatus: 'rejected', inventoryStatus: null } },
{
expect: 'finished',
patch: {
submissionStatus: 'approved',
inventoryStatus: 'archived',
workOrder: reviewerWorkOrder,
},
},
{
expect: 'finished',
patch: {
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: { ...reviewerWorkOrder, status: 'cancelled' },
},
},
{
expect: 'finished',
patch: {
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: { ...reviewerWorkOrder, status: 'accepted' },
},
},
{
expect: 'active',
patch: {
submissionStatus: 'approved',
inventoryStatus: 'consumed',
workOrder: reviewerWorkOrder,
},
},
{ expect: 'active', patch: { submissionStatus: 'approved', inventoryStatus: 'in_stock' } },
]
for (const item of cases) {
const stage = deriveConsignmentItemStage({
submissionStatus: String(item.patch.submissionStatus),
inventoryStatus: (item.patch.inventoryStatus as string | null) ?? null,
workOrder: (item.patch.workOrder as never) ?? null,
viewer: { role: 'seller', workerId: 1 },
})
assert.equal(stage.group, item.expect, `状态机口径变化:${JSON.stringify(item.patch)}`)
}
// 上面这些 group 的判定条件必须都能在 SQL 的 CASE 里找到对应分支
assert.ok(body.includes("WHEN cs.status = 'pending_review' THEN 'reviewing'"))
assert.ok(body.includes("WHEN cs.status = 'rejected' THEN 'finished'"))
assert.ok(body.includes("WHEN ci.status = 'archived' THEN 'finished'"))
assert.ok(
body.includes("WHEN wo.status IN ('cancelled', 'accepted', 'completed') THEN 'finished'"),
)
assert.ok(body.includes("ELSE 'active'"), '未匹配到的都归 active')
})
@@ -940,6 +940,224 @@ export async function listConsignmentSubmissions(filters: {
return { items: listRes.rows, total }
}
/** 我的寄卖分组:与 domain/consignment-item-stage.ts 的 group 口径一致 */
export type WorkerConsignmentGroup = 'all' | 'reviewing' | 'active' | 'finished'
export type WorkerConsignmentRowKey = {
submissionId: number | null
inventoryId: number | null
createdAt: string
}
export type WorkerConsignmentPage = {
rows: WorkerConsignmentRowKey[]
/** 当前分组 + 关键字下的总条数(分页用) */
total: number
/** 各分组条数(受关键字影响,不受当前分组影响),与前端筛选按钮角标对齐 */
counts: Record<WorkerConsignmentGroup, number>
}
/**
* 「我的寄卖」服务端分页 / 搜索 / 分组计数。
*
* 两件事必须在 SQL 做掉,否则前端只能把全部记录捞回来自己过滤(超过 200 条就丢件):
* 1. 关键字:单号 / 规格 / 系统 / 区服 / 系列 / 钻级 / 卖家角色 / 买家资料;
* 2. 分组:状态机推导出来的阶段太多,但落到「待审核 / 履约中 / 已完成」三档只取决于
* 提报单状态、库存状态、绑定工单状态——下面的 CASE 就是这个映射。
* ⚠️ 改 domain/consignment-item-stage.ts 的阶段分组时必须同步改这里
* (consignment-repo.test.ts 有一致的断言)。
*
* 只返回行标识(提报单/库存 id),完整数据由调用方按 id 批量取,避免在这里重复映射一遍字段。
*/
export async function listWorkerConsignmentPage(input: {
workerId: number
group?: WorkerConsignmentGroup | undefined
keyword?: string | undefined
page?: number | undefined
pageSize?: number | undefined
}): Promise<WorkerConsignmentPage> {
const values: unknown[] = [input.workerId]
const keyword = String(input.keyword || '').trim()
let submissionKeywordFilter = ''
let orphanKeywordFilter = ''
if (keyword) {
values.push(`%${keyword}%`)
const p = `$${values.length}`
// 提报单行:提报单 + 它产出的库存 + 绑定工单上的买家资料
submissionKeywordFilter = `
AND (
cs.submission_no ILIKE ${p}
OR cs.item_name ILIKE ${p}
OR cs.system ILIKE ${p}
OR cs.server_zone ILIKE ${p}
OR cs.series ILIKE ${p}
OR cs.diamond_level::text || '钻' ILIKE ${p}
OR cs.seller_game_id ILIKE ${p}
OR cs.contact_info ILIKE ${p}
OR cs.seller_phone ILIKE ${p}
OR ci.inventory_no ILIKE ${p}
OR ci.item_name ILIKE ${p}
OR ci.seller_game_id ILIKE ${p}
OR ci.seller_contact ILIKE ${p}
OR ci.seller_phone ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'gameNickname' ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'roleName' ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'nickname' ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'gameId' ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'characterId' ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'uid' ILIKE ${p}
)`
// 兜底行(没有归属提报单的库存):只能搜库存与买家资料,不能引用提报单别名
orphanKeywordFilter = `
AND (
ci.inventory_no ILIKE ${p}
OR ci.item_name ILIKE ${p}
OR ci.system ILIKE ${p}
OR ci.server_zone ILIKE ${p}
OR ci.series ILIKE ${p}
OR ci.diamond_level::text || '钻' ILIKE ${p}
OR ci.seller_game_id ILIKE ${p}
OR ci.seller_contact ILIKE ${p}
OR ci.seller_phone ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'gameNickname' ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'roleName' ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'nickname' ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'gameId' ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'characterId' ILIKE ${p}
OR wo.material_json -> 'collect' -> 'fields' ->> 'uid' ILIKE ${p}
)`
}
// 提报单行(连带它产出的库存与绑定工单)与「没有归属提报单的库存」兜底行
const baseSql = `
WITH base AS (
SELECT
cs.id AS submission_id,
ci.id AS inventory_id,
cs.created_at AS sort_at,
CASE
WHEN cs.status = 'pending_review' THEN 'reviewing'
WHEN cs.status = 'rejected' THEN 'finished'
WHEN ci.status = 'archived' THEN 'finished'
WHEN wo.status IN ('cancelled', 'accepted', 'completed') THEN 'finished'
ELSE 'active'
END AS group_key
FROM consignment_submissions cs
LEFT JOIN consignment_inventories ci ON ci.source_submission_id = cs.id
LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id
WHERE cs.worker_id = $1
${submissionKeywordFilter}
UNION ALL
SELECT
NULL AS submission_id,
ci.id AS inventory_id,
ci.created_at AS sort_at,
CASE
WHEN ci.status = 'archived' THEN 'finished'
WHEN wo.status IN ('cancelled', 'accepted', 'completed') THEN 'finished'
ELSE 'active'
END AS group_key
FROM consignment_inventories ci
LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id
WHERE ci.worker_id = $1
AND NOT EXISTS (
SELECT 1 FROM consignment_submissions cs2
WHERE cs2.id = ci.source_submission_id AND cs2.worker_id = $1
)
${orphanKeywordFilter}
)
`
const countsRes = await query<{ group_key: string; count: number }>(
`${baseSql} SELECT group_key, COUNT(*)::integer AS count FROM base GROUP BY group_key`,
values,
)
const counts: Record<WorkerConsignmentGroup, number> = {
all: 0,
reviewing: 0,
active: 0,
finished: 0,
}
for (const row of countsRes.rows) {
const key = String(row.group_key || '') as WorkerConsignmentGroup
const count = Number(row.count || 0)
if (key === 'reviewing' || key === 'active' || key === 'finished') {
counts[key] += count
counts.all += count
}
}
const group = input.group || 'all'
const page = Math.max(1, Number(input.page) || 1)
const pageSize = Math.max(1, Math.min(50, Number(input.pageSize) || 10))
let groupFilter = ''
let groupValues = values
if (group !== 'all') {
groupValues = [...values, group]
groupFilter = `WHERE group_key = $${groupValues.length}`
}
const totalRes = await query<{ total: number }>(
`${baseSql} SELECT COUNT(*)::integer AS total FROM base ${groupFilter}`,
groupValues,
)
const total = Number(totalRes.rows[0]?.total || 0)
const listValues = [...groupValues, pageSize, (page - 1) * pageSize]
const listRes = await query<{
submission_id: string | number | null
inventory_id: string | number | null
sort_at: string
}>(
`${baseSql}
SELECT submission_id, inventory_id, sort_at
FROM base
${groupFilter}
ORDER BY sort_at DESC, COALESCE(submission_id, 0) DESC, COALESCE(inventory_id, 0) DESC
LIMIT $${listValues.length - 1} OFFSET $${listValues.length}`,
listValues,
)
return {
rows: listRes.rows.map((row) => ({
submissionId: row.submission_id == null ? null : Number(row.submission_id),
inventoryId: row.inventory_id == null ? null : Number(row.inventory_id),
createdAt: row.sort_at,
})),
total,
counts,
}
}
/**
* 按 id 批量取提报单(「我的寄卖」分页后回填完整数据用)。
* 这里的顺序不重要,调用方会用 Map 按 id 归位。
*/
export async function listConsignmentSubmissionsByIds(
ids: Array<number | string>,
): Promise<ConsignmentSubmissionRow[]> {
const numericIds = [...new Set(ids.map(Number).filter((n) => Number.isFinite(n) && n > 0))]
if (numericIds.length === 0) return []
const res = await query<ConsignmentSubmissionRow>(
`
SELECT
cs.*,
wc.name AS category_name,
wo.acceptance_json AS gift_evidence_json,
wo.work_order_no AS gift_work_order_no,
wo.status AS gift_work_order_status
FROM consignment_submissions cs
LEFT JOIN work_categories wc ON wc.id = cs.category_id
LEFT JOIN consignment_inventories rinv
ON (rinv.id = cs.result_inventory_id OR rinv.source_submission_id = cs.id)
LEFT JOIN work_orders wo ON wo.id = rinv.bound_work_order_id
WHERE cs.id = ANY($1::bigint[])
`,
[numericIds],
)
return res.rows
}
/**
* 按提报单精确取现货。
*
+14 -3
View File
@@ -45,19 +45,30 @@ router.get('/consignment/price-suggestion', requireActiveWorker, async (req, res
})
/**
* GET /api/v1/worker/consignment/my-items
* 打手查看自己在接单平台寄卖的手枪列表与状态
* GET /api/v1/worker/consignment/my-items?group=&keyword=&page=&pageSize=
* 打手查看自己在接单平台寄卖的手枪列表与状态(服务端搜索 + 分组 + 分页)
*/
router.get('/consignment/my-items', async (req, res) => {
try {
const session = getRequiredWorkerSession(req)
const result = await listWorkerConsignments(session.workerId)
const result = await listWorkerConsignments(session.workerId, {
group: normalizeConsignmentGroup(req.query.group),
keyword: String(req.query.keyword || ''),
page: Number(req.query.page || 1),
pageSize: Number(req.query.pageSize || 10),
})
res.json({ ok: true, data: result })
} catch (error) {
sendRouteError(res, error, '获取我的寄卖列表失败', '[worker/consignment/my-items]')
}
})
function normalizeConsignmentGroup(value: unknown): 'all' | 'reviewing' | 'active' | 'finished' {
const raw = String(value || '').trim()
if (raw === 'reviewing' || raw === 'active' || raw === 'finished') return raw
return 'all'
}
/**
* POST /api/v1/worker/consignment/items/:submissionId/confirm-friend
* 卖家确认已加好买家好友 → 进入赠送冷却期(确认后未完成赠送押金不退)
@@ -418,21 +418,80 @@ test('发布寄卖建议价:按 系统+系列+钻级 从接单模板带出(
assert.equal(hit.suggestedPriceFen, 12000, '多条命中取最高价(12000 > 9999)')
assert.equal(hit.matchedRules.length, 2)
// 系统不符不命中:苹果的 1钻 没配价
// 该端只配了更高档:苹果在暗潮下只有 5钻,1钻没有任何更低档可退 → 落到唯一已配档(5钻)
assert.equal(
suggestConsignmentPriceFromRules(rules, { system: '苹果', series: '暗潮', diamondLevel: 1 }),
null,
suggestConsignmentPriceFromRules(rules, { system: '苹果', series: '暗潮', diamondLevel: 1 })
?.suggestedPriceFen,
30000,
)
// 钻级不符不命中:2钻规则没价格、6钻是另一档
// 该端在该系列下完全没有模板才返回 null(苹果+怒嚎:怒嚎只有安卓配过价)
// 中间钻级没配价 → 退到最接近的已配置钻级:2钻距 1钻(1) 比 6钻(4) 更近 → 报 1钻价
assert.equal(
suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '暗潮', diamondLevel: 2 }),
null,
suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '暗潮', diamondLevel: 2 })
?.suggestedPriceFen,
12000,
'2钻未配价时取最接近的 1钻',
)
// 超出后台最大已配钻级 → 落到最大档
assert.equal(
suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '暗潮', diamondLevel: 7 })
?.suggestedPriceFen,
50000,
'7钻超出配置上限时落到 6钻',
)
// 系列不符不命中
assert.equal(
suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '怒嚎', diamondLevel: 1 }),
null,
)
assert.equal(
suggestConsignmentPriceFromRules(rules, { system: '苹果', series: '怒嚎', diamondLevel: 1 }),
null,
)
})
test('发布寄卖建议价:钻级/璀璨距离相同时取更低档,不给低配报高价', () => {
const rules = [
{ ruleKey: 'd1', productName: '安卓暗潮1钻', rewardAmountFen: 10000 },
{ ruleKey: 'd3', productName: '安卓暗潮3钻', rewardAmountFen: 30000 },
]
// 2钻到 1钻/3钻 距离都是 1 → 取更低的 1钻
assert.equal(
suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '暗潮', diamondLevel: 2 })
?.suggestedPriceFen,
10000,
)
// 低于最小已配钻级 → 落到最小档
assert.equal(
suggestConsignmentPriceFromRules(rules, { system: '安卓', series: '暗潮', diamondLevel: 1 })
?.suggestedPriceFen,
10000,
)
// 璀璨档缺失:3钻只配了 0璀 与 3璀,号主出 1璀 → 距离 1 与 2 → 取 0璀
const brillianceRules = [
{ ruleKey: 'b0', productName: '安卓暗潮3钻', rewardAmountFen: 30000 },
{ ruleKey: 'b3', productName: '安卓暗潮3钻3璀', rewardAmountFen: 45000 },
]
assert.equal(
suggestConsignmentPriceFromRules(brillianceRules, {
system: '安卓',
series: '暗潮',
diamondLevel: 3,
brillianceCount: 1,
})?.suggestedPriceFen,
30000,
'1璀未配价时取最接近的 0璀',
)
assert.equal(
suggestConsignmentPriceFromRules(brillianceRules, {
system: '安卓',
series: '暗潮',
diamondLevel: 3,
brillianceCount: 5,
})?.suggestedPriceFen,
45000,
'超出最大璀璨档时落到 3璀',
)
})
test('发布寄卖建议价:模板写「通用/双端」时两端都命中', () => {
@@ -551,7 +610,7 @@ test('发布寄卖建议价:按璀璨数精准区分价格,不会同钻级
assert.equal(hit5?.suggestedPriceFen, 28000)
})
test('发布寄卖建议价:号主钻级比后台配置高时,按已配置的最高钻级报价(7钻报6钻价,低钻不报高钻)', () => {
test('发布寄卖建议价:号主钻级比后台配置高时按最高档,缺档时取最接近档(距离相同取更低档)', () => {
const rules = [
{ ruleKey: 'rule-1z', productName: '安卓怒嚎1钻', rewardAmountFen: 10000 },
{ ruleKey: 'rule-6z-0c', productName: '安卓怒嚎6钻', rewardAmountFen: 35000 },
@@ -579,16 +638,17 @@ test('发布寄卖建议价:号主钻级比后台配置高时,按已配置
assert.equal(hit7z5c?.suggestedPriceFen, 42000)
assert.equal(hit7z5c?.matchedRules[0].ruleKey, 'rule-6z-2c')
// 低配不能充高配:号主出 2钻(未超出 6钻 上限,但未单独配置 2钻):不命中返回 null,绝不误报 6钻
// 号主出 2钻:后台只配了 1钻 与 6钻,2钻到 1钻(距离1) 比到 6钻(距离4) 更近 → 报 1钻价,绝不误报 6钻
const hit2z = suggestConsignmentPriceFromRules(rules, {
system: '安卓',
series: '怒嚎',
diamondLevel: 2,
brillianceCount: 0,
})
assert.equal(hit2z, null)
assert.equal(hit2z?.suggestedPriceFen, 10000)
assert.equal(hit2z?.matchedRules[0].ruleKey, 'rule-1z')
// 号主出 1钻,但如果后台只有 3钻 以上规则,不命中返回 null
// 号主出 1钻,后台只有 3钻 规则:没有任何更低档可退,只能落到唯一的最低已配档(3钻)
const onlyHighRules = [{ ruleKey: 'rule-3z', productName: '安卓怒嚎3钻', rewardAmountFen: 20000 }]
assert.equal(
suggestConsignmentPriceFromRules(onlyHighRules, {
@@ -596,8 +656,8 @@ test('发布寄卖建议价:号主钻级比后台配置高时,按已配置
series: '怒嚎',
diamondLevel: 1,
brillianceCount: 0,
}),
null,
})?.suggestedPriceFen,
20000,
)
})
@@ -623,12 +623,12 @@ export type ConsignmentPriceSuggestionRule = {
*
* 模板的商品名(如「安卓暗潮1钻」)用与订单同一套规格解析器拆解:
* 系统(含通用通配)、系列必须一致;区服不参与比对。
* 钻级与璀璨数支持高配向下兜底匹配(号主出得比配置高时,报价当前已配置的最高规格):
* 1. 钻级对齐:优先精确匹配号主钻级;若号主钻级高于后台配置上限(如号主出7钻但后台只配了1~6钻),
* 按后台已配置的最大钻级(6钻)报价。低钻不能配高钻(号主出1钻不报6钻价)。
* 2. 璀璨对齐:在该钻级下,优先精确匹配号主璀璨;若号主璀璨高于后台配置上限(如号主出3~5璀但后台只配了1~2璀),
* 按后台该钻级已配置的最大璀璨档位报价。
* 3. 命中多条(如通用与专属端同存)取最高价,卖家仍可手动修改。
* 钻级与璀璨数按「最接近的已配置档位」兜底,保证规格选齐后一定给得出价格:
* 1. 钻级对齐:优先精确匹配号主钻级;该钻级没配价时取最接近的已配置钻级,
* 距离相同取更低档(号主出2钻、后台只配了1钻与3钻 → 报1钻价)。低钻不能配高钻。
* 2. 璀璨对齐:在该钻级下,优先精确匹配号主璀璨;该璀璨档没配价时同样取最接近的已配置档位,距离相同取更低档。
* 3. 命中多条(如通用与专属端同存)取最高价。
* 只有当「该系列在该端下完全没有配置过任何有价模板」时才返回 null。
*/
export function suggestConsignmentPriceFromRules(
rules: ConsignmentPriceSuggestionRule[],
@@ -671,10 +671,11 @@ export function suggestConsignmentPriceFromRules(
if (seriesCandidates.length === 0) return null
// 2. 钻级对齐:
// 查找当前系统与系列已配置的最大钻级;若号主出的钻级比后台配置高(如后台只设置了6个价格,号主出7个),
// 按后台已配置的最大钻级报价;未超出配置上限时必须精确对齐钻级(低钻不报高钻,中间未配价的钻级不向下误报)
const maxConfiguredDiamond = Math.max(...seriesCandidates.map((item) => item.diamondLevel))
const targetDiamond = wantedDiamond > maxConfiguredDiamond ? maxConfiguredDiamond : wantedDiamond
// 优先精确对齐;该钻级没配价时退到「最接近的已配置钻级」(如后台只配了 1/3/7 钻、号主出 2 钻 → 取 1 钻),
// 距离相同取更低档,避免把低钻按高钻价报出去;号主钻级高于后台已配最大档时自然落到最大档。
const configuredDiamonds = Array.from(new Set(seriesCandidates.map((item) => item.diamondLevel)))
const targetDiamond = pickNearestConfiguredTier(configuredDiamonds, wantedDiamond)
if (targetDiamond === null) return null
const targetDiamondCandidates = seriesCandidates.filter(
(item) => item.diamondLevel === targetDiamond,
@@ -682,25 +683,20 @@ export function suggestConsignmentPriceFromRules(
if (targetDiamondCandidates.length === 0) return null
// 3. 璀璨对齐:
// 若未指定璀璨(null),取 targetDiamond 下所有规则
// 若指定了璀璨:比后台配置高(如后台该钻级只配置到 1~2 璀,号主出 3/4/5 等),按已配置的最大璀璨报价;
// 未超出配置上限时严格精确对齐璀璨档位(0璀对0璀、1璀对1璀,不拿高璀价格报给低璀)
// 若未指定璀璨(null),取 targetDiamond 下所有规则;
// 若指定了璀璨:优先精确对齐,该璀璨档没配价时退到最接近的已配置档位,距离相同取更低档。
let matchedItems: typeof targetDiamondCandidates
if (wantedBrilliance === null) {
matchedItems = targetDiamondCandidates
} else {
const maxConfiguredBrilliance = Math.max(
...targetDiamondCandidates.map((item) => item.brillianceCount),
const configuredBrilliance = Array.from(
new Set(targetDiamondCandidates.map((item) => item.brillianceCount)),
)
const targetBrilliance = pickNearestConfiguredTier(configuredBrilliance, wantedBrilliance)
if (targetBrilliance === null) return null
matchedItems = targetDiamondCandidates.filter(
(item) => item.brillianceCount === targetBrilliance,
)
if (wantedBrilliance > maxConfiguredBrilliance) {
matchedItems = targetDiamondCandidates.filter(
(item) => item.brillianceCount === maxConfiguredBrilliance,
)
} else {
matchedItems = targetDiamondCandidates.filter(
(item) => item.brillianceCount === wantedBrilliance,
)
}
}
if (matchedItems.length === 0) return null
@@ -708,3 +704,26 @@ export function suggestConsignmentPriceFromRules(
const matchedRules = matchedItems.map((item) => item.rule)
return { suggestedPriceFen, matchedRules }
}
/**
* 从「已配置档位」里挑最接近目标的一档。
* 距离相同时取更低档:宁可少报也不要把低配按高配价格报出去。
*/
function pickNearestConfiguredTier(configured: number[], wanted: number): number | null {
const tiers = configured
.map((value) => Math.round(Number(value)))
.filter((value) => Number.isFinite(value))
.sort((a, b) => a - b)
if (tiers.length === 0) return null
let best = tiers[0]!
let bestDistance = Math.abs(best - wanted)
for (const tier of tiers) {
const distance = Math.abs(tier - wanted)
if (distance < bestDistance) {
best = tier
bestDistance = distance
}
}
return best
}
@@ -95,3 +95,15 @@ test('未开通寄卖白名单权限的打手提报寄卖时被强校验拦截',
'未开通必须返回专门的 consignment_permission_required 错误码',
)
})
test('寄售价以接单模板为准:服务端复算模板价,不信任前端传来的金额', async () => {
const serviceSource = await readServiceSource()
assert.ok(
serviceSource.includes('getWorkerConsignmentPriceSuggestion({'),
'发布时必须按同一套模板规则复算价格',
)
assert.ok(
serviceSource.includes('templatePriceFen > 0 ? templatePriceFen : submittedPriceFen'),
'模板命中时用模板价,只有完全没配模板时才回落到调用方传来的金额',
)
})
@@ -4,8 +4,10 @@ import {
createConsignmentSubmissionWithClient,
getConsignmentInventoryBySubmissionId,
getConsignmentSubmissionById,
listConsignmentInventories,
listConsignmentSubmissions,
listConsignmentInventoriesByIds,
listConsignmentSubmissionsByIds,
listWorkerConsignmentPage,
type WorkerConsignmentGroup,
} from '../../repositories/worker-platform/consignment-repo.js'
import { getWorkerUserById } from '../../repositories/worker-platform/index.js'
import { listEnabledWorkProductRulesForSuggestion } from '../../repositories/worker-platform/product-match-repo.js'
@@ -85,7 +87,7 @@ export type ConsignmentSellerInfo = {
}
function extractConsignmentSellerInfoFromSubmission(
sub: Awaited<ReturnType<typeof listConsignmentSubmissions>>['items'][number],
sub: Awaited<ReturnType<typeof listConsignmentSubmissionsByIds>>[number],
): ConsignmentSellerInfo {
const content = toRecord(sub.content_json) || {}
let characterName = String(content.characterName || '').trim()
@@ -114,7 +116,7 @@ function extractConsignmentSellerInfoFromSubmission(
}
function extractConsignmentSellerInfoFromInventory(
inv: Awaited<ReturnType<typeof listConsignmentInventories>>['items'][number],
inv: Awaited<ReturnType<typeof listConsignmentInventoriesByIds>>[number],
): ConsignmentSellerInfo {
let characterName = ''
let characterId = ''
@@ -244,13 +246,18 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
})
}
const askingPriceFen = Math.max(100, Math.round(Number(payload.askingPriceFen || 0)))
if (askingPriceFen < 100) {
throw createHttpError('寄售价格不能少于1元', {
statusCode: 400,
errorCode: 'invalid_asking_price',
})
}
// 寄售价以「接单模板」为准:服务端按同一套规则复算,不信任前端传来的金额
// (卖家端已把该字段改成只读展示,这里再兜一道,避免直连接口随意定价)。
// 只有该规格在模板里完全没配过价时才回落到调用方传来的值。
const priceSuggestion = await getWorkerConsignmentPriceSuggestion({
system,
series,
diamondLevel,
brillianceCount,
})
const templatePriceFen = Math.max(0, Math.round(Number(priceSuggestion.suggestedPriceFen || 0)))
const submittedPriceFen = Math.max(100, Math.round(Number(payload.askingPriceFen || 0)))
const askingPriceFen = templatePriceFen > 0 ? templatePriceFen : submittedPriceFen
const depositAmountFen = getConsignmentPublishDepositFen()
const sellerPhone = String(payload.sellerPhone || worker.phone || '').trim()
@@ -348,26 +355,45 @@ export async function publishWorkerConsignment(workerId: number, payload: JsonOb
* 卖家看到同一件货两行、两个号。现在**以提报单号为唯一锚点**聚合:一个提报单一条记录,
* 库存与绑定工单的信息挂上去,阶段由统一状态机推导(三套底层状态 → 一个卖家看得懂的阶段)。
*/
export async function listWorkerConsignments(workerId: number) {
const [submissionsRes, inventoriesRes] = await Promise.all([
listConsignmentSubmissions({ workerId, pageSize: 200 }),
listConsignmentInventories({ workerId, pageSize: 200 }),
])
/**
* 「我的寄卖」列表:服务端搜索 + 分组 + 分页。
*
* 分组、关键字、翻页全部在 SQL 完成(见 listWorkerConsignmentPage),
* 这里只负责按行标识回填完整数据并推导展示阶段——阶段口径仍然只由
* domain/consignment-item-stage.ts 决定,避免两处状态机漂移。
*/
export async function listWorkerConsignments(
workerId: number,
options: {
group?: WorkerConsignmentGroup | undefined
keyword?: string | undefined
page?: number | undefined
pageSize?: number | undefined
} = {},
) {
const page = await listWorkerConsignmentPage({
workerId,
group: options.group,
keyword: options.keyword,
page: options.page,
pageSize: options.pageSize,
})
const publishDepositFen = getConsignmentPublishDepositFen()
const inventoryBySubmission = new Map<number, (typeof inventoriesRes.items)[number]>()
const orphanInventories: typeof inventoriesRes.items = []
for (const inv of inventoriesRes.items) {
const submissionId = Number(inv.source_submission_id || 0)
if (submissionId > 0 && !inventoryBySubmission.has(submissionId)) {
inventoryBySubmission.set(submissionId, inv)
} else {
// 客服手工入库的现货没有提报单,用库存号自己当锚点
orphanInventories.push(inv)
}
}
const submissionIds = page.rows
.map((row) => row.submissionId)
.filter((id): id is number => Number(id) > 0)
const inventoryIds = page.rows
.map((row) => row.inventoryId)
.filter((id): id is number => Number(id) > 0)
const [submissions, inventories] = await Promise.all([
listConsignmentSubmissionsByIds(submissionIds),
listConsignmentInventoriesByIds(inventoryIds),
])
const submissionById = new Map(submissions.map((sub) => [Number(sub.id), sub]))
const inventoryById = new Map(inventories.map((inv) => [Number(inv.id), inv]))
const boundWorkOrderIds = [...inventoryBySubmission.values(), ...orphanInventories]
const boundWorkOrderIds = inventories
.map((inv) => Number(inv.bound_work_order_id || 0))
.filter((id) => id > 0)
const workOrders = await listWorkOrdersByIds(boundWorkOrderIds)
@@ -376,7 +402,7 @@ export async function listWorkerConsignments(workerId: number) {
const viewer = { role: 'seller', workerId: Number(workerId) } as const
const resolveStage = (
submissionStatus: string,
inv: (typeof inventoriesRes.items)[number] | undefined,
inv: (typeof inventories)[number] | undefined,
) => {
const workOrder = inv?.bound_work_order_id
? workOrderById.get(Number(inv.bound_work_order_id))
@@ -399,98 +425,101 @@ export async function listWorkerConsignments(workerId: number) {
})
}
const items = [
...submissionsRes.items.map((sub) => {
const inv = inventoryBySubmission.get(Number(sub.id))
const boundWo = inv?.bound_work_order_id
? workOrderById.get(Number(inv.bound_work_order_id))
: undefined
const stage = resolveStage(String(sub.status || ''), inv)
const evidence = extractConsignmentGiftEvidenceFromWorkOrder(boundWo)
return {
/** 唯一锚点:提报单号贯穿发布→审核→在库→匹配→冷却→赠送→结算 */
consignmentNo: sub.submission_no,
submissionId: sub.id,
inventoryId: inv?.id ?? null,
workOrderId: inv?.bound_work_order_id ?? null,
itemName: sub.item_name,
system: sub.system,
serverZone: sub.server_zone,
series: sub.series,
diamondLevel: sub.diamond_level,
brillianceCount: sub.brilliance_count,
askingPriceFen: sub.asking_price_fen,
depositAmountFen: sub.deposit_amount_fen,
reviewNote: sub.review_note,
// 库里存的是入库时的签名链接(15 分钟有效),出参必须重签,否则卖家自己回头看也是裂图
screenshotsJson: (Array.isArray(sub.screenshots_json) ? sub.screenshots_json : [])
.filter((url): url is string => typeof url === 'string' && url.trim().length > 0)
.map((url) => refreshStoredFileUrl(url)),
giftEvidenceImages: evidence.giftEvidenceImages,
giftEvidenceNote: evidence.giftEvidenceNote,
giftEvidenceSubmittedAt: evidence.giftEvidenceSubmittedAt,
createdAt: sub.created_at,
...stage,
/** 冷却截止时间,前端拿它显示倒计时 */
giftAvailableAt: inv?.bound_work_order_id
? (workOrderById.get(Number(inv.bound_work_order_id))?.gift_available_at ?? null)
: null,
friendAddedAt: inv?.bound_work_order_id
? (workOrderById.get(Number(inv.bound_work_order_id))?.friend_added_at ?? null)
: null,
sellerInfo: extractConsignmentSellerInfoFromSubmission(sub),
buyerInfo: extractConsignmentBuyerInfoFromWorkOrder(boundWo),
}
}),
...orphanInventories.map((inv) => {
// 手动入库已停用:没有提报单的货不该再出现,这里只做兜底展示(不显示内部仓库号、不给操作能力)
const boundWo = inv.bound_work_order_id
? workOrderById.get(Number(inv.bound_work_order_id))
: undefined
const stage = resolveStage('approved', inv)
const evidence = extractConsignmentGiftEvidenceFromWorkOrder(boundWo)
return {
consignmentNo: '客服手工入库',
...(stage.canConfirmFriend || stage.canSubmitEvidence
? { canConfirmFriend: false, canSubmitEvidence: false }
: {}),
submissionId: null,
inventoryId: inv.id,
workOrderId: inv.bound_work_order_id ?? null,
itemName: inv.item_name,
system: inv.system,
serverZone: inv.server_zone,
series: inv.series,
diamondLevel: inv.diamond_level,
brillianceCount: inv.brilliance_count,
askingPriceFen: inv.cost_price_fen,
depositAmountFen: inv.deposit_amount_fen,
reviewNote: '',
screenshotsJson: [],
giftEvidenceImages: evidence.giftEvidenceImages,
giftEvidenceNote: evidence.giftEvidenceNote,
giftEvidenceSubmittedAt: evidence.giftEvidenceSubmittedAt,
createdAt: inv.created_at,
...stage,
giftAvailableAt: inv.bound_work_order_id
? (workOrderById.get(Number(inv.bound_work_order_id))?.gift_available_at ?? null)
: null,
friendAddedAt: inv.bound_work_order_id
? (workOrderById.get(Number(inv.bound_work_order_id))?.friend_added_at ?? null)
: null,
sellerInfo: extractConsignmentSellerInfoFromInventory(inv),
buyerInfo: extractConsignmentBuyerInfoFromWorkOrder(boundWo),
}
}),
].sort((a, b) => Date.parse(String(b.createdAt || '')) - Date.parse(String(a.createdAt || '')))
const buildSubmissionItem = (
sub: (typeof submissions)[number],
inv: (typeof inventories)[number] | undefined,
) => {
const boundWo = inv?.bound_work_order_id
? workOrderById.get(Number(inv.bound_work_order_id))
: undefined
const stage = resolveStage(String(sub.status || ''), inv)
const evidence = extractConsignmentGiftEvidenceFromWorkOrder(boundWo)
return {
/** 唯一锚点:提报单号贯穿发布→审核→在库→匹配→冷却→赠送→结算 */
consignmentNo: sub.submission_no,
submissionId: sub.id,
inventoryId: inv?.id ?? null,
workOrderId: inv?.bound_work_order_id ?? null,
itemName: sub.item_name,
system: sub.system,
serverZone: sub.server_zone,
series: sub.series,
diamondLevel: sub.diamond_level,
brillianceCount: sub.brilliance_count,
askingPriceFen: sub.asking_price_fen,
depositAmountFen: sub.deposit_amount_fen,
reviewNote: sub.review_note,
// 库里存的是入库时的签名链接(15 分钟有效),出参必须重签,否则卖家自己回头看也是裂图
screenshotsJson: (Array.isArray(sub.screenshots_json) ? sub.screenshots_json : [])
.filter((url): url is string => typeof url === 'string' && url.trim().length > 0)
.map((url) => refreshStoredFileUrl(url)),
giftEvidenceImages: evidence.giftEvidenceImages,
giftEvidenceNote: evidence.giftEvidenceNote,
giftEvidenceSubmittedAt: evidence.giftEvidenceSubmittedAt,
createdAt: sub.created_at,
...stage,
/** 冷却截止时间,前端拿它显示倒计时 */
giftAvailableAt: boundWo?.gift_available_at ?? null,
friendAddedAt: boundWo?.friend_added_at ?? null,
sellerInfo: extractConsignmentSellerInfoFromSubmission(sub),
buyerInfo: extractConsignmentBuyerInfoFromWorkOrder(boundWo),
}
}
const buildOrphanInventoryItem = (inv: (typeof inventories)[number]) => {
// 手动入库已停用:没有提报单的货不该再出现,这里只做兜底展示(不显示内部仓库号、不给操作能力)
const boundWo = inv.bound_work_order_id
? workOrderById.get(Number(inv.bound_work_order_id))
: undefined
const stage = resolveStage('approved', inv)
const evidence = extractConsignmentGiftEvidenceFromWorkOrder(boundWo)
return {
consignmentNo: '客服手工入库',
...(stage.canConfirmFriend || stage.canSubmitEvidence
? { canConfirmFriend: false, canSubmitEvidence: false }
: {}),
submissionId: null,
inventoryId: inv.id,
workOrderId: inv.bound_work_order_id ?? null,
itemName: inv.item_name,
system: inv.system,
serverZone: inv.server_zone,
series: inv.series,
diamondLevel: inv.diamond_level,
brillianceCount: inv.brilliance_count,
askingPriceFen: inv.cost_price_fen,
depositAmountFen: inv.deposit_amount_fen,
reviewNote: '',
screenshotsJson: [],
giftEvidenceImages: evidence.giftEvidenceImages,
giftEvidenceNote: evidence.giftEvidenceNote,
giftEvidenceSubmittedAt: evidence.giftEvidenceSubmittedAt,
createdAt: inv.created_at,
...stage,
giftAvailableAt: boundWo?.gift_available_at ?? null,
friendAddedAt: boundWo?.friend_added_at ?? null,
sellerInfo: extractConsignmentSellerInfoFromInventory(inv),
buyerInfo: extractConsignmentBuyerInfoFromWorkOrder(boundWo),
}
}
// 顺序完全由服务端分页决定,这里不再排序
const items = page.rows
.map((row) => {
const sub = row.submissionId ? submissionById.get(Number(row.submissionId)) : undefined
const inv = row.inventoryId ? inventoryById.get(Number(row.inventoryId)) : undefined
if (sub) return buildSubmissionItem(sub, inv)
return inv ? buildOrphanInventoryItem(inv) : null
})
.filter((item): item is NonNullable<typeof item> => item !== null)
return {
publishDepositFen,
items,
stageCounts: items.reduce<Record<string, number>>((acc, item) => {
acc[item.stage] = (acc[item.stage] || 0) + 1
return acc
}, {}),
total: page.total,
page: Math.max(1, Number(options.page) || 1),
pageSize: Math.max(1, Number(options.pageSize) || 10),
counts: page.counts,
}
}