docs: 重构项目文档并补充影子总账与财务集成方案

This commit is contained in:
yml2213
2026-10-17 17:06:36 +08:00
parent 6850bc64ff
commit a5f2be6669
26 changed files with 1233 additions and 83 deletions
@@ -0,0 +1,213 @@
# 寄售仓库 / 寄卖子系统 深度技术分析
> 分析基线:`ef491660`(含 `9298b31b` 白名单总开关)。全部结论取自仓库真实代码,行号可复核。
## 一、业务定位与术语表(本仓库口径)
| 术语 | 代码实体 | 含义与关键约束 |
| --- | --- | --- |
| 寄卖 / 寄存 | `consignment_*` 全套 | 打手把自己在《和平精英》的**苏尔南手枪**挂到平台,由平台撮合卖给买家 |
| 现货 / 库存 | `consignment_inventories` | 审核通过后落库的一件真实货物,`status ∈ in_stock/consumed/archived` |
| 提报 / 上架 | `consignment_submissions`(打手端 `publishWorkerConsignment`) | 打手端唯一入口,发布即冻结发布押金;`SUB…` 单号是全流程唯一锚点(`consignment-worker-service.ts:411`) |
| 审核入库 | `approveAdminConsignmentSubmission` | 客服审核通过 → 生成 `INV…` 库存行(`consignment-repo.ts:1170-1217`);驳回退押金 |
| 匹配 | `consignment-matching-service.ts` | 按系统/区服/系列/钻级/璀璨做规格比对,`exact/partial/conflict` |
| 履约(两条) | `internal_stock` / `worker_deposit` | 内部现货:客服/内部仓代交付(无接单打手);押金履约:指派打手交货并冻结履约押金 |
| 待加好友 | `parkWorkOrderWaitingFriendAddWithClient` | 占库后工单停在 `gift_phase=material_required`,等人确认加好友 |
| 赠送冷却 | `gift_phase=friend_countdown` | 确认加好友后进入冷却,冷却结束才可上传赠送截图 |
| 赔付 / 没收 | `forfeitConsignmentPublishDeposit*`、`settleWorkOrderDepositWithClient` | 卖家发布押金 / 打手履约押金的没收与退还 |
| 押金履约押金 | `worker_wallets.frozen_deposit_amount` | 发布押金与工单履约押金共用同一冻结池,靠 `payload_json.kind` 区分口径 |
| 白名单 | `worker_users.can_consign` + 总开关 | 详见第八节 |
## 二、端到端流程
```mermaid
flowchart TD
A[打手端提报 苏尔南] -->|检查白名单+冻结发布押金 ¥20| B[consignment_submissions pending_review]
B -->|客服驳回| B2[rejected + 释放押金]
B -->|客服审核通过| C[consignment_inventories in_stock/待安排]
C -->|工单发布前候选提醒 / 手动匹配| D{匹配方式}
D -->|internal_stock| E1[原子占库 consumed/已安排 + 工单绑 material + 停待加好友]
D -->|worker_deposit| E2[条件占库 + 指派打手冻结履约押金 + in_progress]
E1 --> F[确认加好友 → 赠送冷却]
E2 --> F
F -->|冷却结束| G[卖家/打手上传赠送截图 → pending_acceptance]
G --> H[客服验收 accepted]
H --> I[释放发布押金 + 结算供货货款 cost_price_fen]
E1 -.撤销履约 reopen/回库/作废.-> C
E2 -.撤单/取消指派/超时.-> C
```
工单终态解绑出口唯一:`releaseConsignmentInventoryBindingForWorkOrderWithClient`(`consignment-repo.ts:765`),已接入 `work-order-cancel-repo.ts:84,202,325,448,537`、`worker-cancel-request-repo.ts:572`、`work-order-issue-repo.ts:344`。
## 三、数据模型
| 表 | 关键字段 | 状态枚举 | 索引/约束 |
| --- | --- | --- | --- |
| `consignment_inventories` (077:3-39, 078:3-7, 079:49-58) | `inventory_no` UNIQUE、`item_name/system/server_zone/series/diamond_level/brilliance_count`、`cost_price_fen`、`credentials_json`(账号密码)、`screenshots_json`、`allow_matching`、`worker_id`(卖家)、`assigned_worker_id`(履约打手)、`deposit_amount_fen`、`bound_order_id/bound_work_order_id/bound_platform_order_id/shop_name` | `status ∈ in_stock/consumed/archived`(077:25-26);`allocation_status ∈ 待安排/已安排`(080:33-35);`source_type ∈ manual/worker_portal/recycle`(079:38-40) | `uniq_consignment_inventories_bound_work_order` 部分唯一(`bound_work_order_id WHERE status='consumed'`, 079:93-95);`idx_..._status_matching`、`_game_category`、`_specs`、`_buyer`、`_shop`、`_batch`、`_worker_id`、`_assigned_worker_id` |
| `consignment_submissions` (077:64-91, 078:9-13) | `submission_no` UNIQUE、`result_inventory_id`、`content_json`(characterName/Id/sellerNote/channel)、`asking_price_fen`、`deposit_amount_fen`、`worker_id/name/phone` | `status ∈ pending_review/approved/rejected`(077:83-84) | `idx_..._status_created`;`link_id` 已被 079:11-12 删除 |
| `consignment_collect_links` | — | — | **079:15 已整体 DROP** |
| `worker_users` (082:4-6) | `can_consign`(默认 FALSE)、`consignment_activated_at` | — | `idx_worker_users_can_consign`(082:12-13) |
| `worker_wallet_ledgers` | `ledger_type`、`payload_json.kind=consignment_publish`、`consignmentSubmissionId` | `deposit_freeze/deposit_release/deposit_deduction` | `uniq_worker_wallet_ledgers_consignment_publish_freeze`(079:177-180) |
**079 为什么删除收集链接**:H5 定向收集(`/consignment-collect/:token` + 公开 `link/submit/upload`)存在两个致命问题——上传响应层级取错导致截图恒为空(旧 `ConsignmentCollectPage.tsx`,优化方案 P0-1),以及按手机号自动绑定打手可让第三方被动冻结其押金(优化方案 P2-5)。业务改为「只能在接单平台提报」,于是删表删列,`source_type` 的 `collection_link` 归并为 `worker_portal`(079:1-8、17-40)。同一次迁移还补了两件资金/一致性收口:`assigned_worker_id` 拆分(079:47-54,防止占库覆盖卖家导致"我的寄卖"丢件)与冻结流水幂等唯一索引 + 历史重复冻结去重退款(079:97-180)。
## 四、核心服务清单
| 文件(仓库根相对) | 职责 | 关键导出 |
| --- | --- | --- |
| `apps/backend/src/services/worker-platform/consignment-worker-service.ts` | 打手端:提报(白名单+规格校验+冻结押金)、我的寄卖聚合、确认加好友、提交赠送截图、建议价 | `publishWorkerConsignment`(:146)、`listWorkerConsignments`(:351)、`confirmWorkerConsignmentFriendAdded`(:551)、`submitWorkerConsignmentGiftEvidence`(:566)、`getWorkerConsignmentPriceSuggestion`(:581) |
| `.../consignment-matching-service.ts` | 规格解析/归一、三态比对、候选排序、手动匹配分类、建议价规则 | `extractPeacekeeperSpecs`(:245)、`matchPeacekeeperOrderWithInventory`(:309)、`classifyConsignmentMatch`(:518)、`listConsignmentMatchOptions`(:552)、`findMatchingPeacekeeperInventories`(:569)、`suggestConsignmentPriceFromRules`(:633) |
| `.../consignment-fulfillment-service.ts` | 履约:内部现货履约、指派打手履约、补偿回滚、撤销、候选查询、服务端规格复检 | `listWorkOrderInventoryMatchOptions`(:179)、`listInventoryMatchableOrders`(:220)、`checkWorkOrderInventoryMatches`(:305)、`fulfillWorkOrderWithConsignment`(:345)、`assignWorkOrderWithConsignmentWorker`(:603)、`resolveConsignmentAssignRewardFen`(:590)、`revokeWorkOrderConsignmentFulfillment`(:910) |
| `.../consignment-admin-service.ts` | 后台:库存 CRUD/开关/作废/回库、提报审核、映射 | `mapConsignmentInventory`(:158)、`fetchAdminConsignmentInventories`(:261)、`createAdminConsignmentInventory`(:319,已 409 停用)、`updateAdminConsignmentInventory`(:326)、`archiveAdminConsignmentInventory`(:414)、`returnAdminConsignmentInventory`(:471)、`approve/reject`(:564/:648) |
| `.../repositories/worker-platform/consignment-repo.ts` | 库存/提报全部 SQL、占库/释放/解绑唯一出口 | `claimConsignmentInventoryForWorkerWithClient`(:500)、`releaseConsignmentInventoryClaimWithClient`(:563)、`archiveConsignmentInventoryWithClient`(:673)、`returnConsignmentInventoryToStockWithClient`(:706)、`releaseConsignmentInventoryBindingForWorkOrderWithClient`(:765)、`approveConsignmentSubmissionWithInbound`(:1126) |
| `.../repositories/worker-platform/consignment-deposit-repo.ts` | 发布押金冻结/释放/没收、供货货款结算 | `freezeConsignmentPublishDepositWithClient`(:51)、`release...WithClient`(:118)、`getConsignmentDepositLedgerSnapshot`(:186)、`forfeit...BySubmissionWithClient`(:332)、`forfeit...ByWorkOrderWithClient`(:399)、`settleConsignmentSupplierPayoutByWorkOrderWithClient`(:441) |
| `.../repositories/worker-platform/consignment-supplier-repo.ts` | 从工单反查供货卖家(货款与售后共用) | `getConsignmentSupplierByWorkOrderWithClient`(:20) |
### 手动匹配(f07e4087)如何工作
1. 两个只读入口:订单侧 `GET /matches/:workOrderId/options` → `listWorkOrderInventoryMatchOptions` 返回**全部**在库现货,按 `exact(0)/partial(1)/conflict(2)` + 成本价 + id 排序(`consignment-fulfillment-service.ts:191-206`);库存侧 `GET /inventories/:id/match-orders` → `listInventoryMatchableOrders` 反查 `unassigned/pending_material/open` 工单(:252-261)。
2. 前端 `ConsignmentManualMatchModal.tsx` 按 `matchType` 决定确认项:`partial` 勾"允许非精确",`conflict` 勾"确认规格不一致"且**必填原因**(`utils/consignment-manual-match.ts:19-53`)。
3. 落地只走 `fulfillWorkOrderWithConsignment`(`ConsignmentManualMatchModal.tsx:178-184`),把 `allowPartial/allowSpecMismatch/matchNote` 传后端。
4. 后端二次复检:`assertConsignmentSpecMatchedOrder`(:73-110) 用宽松模式复检,真冲突默认 409(`consignment_spec_mismatch`),仅 `allowSpecMismatch=true` 且 `matchNote` 非空才放行(`resolveManualMatchNote`:155-167),冲突明细写入工单事件与 `material.consignment.specConflicts`(:489-495)。
## 五、状态机
**库存项展示阶段** `deriveConsignmentItemStage`(`domain/consignment-item-stage.ts:146-248`)把「提报单 + 库存 + 绑定工单」三套状态推导为唯一阶段:
`pending_review` → `in_stock` → (consumed 后按工单) `matched_waiting_friend` → `gift_cooldown` → `gift_ready` → `gift_submitted` → `completed`;标准验收走 `fulfilling`/`pending_acceptance`;异常终态 `rejected`/`archived`/`cancelled`(:20-34、46-78)。
| 迁移 | 触发者 | 代码位置 | 附加效果 |
| --- | --- | --- | --- |
| `in_stock → consumed`(内部现货) | 客服 `consignment.fulfill` | `consignment-fulfillment-service.ts:424-453` | 锁单 `FOR UPDATE` + 写绑定 + 停在待加好友 |
| `in_stock → consumed`(指派打手) | 客服 | `consignment-repo.ts:511-540` | 写 `assigned_worker_id='已安排'`,再调通用指派 |
| `consumed → in_stock`(撤销回库) | 客服 | `consignment-fulfillment-service.ts:986-1000` | 带 `AND bound_work_order_id=$4` 仲裁;发布押金 auto=保持冻结 |
| `consumed → archived` | 客服/自动 | 同上 :971-984、`consignment-admin-service.ts:414-440` | 已确认加好友**强制**作废不回池(:968-969) |
| `in_stock → archived` | 客服 | `consignment-repo.ts:673` | 释放发布押金 |
| `consumed → in_stock`(人工回库) | 客服 | `consignment-repo.ts:706-763` | 需 `NO_LIVE_BOUND_WORK_ORDER_SQL`;回库**重新冻结**押金(`ensure...`:262) |
| `in_stock → in_stock`(终态解绑) | 系统 | `consignment-repo.ts:765` | 撤单/超时/取消指派自动回池,押金不动 |
`allocation_status` 只由系统写:占库写 `已安排`(`consignment-repo.ts:533`、履约服务 :428),回库/撤销写 `待安排`(:990、release :569)。
## 六、匹配规则
- **品类隔离门禁**:`detectItemCategory` 识别手枪/密钥/机甲/车皮等,双方都能识别且不一致直接 `null`;现货为 `pistol_suernan` 时订单必须含系列或"苏尔南/手枪/枪皮"信号(`consignment-matching-service.ts:319-340`)。
- **规格三态**:`compareSpec`(:292-304) 返回 `match/mismatch/unknown`;通配 `SYSTEM_WILDCARDS=['通用']`、`ZONE_WILDCARDS=['通用','互通']`(:288-290)。
- **档位规则**:钻级/璀璨不得低配充高配(:359-360);`unknown` 项禁止判精确(:363-374)。
- **严格/宽松**:默认严格——系统/区服/系列任一非 `match` 即排除,且无"高配"正信号也排除(:394-418),回退掉了"只写游戏名就命中全库"的旧行为;宽松模式 `allowUnspecifiedSpecs` 仅供人工选定后复检(:394、`consignment-fulfillment-service.ts:88-90`)。
- **排序**:`score DESC → cost_price_fen ASC → inbound_at ASC → id ASC`(:597-611)。
- **白名单/手动匹配**:白名单只管"能否发布",不参与匹配(`domain/consignment-whitelist.ts:7-12`);手动匹配可越过任何规格门禁,代价是必填原因并留痕。
- **建议价**:按系统(含通用)+系列精确、钻级/璀璨向上兜底取后台已配最高档,多命中取最高(:633-710)。
## 七、资金 / 押金 / 赔付
| 事件 | 资金动作 | 代码 |
| --- | --- | --- |
| 发布提报 | 按 `consignmentPublishDepositFen`(默认 2000 分 = ¥20,`worker-finance-config-service.ts:73-78,108`)从可用余额冻结,余额不足 409 | `consignment-worker-service.ts:255,297`;`consignment-deposit-repo.ts:51-113` |
| 审核驳回 | 全额释放(幂等:`outstanding=0` 即空操作) | `consignment-admin-service.ts:670-674` |
| 库存作废 | 释放发布押金 | `consignment-admin-service.ts:420-426` |
| 人工回库 | **重新冻结**(错失则 409 `consignment_deposit_already_settled`) | :500-506、:262-314 |
| 验收通过 | 释放发布押金 + 补发**供货货款** `cost_price_fen \|\| asking_price_fen` 给卖家 | `work-order-settlement-repo.ts:99-104`、:429、:679;`consignment-deposit-repo.ts:441-...` |
| 确认加好友后违约/取消/超时 | 没收发布押金(`deposit_deduction`) | `work-order-cancel-repo.ts:140,261,385`、`work-order-issue-repo.ts:68,254` |
| 撤销寄售履约 | 打手履约押金按 `auto/refund/forfeit`;卖家发布押金按 `auto/archive→release/回库→keep_frozen/forfeit` | `consignment-fulfillment-service.ts:1016-1058`;`domain/consignment-deposit-policy.ts:54-77` |
| 防双付 | 指派对象≠供货卖家时报酬保持原值,货款走供货货款渠道;=卖家则改报酬为结算价 | `consignment-fulfillment-service.ts:590-601,689-695`;`consignment-deposit-repo.ts:459` |
## 八、权限与白名单(082 + cb8b249e / 9298b31b)
- **总开关**:`worker_finance_config.consignmentWhitelistEnabled`(非 DB 列),默认 `true`(`worker-finance-config-service.ts:16-19,79-80,108`);后台在资金配置面板切换。
- **单人权限**:`worker_users.can_consign`,客服在打手管理列表 Switch 实时开关(082:4-6;`admin-worker-user-service.ts:182-205`;路由 `routes/admin/worker-platform/workers.ts:146-158`,权限 `admin/operator`)。空 body 不默认关闭(400 `can_consign_required`)。
- **判定**:`canWorkerPublishConsignment = !whitelistEnabled || workerCanConsign`(`domain/consignment-whitelist.ts:7-12`),后端在提报入口强校验并 403 `consignment_permission_required`(`consignment-worker-service.ts:151-161`);前端同口径(`utils/consignment-whitelist.ts:5-12`)+ 引导卡片(`WorkerConsignmentPage.tsx:597-620`)。
- **平滑过渡**:082:16-24 把历史上出现在 `consignment_submissions` 的 `worker_id` 批量置 `can_consign=TRUE`。语义已从"永久白名单"演进为"总开关可整体放行"(9298b31b 新增),关闭总开关时保留标记以便再次开启。
## 九、API 端点清单
**打手端**(`routes/worker/consignment.ts`,前缀 `/api/v1/worker`)
| 方法/路径 | 门禁 | 服务 |
| --- | --- | --- |
| `POST /consignment/publish` (:19) | `requireActiveWorker` | `publishWorkerConsignment` |
| `GET /consignment/price-suggestion` (:33) | `requireActiveWorker` | 建议价 |
| `GET /consignment/my-items` (:51) | 仅会话 | `listWorkerConsignments` |
| `POST /consignment/items/:submissionId/confirm-friend` (:65) | `requireActiveWorker` | 复用客服加好友链路 |
| `POST /consignment/items/:submissionId/gift-evidence` (:87) | `requireActiveWorker` | 复用 `submitWorkerOrderAcceptance(asConsignmentSupplier)` |
**后台**(`routes/admin/worker-platform/consignment.ts`,前缀 `/api/v1/admin`)
| 方法与路径 | 权限 | 行号 |
| --- | --- | --- |
| `GET /consignment/inventories`、`/stats`、`/inventories/:id` | `consignment.view` | :37,:47,:57 |
| `POST /consignment/inventories`(已停用 409) | `consignment.manage` | :67 |
| `PUT /consignment/inventories/:id` | `consignment.manage` | :80 |
| `PATCH /consignment/inventories/:id/matching` | `consignment.manage` | :98 |
| `POST /consignment/inventories/:id/archive`、`/return` | `consignment.manage` | :112,:122 |
| `GET /consignment/submissions`、`/submissions/:id` | `consignment.view` | :142,:152 |
| `POST /consignment/submissions/:id/approve`、`/reject` | `consignment.review` | :162,:180 |
| `GET /consignment/matches/:workOrderId`、`/.../options` | `consignment.view` | :200,:211 |
| `GET /consignment/inventories/:id/match-orders` | `consignment.view` | :225 |
| `POST /consignment/fulfill` | `consignment.fulfill` | :242 |
| `POST /consignment/assign-worker` | `consignment.fulfill` | :263(前端已无调用方) |
| `POST /consignment/revoke` | `consignment.fulfill` | :285 |
| `POST /worker-platform/workers/:workerId/consignment-permission` | `admin/operator` | `workers.ts:146` |
## 十、风险与问题(按严重程度)
### 资金安全
1. **审核改派打手会把货款付给非发货人** — `consignment-repo.ts:1227-1230` 审核可 `worker_id = COALESCE($4, worker_id)` 改派;`consignment-supplier-repo.ts:67` 结算优先取 `inventory.worker_id`。发布押金已按台账归还**原发布人**(`consignment-deposit-repo.ts:186-241`),但 `cost_price_fen` 货款会打给被改派的打手。若改派是刻意玩法可接受,否则是资金错付。**待确认(需业务口径)**。
2. **`assignWorkerWithConsignment` 仍是可达接口但已无前端入口** — `routes/admin/worker-platform/consignment.ts:263-283` + `services/admin/consignment.ts:151` 仍在;直连可绕过 `ConsignmentManualMatchModal.tsx:178` 的"只走内部现货"收敛,回到追踪文档 §四描述的双重扣押金 + 打手被迫重填资料场景。建议下线或加开关。
3. **`mapConsignmentInventory` 全量下发 `credentialsJson`(游戏账号密码)** — `consignment-admin-service.ts:179-182`,仅需 `consignment.view`,列表接口即返回(优化方案 P2-3 仍成立)。
### 数据一致性 / 并发
4. **指派链路跨事务"部分成功"窗口仍在** — `claimConsignmentInventoryForWorker`(:663) 独立事务提交后,`assignAdminWorkOrderToWorker`(:680)、`updateWorkOrder`(:742)、`createWorkOrderEvent`(:748) 各自独立;失败靠 `compensateFailedConsignmentAssignment`(:802-849) 收敛,且三步补偿全是 `catch {}` 吞错(:821-848)。补偿自身失败时会出现「工单 in_progress + 押金已冻 + 现货已回池」→ 同一件货被第二张单占用。文档 `寄卖系统优化方案.md:159-165` 仍标注"指派事务化未做"。
5. **改派导致卖家端"我的寄卖"错位** — `consignment-worker-service.ts:352-368` 分别按 `submissions.worker_id` 与 `inventories.worker_id` 过滤(repo `:137-140`、`:902-905`)。审核改派后,原卖家有提报无库存 → 永远停在 `in_stock`「正在入库」(`consignment-item-stage.ts:158-161` 兜底);被改派者看到从未提报的货。
6. **提报列表 LEFT JOIN 存在行放大** — `consignment-repo.ts:937-943` 用 `rinv.id = cs.result_inventory_id OR rinv.source_submission_id = cs.id`,两个条件同时命中时同一提报单会出两行。
7. **我的寄卖窗口被硬截断** — `consignment-worker-service.ts:353-354` 固定 `pageSize: 200`,超过 200 条的历史寄卖静默不可见;`:362-367` 同一提报单只保留第一件库存。
8. **`internal_stock` 履约的押金释放依赖验收事务**,撤销/回库路径已覆盖,但"待加好友"无限期挂起时押金与货物同时冻结,无超时策略(文档 `寄卖库存优化现状与进度追踪.md:93,98` 仍标"待业务拍板")。
### 性能
9. **候选匹配全表入内存** — `consignment-repo.ts:854-887` `listActiveConsignmentInventoriesForMatching` 无分页、无 LIMIT,`checkWorkOrderInventoryMatches`/`listWorkOrderInventoryMatchOptions` 每次调用全量拉取后在 JS 内比对(`consignment-fulfillment-service.ts:190,318`)。
10. **统计接口每次 5 条全表聚合** — `consignment-repo.ts:218-303`(优化方案 P2-2 未处理)。
### 可维护性
11. **仓库关键字搜索仍不覆盖承诺字段** — `consignment-repo.ts:178-182` 未含 `buyer_name/shop_name/seller_phone/worker_name`,而前端占位符承诺(追踪文档 `:54` 仍标"待优化")。
12. **编辑无法解除打手关联** — `ConsignmentInventoryPanel.tsx:255` 传 `workerId || undefined`,后端按 `!== undefined` 判定(`consignment-admin-service.ts:381-386`),传空即"不修改"。
13. **`remark` 拼接式审计无长度上限**(`:980`、`:996`、release `consignment-repo.ts:582`)。
14. **`listWorkerConsignments` 用 `Promise.all` 两查询 + N 次工单批量查**,卖家量大时内存放大(非阻塞问题)。
## 十一、文档与代码一致性
| 文档承诺 | 实际 | 结论 |
| --- | --- | --- |
| 优化方案 P0-1/2/3/4/5 五项 | 全部落地(079 迁移、`assertConsignmentRevocableWorkOrderStatus:864`、绑定唯一索引、`release...BindingForWorkOrderWithClient:765`) | ✅ 一致 |
| P1-2「指派事务化」 | 仅做了补偿回滚,未合并事务(见风险 4) | ⚠️ 半做 |
| P1-8 前端权限门控 | 已落地(`consignment-permissions.test.ts` 存在) | ✅ |
| 批次 E 未做项:报表(节省赏金/出库单数/批次回款)、搜索字段扩展、打手端菜单按账号状态隐藏 | 仍全部未做 | ❌ 未做 |
| §六/§七 统计看板 | `getConsignmentInventoryStats` 只有数量/成本 + 4 个 GROUP BY | ❌ 未做 |
| 追踪文档 `:260` 写"`if (!worker.can_consign) throw`" | 实际是 `canWorkerPublishConsignment({whitelistEnabled, workerCanConsign})`(`consignment-worker-service.ts:151-155`) | ⚠️ 文档滞后于 9298b31b |
| 追踪文档 §四「手动匹配弹窗收敛为内部现货履约」 | 前端已收敛(`ConsignmentManualMatchModal.tsx:178`),但后端 `assign-worker` 接口未下线 | ⚠️ 半做 |
| 批次策略"回库保留运营匹配开关" | `releaseConsignmentInventoryClaim`(:563) 与 `returnConsignmentInventoryToStockWithClient`(:706) 均保留 `allow_matching` | ✅ |
| 押金策略(待审超期自动驳回、打手自助撤回) | 均未实现,`consignment_submissions.status` 无 `cancelled` | ❌ 未做 |
**历史演进(`git log` 摘要)**:`1aa6dba9` 初版(仓库+H5 收集+内部履约)→ `4a6215e8` 接入打手押金履约 → `fd1eb581` 冷静期 + 押金闭环 → `e828e677` 权限/竞态/撤销守卫 → `632dddc4` 下线 H5 + 状态机封闭 → `5d11bf20` 补偿 + 规格复检 → 批次 F–U(押金不退、作废口径、统一状态机、货款结算、手动匹配)→ **`a5727554`「彻底去除非平台内部现货履约」把匹配收敛为指派打手** → 追踪文档 §四发现该路线导致卖家被二次扣押金、被迫重填资料,随后提交把弹窗改回 `fulfillWorkOrderWithConsignment`;**`577e0eec` 反向清理**:从 `WorkOrdersPanel.tsx`/`WorkerOrderDetailDrawer.tsx` 移除已废弃的指派打手路径(a5727554 的方向被回退,指派线降级为遗留接口)→ `cb8b249e`/`9298b31b` 白名单与总开关。
## 十二、测试覆盖评估
| 测试文件 | 断言重点 |
| --- | --- |
| `domain/consignment-item-stage.test.ts`(10 例) | 各阶段推导、内部现货 vs 打手线的"确认加好友"按钮差异、冷却到点转待赠送、终态 |
| `domain/consignment-deposit-policy.test.ts`(6 例) | auto/显式覆盖的押金处置矩阵、非法值归一 |
| `domain/consignment-whitelist.test.ts`(2 例) | 总开关开/关 |
| `consignment-matching-service.test.ts`(24 例) | 规格解析、精确/高配充低配、通配互通、空规格不命中、品类隔离、建议价档位兜底 |
| `consignment-manual-match.test.ts`(7 例) | 冲突清单、宽松复检、必填原因、两链路留痕、读接口只读 |
| `consignment-worker-service.test.ts`(9 例) | 单号聚合、状态机接线、卖家归属、白名单 403、璀璨≤钻级 |
| `consignment-payout.test.ts`(10 例) | 单位(分)、双付防护、拼单钩子、079 去重迁移 |
| `consignment-fulfillment-price-trace.test.ts`(4 例) | 发单金额改写与撤销恢复原值 |
| `consignment-revoke-policy.test.ts`(3 例) | 撤销必结算打手押金、作废关匹配开关 |
| `consignment-evidence.test.ts`(8 例) | 凭证取图去重、手工入库停用、链接归一化 |
| `consignment-repo.test.ts`(3 例) | SUB 号搜索、join 列前缀、`allocation_status` 落在 080 枚举内 |
| `admin-worker-consignment-permission.test.ts`(3 例) | 权限回显、路由注册、空 body 不默认关闭 |
| 前端 8 个 consignment 测试 | 冷却倒计时、凭证、手动匹配挂载(断言走内部现货)、权限、规格选项、白名单、供货打手展示 |
**缺口**:(a) 无 `approveAdminConsignmentSubmission` 的端到端测试(改派+押金归属+货款归属这条链路无回归保护);(b) 无审核/驳回押金释放的金额断言;(c) 无 `revokeWorkOrderConsignmentFulfillment` 的并发/状态仲裁用例(`assertConsignmentRevocableWorkOrderStatus` 有纯函数测试但事务内 0 行 409 未覆盖);(d) 无 `releaseConsignmentInventoryBindingForWorkOrderWithClient` 的幂等/多工单绑定冲突用例;(e) 白名单仅有总开关与单人布尔两种纯函数用例,无"提报时总开关关闭但 `can_consign=false` 放行"的集成断言;(f) 无前端 `ConsignmentManualMatchModal` 对 `allowSpecMismatch` 必填原因的交互测试。
@@ -0,0 +1,272 @@
# 寄卖库存功能 · 现状分析、进度与证据追踪报告
> 更新时间:2026-09-21
> 项目基线:`040b3db8` (main 分支,工作区干净)
> 当前验证状态:后端 501 测试全绿 (498 pass / 0 fail / 3 skip);前端 54 测试全绿 (54 pass / 0 fail);两侧构建与类型检查通过。
---
## 一、 证据分类记录 (Evidence Classification)
### 1. 代码证据 (Code Implementation)
- **打手提报与建议价闭环**:
- `apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx`: 打手端唯一提报入口,支持系统(苹果/安卓)与区服(互通)规范输入;300ms防抖自动带出「接单模板」建议价;支持高档位溢出兜底与手改价保护;支持 URL/localStorage 记忆停留选项卡。
- `apps/backend/src/services/worker-platform/consignment-worker-service.ts`: `suggestConsignmentPriceFromRules` 严格对齐璀璨数(0/1/2/3璀璨)与钻石等级;发布接口强制 `requireActiveWorker`。
- **仓库与审核管理**:
- `apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx`: 寄售仓库管理,支持状态筛选、允许匹配开关、凭证链查看、工单双向匹配与退单回库。
- `apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx`: 寄卖提报审核,支持通过入库与驳回退还押金。
- **匹配与履约执行引擎**:
- `apps/backend/src/services/worker-platform/consignment-matching-service.ts`: `compareSpec` 三态判定(match / mismatch / unknown);支持区服「互通」对称通配;未知项降级为 partial 并列出缺失字段。
- `apps/backend/src/services/worker-platform/consignment-fulfillment-service.ts`: 内部现货履约(原子占库 + 工单绑定 + 停在待加好友)与指派打手押金履约;服务端强制规格复检与冲突留痕。
- **双向手动匹配与工单详情(最近落地)**:
- `apps/frontend/src/pages/admin/ConsignmentManualMatchModal.tsx`: 双向手动匹配弹窗,支持 WO 工单号与 SUB 提报单号模糊检索,支持规格冲突时勾选强制匹配并必填原因。
- `apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx`: 工单详情抽屉完整回显卖家资料(角色名、数字ID、区服、系统、联系方式、单号等)与凭据截图,支持一键聚合复制与单项复制。
### 2. 数据与迁移证据 (Database & Migrations)
- `077_consignment_inventory.sql`: 寄售仓库表 `consignment_inventories` 与提报表 `consignment_submissions` 基线,建立 RBAC 四大权限。
- `078_consignment_worker_deposit.sql`: 打手端押金履约字段与台账支持。
- `079_drop_consignment_collect_links.sql`: 完全移除历史遗留 H5 定向收集渠道(下线 `consignment_collect_links` 表与 `link_id` 列);拆分 `assigned_worker_id`(履约打手)与 `worker_id`(供货卖家);建立绑定工单部分唯一索引与冻结流水去重幂等索引。
- `080_consignment_allocation_status_and_copy.sql`: 约束 `allocation_status IN ('待安排', '已安排')`。
### 3. 资金与状态机闭环证据 (Financial & State Machine Audit)
- **押金流转**:
- 卖家发布提报:冻结发布押金(`deposit_freeze`)。
- 审核驳回:全额退还(`deposit_unfreeze`)。
- 确认加好友后违约/撤单:没收卖家押金(`deposit_deduction`),只扣冻结额,不退可用余额。
- 履约验收:释放卖家发布押金,同时自动补发「供货货款」(`settleConsignmentSupplierPayoutByWorkOrderWithClient`)。
- **撤销与解绑**:
- 撤销白名单严格限制在 `in_progress | pending_acceptance | problem`,验收后绝对禁止撤销;
- 解绑现货时自动识别是否已加好友(已加好友作废 `archived` 下架不回池,未加好友 `in_stock` 回池)。
---
## 二、 进度管理:已知、未知、目标与确信度登记
| 类型 | 事项描述 | 确信度 | 支撑证据 / 依据 | 状态 / 备注 |
| :--- | :--- | :--- | :--- | :--- |
| **已知** | 提报渠道单一化收敛完成 | 100% | 迁移 079 已删 H5 相关表与路由,手工入库已设 409 守卫,唯一入口为打手端提报 | ✅ 已闭环 |
| **已知** | 统一单号锚定与三态状态机 | 100% | 提报单号(SUB...)作为统一主键暴露给前端与客服,隐藏 INV 编号;`domain/consignment-item-stage.ts` 纯函数推导状态 | ✅ 已闭环 |
| **已知** | 资金流转(货款结算 + 押金没收/释放)闭环 | 100% | 批次 Q 补发货款给供货卖家,修 100 倍单位错;批次 F/G 加好友后违约没收押金不可逆 | ✅ 已闭环 |
| **已知** | 双向手动指定现货与强制匹配留痕 | 100% | commit `f07e4087` 支持在工单调起匹配现货、在仓库调起匹配工单,支持工单号与提报单号搜索,冲突强制匹配带原因留痕 | ✅ 已闭环 |
| **已知** | 资料展示与一键复制功能完成 | 100% | commit `f08a221e`, `2c120bd3` 工单详情抽屉完整展示卖家信息并提供聚合及单项复制按钮 | ✅ 已闭环 |
| **已知** | 建议价精准对齐璀璨数与溢出兜底 | 100% | 批次 X/Z:按系统+系列+钻级+璀璨数全匹配接单模板;超出档位按最高配报;手改不覆盖 | ✅ 已闭环 |
| **已知** | 卖家上架主页截图在工单详情抽屉与打手端正常展示 | 100% | 修复 `normalizeUploadedFiles` 字符串解析与 `effectiveBoosterMaterial` 截图兜底,504后端/54前端测试全绿,真实工单 #100140 成功解析出截图 | ✅ 已闭环 |
| **已知** | 仓库搜索框占位符承诺字段与后端实现存在偏差 | 100% | `ConsignmentInventoryPanel.tsx:789` 承诺按“收购人/店铺/卖家”,但 `consignment-repo.ts:178` 未覆盖 `buyer_name`, `shop_name`, `seller_phone` | ⚠️ 待优化(低成本) |
---
## 五、 专项优化:接单工单匹配后详情抽屉展示卖家上架主页截图(2026-09-21 已闭环)
### 1. 现象与线上真实证据
- **问题现象**:接单工单匹配寄售现货后,在管理后台工单详情抽屉的「寄售赠送进度」模块中,「卖家上架/主页截图」区域显示为“未提交”,无法查看卖家寄卖时上传的游戏主页与物资截图。
- **数据库真实记录查证**(工单 `WOc5502148fd73`,ID: `100140`):
- 关联寄售库存 ID: `23` (`INV26091917439822`);
- `material_json.consignment.screenshots` 中确切存有截图:`["/api/v1/files/object?key=consignment_screenshot%2F2026%2F09%2F19%2Fb8b974206a8a0cf6c89a6296.webp"]`(纯字符串 URL 数组);
- 但后端接口返回的 `gift.boosterMaterial.screenshots` 却为空数组 `[]`。
### 2. 代码级根因定位
1. **文件归一化函数 `normalizeUploadedFiles` 丢失字符串 URL 支持(核心根因)**:
- `apps/backend/src/services/worker-platform/worker-input-normalizers.ts:36-40`:
原逻辑仅接受 `{ objectKey, url }` 对象格式;当传入纯字符串 URL 数组(寄售提报老格式与历史库数据)时,`typeof item === 'object'` 判定为 false,提取出的 `objectKey` 和 `url` 均为空串,最终被 `filter(Boolean)` 全数过滤为 `[]`!
2. **工单展示映射器 `resolveWorkOrderEffectiveBoosterMaterial` 缺少截图 fallback**:
- `apps/backend/src/services/worker-platform/work-order-display-mappers.ts:104`:
当工单的 `booster_material_json` 中已存在 `fields`(如之前同步过卖家昵称/ID)但 `screenshots` 为空时,直接提前返回了空截图列表,没有回退到寄售数据的 `consignment.screenshots`。
3. **前端工单详情渲染缺乏兜底**:
- `apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx:2412` 与 `WorkerOrderDetailDrawer.tsx:244`:
原逻辑只读取 `detailOrder.gift.boosterMaterial?.screenshots`,未将 `consignmentInfo?.screenshots` 字符串数组作为第二层降级展示。
### 3. 解决对策与落地成果
1. **文件/URL 解析能力全方位增强**:
- `apps/backend/src/services/file-storage/file-storage-service.ts`:导出 `extractObjectKeyFromFileUrl`,支持从 `/api/v1/files/object?key=...` 中稳定提取 `objectKey`。
- `apps/backend/src/services/worker-platform/worker-input-normalizers.ts`:`normalizeUploadedFiles` 增加分支支持 `typeof item === 'string'`,自动提取 `objectKey` 并标准化为带有 `url`、`thumbnailUrl`、`mediumUrl` 的完整 `UploadedFilePayload`。
2. **展示层截图自动回退**:
- `apps/backend/src/services/worker-platform/work-order-display-mappers.ts`:在 `resolveWorkOrderEffectiveBoosterMaterial` 中,当打手/工单未传截图且关联寄售时,自动取寄售卖家的主页截图兜底填充,并保留 `isConsignmentSellerMaterial` 标识。
3. **前后端双保险与打手端对齐**:
- `apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx`:详情抽屉在 `detailOrder.gift.boosterMaterial.screenshots` 之后追加 `consignmentInfo.screenshots` 字符串列表降级渲染。
- `apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx`:打手端详情抽屉同步增加对寄售截图的降级渲染。
4. **自动化测试与实际验证**:
- 新增后端单元测试 `apps/backend/src/services/worker-platform/consignment-booster-material.test.ts`;
- 真实工单数据(`WOc5502148fd73`)映射执行验证,成功输出标准卖家主页截图对象;
- 后端 504 单测全绿、前端 54 单测全绿,构建与样式检查全部通过。
| **未知** | 待审核状态(pending_review)下打手是否需要自主撤回能力 | 80% | 业务目前仅提供客服审核通过/驳回,打手填错价格或想自用无法自主撤回,需业务确认 | ⏳ 待业务拍板 |
| **未知** | 待加好友停滞是否有超时策略 | 75% | 现货履约停在待加好友,若客服/卖家长时间不操作,工单永久挂起,是否需超时自动预警/工单回退 | ⏳ 待业务拍板 |
| **未知** | 批次 Q 之前已验收的历史寄卖单是否需要补发货款 | 70% | 历史单(如 #100171)在批次 Q 之前结算未获货款,需与运营对账确认是否需要补账脚本 | ⏳ 待对账确认 |
| **目标** | 完善寄卖仓库检索与展示体验 | 95% | 将后端搜索扩展至 `buyer_name`、`shop_name`、`seller_phone`、打手昵称等,兑现前端占位符承诺 | 🎯 近期可推进 |
| **目标** | 打手端支持“撤回提报”并即时解冻押金 | 90% | 在打手端“我的寄卖-待审核”卡片增加撤回功能,避免误提报占用押金和客服审核工时 | 🎯 近期可推进 |
| **目标** | 增加寄卖模块统计与节省赏金看板 | 80% | 补齐设计方案中的“累计节省赏金”、“出库单数”、“货款结算对账”统计图表 | 🎯 中期规划 |
| **目标** | 待加好友超时预警与催促机制 | 85% | 工单列表中对停留在待加好友超过 24/48 小时的寄售工单显示催促角标 | 🎯 近期可推进 |
---
## 三、 思考、灵感与演进分析 (Insights & Inspirations)
### 1. 思考:从“被动等待匹配”到“主动撮合建议”
- **现状**:目前系统主要依靠进单发布时自动检查现货候选,或者客服在管理后台手动调起匹配弹窗。
- **灵感**:当打手发布的高价值现货(例如安卓微信暗潮5钻)审核入库时,后台可自动检索当前排队池(抢单大厅、未分配工单)中是否有完全匹配的订单,并在审核通过成功提示或管理后台右上角通知栏弹窗提醒:“该现货已自动匹配待发工单 #WO26...,是否立即一键履约?”。这将将货找单的时延缩短至秒级。
### 2. 思考:打手端自闭环与客服减负
- **现状**:打手一旦提交寄卖,如果填错账号或写错价格,只能联系客服在后台驳回。
- **灵感**:在 `pending_review`(待审核)阶段,打手拥有随时撤回的权利(打手自主取消 -> 事务内释放押金 -> 状态转 `cancelled`)。这既能防止客服审核了无效的错单,也能大幅降低双方沟通成本。
### 3. 思考:待加好友阶段的履约节奏管控
- **现状**:为了防止错发和押金纠纷,内部现货履约被设计为停在“待加好友”,待加好友后才启动 72 小时赠送冷却。
- **灵感**:游戏内加好友存在单向通过的时滞。可以在工单面板上为“待加好友”超过 12 小时的工单打上“⚠️ 待加好友超时”标签,让客服能集中优先跟进买家/号主,保障履约时效。
---
## 四、 专项排查:打手 A 重复扣押金与二次填写资料的根因分析
### 1. 现象复现路径
- 打手 A 在接单平台提报寄卖一个物品,系统冻结发布押金(`deposit_freeze`,如 ¥20 或 ¥100);
- 后续外部进单匹配到此物品时,实际走入了【指派打手履约(`worker_deposit`)】而非【内部现货直接履约(`internal_stock`)】;
- 导致打手 A 账户被额外扣除一笔【接单履约押金】(双倍资金占用);
- 工单在打手端显示为“待提交资料”,打手 A 被迫重新手动上传游戏昵称、ID 及截图。
### 2. 代码级根因定位
1. **手动匹配弹窗硬编码为指派打手(关键漏项)**:
- `apps/frontend/src/components/admin/ConsignmentManualMatchModal.tsx:184`:
不论从寄售仓库还是工单操作列调起手动匹配弹窗,提交按钮直接硬编码调用了 `assignWorkOrderWithConsignmentWorker`(指派打手履约接口);
弹窗内**完全没有提供【内部现货直接履约】(`fulfillWorkOrderWithConsignment`)的选项**。
2. **发布拦截弹窗排版引导偏差**:
- `apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx:3434-3475`:
候选卡片中,【走接单平台指派打手(冻结押金交付)】被置于顶部且为高亮紫色主按钮(Primary),而真正推荐的【内部现货直接履约(免外派佣金)】为次要按钮(Default),客服极易默认点击主按钮。
3. **指派打手履约复用普通工单逻辑导致双冻结与资料丢失**:
- `consignment-fulfillment-service.ts:680` 调用了通用的 `assignAdminWorkOrderToWorker`;
- 通用指派将打手 A 视作外部抢单打手,再次执行 `freezeWorkerOrderDepositWithClient` 冻结履约押金;
- 通用指派将工单置为 `acceptance_mode = 'friend_gift', gift_phase = 'material_required'`,但未自动将现货中的 `seller_game_id`、`credentials_json`、`screenshots_json` 自动写入工单 `booster_material_json` 并推进阶段,导致打手端仍停留在“待提交资料”。
### 3. 解决对策与落地成果(2026-09-21 已闭环)
1. **手动匹配弹窗收敛为单一【内部现货直接履约】**:
- 在 `ConsignmentManualMatchModal.tsx` 中彻底废除 `assignWorkOrderWithConsignmentWorker`,切换为调用 `fulfillWorkOrderWithConsignment`;
- 彻底解除 `selected.workerId` 强依赖与无法指派错误提示,详情卡片调整为展示供货来源(“供货打手”或“内部现货”);
- 文案统一调整为“确认内部现货履约”,明确告知“免外派佣金、不冻结重复押金、自动跳过打手资料”。
2. **工单发布拦截弹窗候选操作收敛为【内部现货直接履约】**:
- 在 `WorkOrdersPanel.tsx` 中将卡片操作按钮替换为【内部现货直接履约】高亮主按钮(绿色),副标题标注“免外派佣金 · 不扣打手押金 · 进入待加好友”;
- 杜绝客服误点走旧指派流程。
3. **测试套件与类型风格全面达标**:
- `consignment-manual-match-mount.test.ts` 更新为断言手动匹配走内部现货直接履约;
- 前端 54 测试全绿、后端 501 测试全绿;两侧 `typecheck`、`format:check`、`build` 均 100% 通过。
---
## 六、 专项优化:接单工单表格「打手」列回显寄售供货打手与现货资料(2026-09-21 已闭环)
### 1. 现象与用户反馈
- **问题现象**:接单工单在走内部现货直接履约后,工单列表表格的【打手】列以及工单详情基本信息中的【打手】字段显示为冰冷的一横杠 `-`,客服/运营在列表视图下无法直观查看是谁供的货或对应哪个游戏角色。
### 2. 代码级根因定位
- `apps/frontend/src/pages/admin/panels/work-order-panel-utils.tsx:31`:
原 `renderWorkOrderWorkers(order)` 仅针对 `order.worker`(传统指派/抢单打手)与 `order.sharingWorkers`(拼单打手)进行了渲染,而内部现货履约单没有 `order.worker`,直接走到底部返回了 `'-'`。
### 3. 解决对策与落地成果
1. **提炼寄售供货展示纯函数**:
- 在 `apps/frontend/src/utils/consignment-display.ts` 中建立 `resolveConsignmentWorkerDisplay(order: WorkOrder)`;
- 自动解析 `material.consignment` 与 `gift.boosterMaterial.fields`:
- 当存在 `workerName` 时,主标题展示供货打手名称,打上 `<Tag color="cyan">供货打手</Tag>`,副标题自动拼接游戏角色昵称与游戏 ID;
- 当无 `workerName`(纯仓库现货)时,主标题展示卖家角色昵称/游戏 ID,打上 `<Tag color="cyan">内部现货</Tag>`。
2. **列表与详情无缝集成**:
- `renderWorkOrderWorkers` 统一接入 `resolveConsignmentWorkerDisplay`,使工单列表表格中的【打手】列和工单详情抽屉基本信息中的【打手】栏均能清晰、美观地回显供货人。
3. **自动化测试与样式达标**:
- 新增前端单元测试 `apps/frontend/tests/consignment-worker-display.test.ts`,验证普通工单、有供货打手寄售单、无打手纯现货单的解析分支;
- 前端 57 个测试全绿,`format:check`、`lint:check`、`typecheck` 全部通过。
---
## 七、 专项优化:打手端「我要寄卖」顶部臃肿卡片轻量化重构(2026-09-21 已闭环)
### 1. 现象与用户反馈
- **问题现象**:打手在手机端打开“我要寄卖”页面,顶部纵向堆叠了 4 个巨型卡片(`待审核`、`履约中`、`已完成`、`发布寄卖 苏尔南`),占用高达 400px+ 竖向空间,几乎占满整块手机屏幕。打手进页面完全看不到下方的发布表单,必须下滑整屏才能开始填表,操作链路极长且视觉严重失调。
### 2. 代码级根因定位
1. **通用样式在移动端粗暴单列堆叠**:
- `apps/frontend/src/pages/worker/WorkerConsignmentPage.tsx:510` 原先套用了通用 `.worker-orders-kpi-grid`;
- 其在 `@media (max-width: 640px)` 下被定义为 `grid-template-columns: 1fr`,4 个卡片被强制纵向拉成长条大块;
2. **信息噪音多、占位无意义**:
- 每个卡片塞入了诸如“客服审核中,通过后自动入库待匹配”、“待加好友 / 冷却 / 待赠送 / 待验收”、“含已完成、已驳回、已下架作废”等多余的长文本;
- 第 4 个卡片“发布寄卖 苏尔南”纯属凑数占位,点击只是切到下方的发布 Tab,自身毫无数据与功能意义。
### 3. 解决对策与落地成果
1. **彻底移除无意义的第 4 个卡片**:
- 移除“发布寄卖 苏尔南”假 KPI 卡片,将核心关注点精准收敛为【待审核】、【履约中】、【已完成】3 大关键数据。
2. **重构为专属轻量级横向数据栏**:
- 提取并新增 `.worker-consignment-kpi-grid` 与 `.worker-consignment-kpi-card`:
- 移动端采用 `repeat(3, 1fr)` 紧凑横向三等分网格,高度由原来的 320px+ 骤降至约 52px(**竖向空间释放超 80%**);
- 剔除无意义的长句副文案,采用居中微标签布局:上行小字展示状态(11px),下行加粗展示数值(16px);
- 待审核(紫/橙光)、履约中(科技蓝光)、已完成(清新绿光),选中态带优雅的主题高亮指示;
- 桌面端保留图文两列的优雅排布,兼顾大屏美观。
3. **首屏即见核心发布表单**:
- 优化后,打手打开手机首屏顶端即可在不滑动的情况下直接看到“履约押金提示”、“操作系统(苹果/安卓)”与“游戏区服”选择,提报效率大幅提升!
4. **自动化测试与全量构建**:
- 前端 59 个自动化测试全绿(**59 pass / 0 fail**);
- `format:check`、`lint:check`、`typecheck`、`build` 全部通过。
---
## 八、 专项优化:打手抢单大厅首页新增「联系客服」入口与通用客服弹窗组件(2026-09-21 已闭环)
### 1. 现状与用户诉求(User Feedback & Target)
- **用户反馈**:“首页 增加联系客服 现在联系客服藏的太深了”,并附带手机端首页截图,用红圈标注了移动端顶部状态栏(`余额 / 上限` 胶囊右侧、操作按钮区左侧的空白区域)。
- **目标**:
1. 在抢单大厅首页移动端顶部状态栏(红圈标注的核心首屏黄金位置)增加醒目高质感的【联系客服】胶囊按钮;
2. 在桌面端抢单大厅头部操作栏同步补齐【联系客服】入口,打通双端闭环;
3. 将原本深藏在个人中心底层的“联系客服/管理员”弹窗抽象为全站通用的 `WorkerContactModal` 组件,统一维护二维码、账号、余额等信息,提供一键复制与标准沟通提示。
### 2. 证据分类与代码级根因(Evidence & Root Cause)
- **已知信息 [确信度: 100%]**:
1. 官方公告栏循环播放:“☆接单请先个人中心联系客服☆方便客服处理”,证明新打手或接单打手频繁需要找客服沟通充值、提现或报备;
2. 原联系管理员弹窗代码内联在 `apps/frontend/src/pages/worker/WorkerProfileAccountModals.tsx:187-240`,打手必须切换至“个人中心”页面并向下滑动到底部才能找到入口,链路过长、深度太深;
3. `WorkerHallPage.tsx` 中的 `profileQuery.data?.data` 原生就包含了 `worker` 实体与 `financeConfig`(含管理员微信二维码配置 `adminContact.wechatQrCodeImage`),数据已就绪,仅缺前端呈现入口。
- **未知/风险预判 [确信度: 100%]**:
- 移动端 375px 或超窄屏(如 320px)下顶部栏元素横向空间是否会挤压拥挤;
- 应对对策:采用弹性缩放与媒体查询控制,超小屏下保持单行不折叠。
### 3. 解决对策与落地成果(Solution & Milestones)
1. **抽离通用联系客服弹窗组件**:
- 新建 `apps/frontend/src/components/worker/WorkerContactModal.tsx`,集中管理微信二维码大图、当前打手账号、昵称、手机号、账号状态、可用余额,以及一键复制和温馨沟通提示;
- 在 `WorkerProfileAccountModals.tsx` 与 `WorkerHallPage.tsx` 中全面复用该组件,消灭重复代码。
2. **移动端黄金位置新增【联系客服】胶囊按钮**:
- 在 `WorkerHallPage.tsx` 移动端顶部状态条(用户红圈位置)插入专属胶囊:
```html
<button class="worker-hall-mobile-contact-pill" onClick={() => setContactOpen(true)}>
<CustomerServiceOutlined />
<span>联系客服</span>
</button>
```
- 采用浅暖橙背景(`#fff7ed`)+ 优雅橙边框(`#fed7aa`)+ 微动效,与打手端整体暖橙调性浑然一体,点击即弹出客服微信与账号卡片。
3. **桌面端同步补齐入口**:
- 在桌面端头部操作按钮栏(刷新与排行榜旁)新增 `<Button icon={<CustomerServiceOutlined />} onClick={() => setContactOpen(true)}>联系客服</Button>`。
4. **验证门禁全绿通过 [确信度: 100%]**:
- 前端 59 个自动化测试全绿(**59 pass / 0 fail**);
- 前端 `format:check`、`lint:check`、`typecheck`、`build` 均零错误零警告通过;
- 后端 504 个自动化测试全绿(**501 pass / 3 skipped / 0 fail**)。
---
## 九、 专项优化:打手寄卖白名单开关控制与存量平滑过渡(2026-09-21 已闭环)
### 1. 现状与用户诉求(User Feedback & Target)
- **用户反馈**:“寄卖系统 我想增加一个类似白名单 激活模式, 如果有新的注册的人员, 默认无法开启寄卖; 必须联系客服... 方案 A 就够了, 能实现控制 一个开关也行, 然后 存量老打手平滑过渡 发布过的默认开启”。
- **目标**:
1. 实行寄卖白名单准入机制:新注册打手默认不开启寄卖,防止虚假提报与黑产刷单;
2. 存量老打手平滑过渡:历史上在 `consignment_submissions` 中发布过寄卖的打手,在迁移中自动批量赋权开启;
3. 后台提供开关:客服/运营在打手管理表格与详情抽屉中可一键开通/关闭打手寄卖权限;
4. 打手端友好提示与拦截:未开通寄卖权限的打手在「我要寄卖」页面展示专属引导卡片,一键唤起客服弹窗;后端接口进行服务端强拦截(403 `consignment_permission_required`)。
### 2. 证据分类与实现细节(Evidence & Implementation)
- **已知信息 [确信度: 100%]**:
1. `worker_users` 表增加了 `can_consign`(布尔值,默认 `FALSE`)与 `consignment_activated_at`(开通时间戳);
2. 迁移文件 `082_worker_consignment_whitelist.sql` 通过 `IN (SELECT DISTINCT worker_id FROM consignment_submissions WHERE worker_id IS NOT NULL)` 实现存量打手秒级平滑过渡;
3. 打手端提报接口 `publishWorkerConsignment` 增加了 `if (!worker.can_consign) throw ...` 强校验;
4. 前端管理后台 `WorkersPanel.tsx` 增加了「寄卖权限」列,直接嵌入 Switch 开关实时切换;打手详情抽屉 `WorkerDetailDrawer.tsx` 补充了权限和开通时间回显。
### 3. 验证与门禁报告(Quality Gate Status)
- **后端单测与类型检查 [确信度: 100%]**:
- 新增 `admin-worker-consignment-permission.test.ts` 及 `consignment-worker-service.test.ts` 权限拦截测试;
- 后端 507 项单测全绿(**504 pass / 3 skipped / 0 fail**);
- 后端 `typecheck`、`check:sql` 全部通过;
- **前端门禁与生产构建 [确信度: 100%]**:
- 前端 59 项单元测试全绿(**59 pass / 0 fail**);
- 前端 `format:check`、`lint:check`、`typecheck`、`build` 零错误全绿通过。
@@ -0,0 +1,847 @@
# 寄卖(寄存)系统 · 现状分析与下一步优化方案
> 基线:`e828e677`(main,工作区干净)
> 基线校验:后端 397 测试 / 394 通过 / 0 失败 / 3 skip;`typecheck`、`lint:check` 通过
> 分析对象:`1aa6dba9`、`4a6215e8`、`fd1eb581`、`6b940796`、`e828e677` 及当前代码
> 取证方式:逐行读代码(含 SQL 条件、前端调用点)+ 三路并行专项审计(状态机并发 / 前后端+RBAC / 资金闭环)
> 所有关键结论均已复核到 `文件:行`
> **本轮落地(2027-02-21,未提交)后校验**:后端 401 测试 / 398 通过 / 0 失败 / 3 skip;前端 16 通过;两侧 `typecheck`、`lint:check`、`format:check`、`check:sql`、`build` 全绿
---
## 一、最近提交在做什么
| 提交 | 内容 | 落点 |
| :--- | :--- | :--- |
| `1aa6dba9` | 初版:寄售仓库 / 定向收集链接 / 玩家提报审核 / 工单智能匹配与内部履约 | 迁移 `077`、`consignment-*` 全套、RBAC 四权限 |
| `4a6215e8` | 打手端押金履约(打手发布 → 冻结押金 → 指派打手交货) | 迁移 `078`、`consignment-worker-service`、`consignment-deposit-repo` |
| `fd1eb581` | 内部现货占库进入赠送冷静期;发布押金冻结/驳回退还/验收释放闭环 | `consignment-fulfillment-service`、`work-order-gift-repo`、结算联动 |
| `6b940796` | 样式修复(cooldownMinutes prefer-const) | — |
| `e828e677` | 权限打通(19 接口改 `requireAdminPermission`)、指派占库改条件更新、撤销守卫 | 新增 4 个回归测试 |
| `632dddc4` | 上一轮:H5 渠道整体下线 + 凭据字段修复 + 状态机封闭(撤销守卫 / 库存绑定唯一性 / 生命周期解绑)+ 押金归属取台账 | 迁移 `079`、`consignment-*` 五个文件、`work-order-*` 三个仓库、两个前端面板 |
| `5d11bf20` | 批次 C+D:指派部分成功补偿、凭证核销如实反馈、空规格不放行 + 履约/指派服务端规格复检 | `consignment-fulfillment-service.ts`、`consignment-matching-service.ts`、`consignment-repo.ts`、履约路由、`WorkOrdersPanel.tsx` |
| `d6f6219f` | 批次 F1:内部现货履约停在「待加好友」,客服确认后才进赠送冷却 + 备注快捷项 | `work-order-gift-repo.ts`、履约服务、`WorkOrdersPanel.tsx`、`utils/gift-phase.ts` |
| 本批提交(批次 Z,同一提交内含本文档,故不写自身 hash) | 批次 Z:建议价精准对齐璀璨数(0/1/2/3璀独立匹配避免全中取最高)+ 限制璀璨不超过钻石等级(前端选项禁用与联动收敛 + 后端硬拦截) | `consignment-matching-service.ts`、`consignment-worker-service.ts`、`routes/worker/consignment.ts`、`WorkerConsignmentPage.tsx`、`services/worker.ts` |
| `617794f2` | 批次 Y:修生产 079 迁移 23505——建冻结幂等索引前先去重历史重复冻结并退还多冻押金(服务器未应用过 079,经确认直接改原迁移) | `079_drop_consignment_collect_links.sql`、`consignment-payout.test.ts` |
| 本批提交(批次 X,同一提交内含本文档,故不写自身 hash) | 批次 X:发布寄卖自动带建议价——规格选齐后按 系统+系列+钻级 从「接单模板」(work_product_rules)匹配价格自动填入(安卓暗潮1钻 → ¥120),多条命中取最高,手改过不覆盖 | `consignment-matching-service.ts`、`product-match-repo.ts`、`consignment-worker-service.ts`、`routes/worker/consignment.ts`、`WorkerConsignmentPage.tsx` |
| 本批提交(批次 W,同一提交内含本文档,故不写自身 hash) | 批次 W:规格口径定案——后台全量(系统 苹果/安卓/通用,区服 微信/QQ/互通),打手端按物品展示(苏尔南:系统二选一、区服唯一「互通」默认选中);「互通」为区服通配值,匹配引擎通配符对称化 | `utils/consignment-spec.ts`、`WorkerConsignmentPage.tsx`、两个面板、`consignment-matching-service.ts`、`consignment-worker-service.ts` |
| 本批提交(批次 V,同一提交内含本文档,故不写自身 hash) | 批次 V:复核遗留——拼单份额验收收官补寄卖钩子(押金释放+供货货款)、手动匹配弹窗 409 刷新/关键字透传/无打手禁指派、核销反馈抽共享出口 | `work-order-settlement-repo.ts`、`ConsignmentManualMatchModal.tsx`、`utils/voucher-consume.ts` |
| 本批提交(批次 U,同一提交内含本文档,故不写自身 hash) | 批次 U:复核修复——占库 allocation_status 撞 080 CHECK(真实库必 500)、指派非供货卖家时货款双付、手动匹配入口按 fulfill 权限门控 | `consignment-repo.ts`、`consignment-fulfillment-service.ts`、两个面板、三个测试文件 |
| 本批提交(批次 S,同一提交内含本文档,故不写自身 hash) | 批次 S:手动指定现货匹配(判定/差异/强制留痕 + 双向入口 + 共享弹窗) | `consignment-matching-service.ts`、`consignment-fulfillment-service.ts`、`ConsignmentManualMatchModal.tsx` |
| 本批提交(批次 R,同一提交内含本文档,故不写自身 hash) | 批次 R:售后立案回落到供货卖家;抽共享"从工单找供货卖家"查询 | `consignment-supplier-repo.ts`、`after-sales-repo.ts`、`consignment-deposit-repo.ts` |
| 本批提交(批次 Q,同一提交内含本文档,故不写自身 hash) | 批次 Q:验收结算补寄卖货款给供货卖家;修指派打手线报酬 100 倍单位错 | `consignment-deposit-repo.ts`、`work-order-settlement-repo.ts`、`consignment-fulfillment-service.ts` |
| 本批提交(批次 P,同一提交内含本文档,故不写自身 hash) | 批次 P:确认加好友/上传赠送截图恢复为卖家操作,客服兜底 | `consignment-item-stage.ts`、`consignment-worker-service.ts`、`worker-acceptance-service.ts` |
| 本批提交(批次 O,同一提交内含本文档,故不写自身 hash) | 批次 O:文件链接去掉签名与有效期(修客服端凭证裂图),寄卖出参统一归一化 | `file-storage-service.ts`、`routes/files.ts`、`consignment-admin-service.ts` |
| 本批提交(批次 N,同一提交内含本文档,故不写自身 hash) | 批次 N:删掉按库存号的卖家入口,卖家动作只按寄卖单号定位 | `consignment-worker-service.ts`、`routes/worker/consignment.ts`、`WorkerConsignmentPage.tsx` |
| 本批提交(批次 M,同一提交内含本文档,故不写自身 hash) | 批次 M:停用客服手动入库(前端入口删除 + 后端 409 守卫),寄卖货源只走平台提报 | `consignment-admin-service.ts`、`consignment-repo.ts`、`ConsignmentInventoryPanel.tsx` |
| 本批提交(批次 L,同一提交内含本文档,故不写自身 hash) | 批次 L:匹配弹窗两个动作一律二次确认 + 按钮分区/图标/后果说明,防点错 | `WorkOrdersPanel.tsx` |
| 本批提交(批次 K,同一提交内含本文档,故不写自身 hash) | 批次 K:匹配弹窗改用寄卖单号(候选查询补 join),客服界面不再展示内部仓库号 | `consignment-repo.ts`、`WorkOrdersPanel.tsx`、`ConsignmentInventoryPanel.tsx` |
| 本批提交(批次 J,同一提交内含本文档,故不写自身 hash) | 批次 J:凭证链可视化(提报凭证 + 赠送凭证),客服审核/仓库面板统一展示 | `consignment-admin-service.ts`、`consignment-repo.ts`、`ConsignmentSubmissionsPanel.tsx`、`ConsignmentInventoryPanel.tsx` |
| 本批提交(批次 I,同一提交内含本文档,故不写自身 hash) | 批次 I:提报单号为唯一寄卖单号(一件货一行)+ 统一状态机 + 卖家确认加好友/提交赠送截图 | `consignment-item-stage.ts`、`consignment-worker-service.ts`、`routes/worker/consignment.ts`、`ConsignmentInventoryPanel.tsx`、`WorkerConsignmentPage.tsx` |
| 本批提交(批次 H,同一提交内含本文档,故不写自身 hash) | 批次 H:修掉发布时乱弹内部库存提醒(部分匹配兜底过宽)+ 弹窗赏金分/元换算 | `consignment-matching-service.ts`、履约复检、`WorkOrdersPanel.tsx` |
| 本批提交(批次 G,同一提交内含本文档,故不写自身 hash) | 批次 G:确认加好友后现货作废不回池 + 撤销寄售履约押金可选(含修打手押金永久冻结、真实库 42804 500) | `consignment-repo.ts`、`consignment-deposit-policy.ts`、`work-order-deposit-repo.ts`、撤销履约服务与路由、`WorkOrdersPanel.tsx` |
| 本批提交(批次 F2,同一提交内含本文档,故不写自身 hash) | 批次 F2:确认加好友后卖家发布押金不退(`deposit_deduction` 落地)+ 二次冻结护栏 | `consignment-deposit-repo.ts`、取消/退单仓库、撤销履约服务 |
| 本批提交(批次 E,同一提交内含本文档,故不写自身 hash) | 前端四权限门控收口、`allocation_status` 约束与权限文案修正、面板错误态/防双提交/真实待审计数 | `admin-auth.ts`、两个寄卖面板、`AdminWorkerPlatformPage.tsx`、迁移 `080` |
**结论**:功能主干 3 天连成闭环(库存 → 提报审核 → 匹配 → 内部现货 / 打手押金两条履约 → 赠送冷静期 → 验收结算 → 撤销回库),但最近三次提交全是**上线后回填正确性**(权限被静默清除、并发占库、撤销资金挂账)。
当前主要矛盾不是"缺功能",而是:**状态机不封闭 + 库存绑定无唯一性约束 + 工单终态对寄卖零感知**(本轮已处理);入口侧"H5 不可提交 / 手工录入丢凭据"两条断链已通过"下线 H5 + 修字段名"解决。
---
## 二、现状链路
```
[打手接单平台提报] ── 唯一入口(H5 定向收集链接已整体下线)
│
▼
提报审核 → consignment_inventories(in_stock, allow_matching)
│
工单发布前拦截 ── 命中候选(exact 100 / partial 80,仅前端展示)
│
├─► 内部现货履约 (internal_stock):同一事务原子占库(含工单绑定)→ 工单 in_progress + 赠送冷静期 → 验收释放押金
└─► 指派打手押金履约 (worker_deposit):先占库(独立事务,写 assigned_worker_id + 工单绑定)→ 再指派(另一事务冻结押金/核销凭证)→ 再改 material/reward/写事件
│
└─ 撤销寄售履约(仅内部现货,仅 in_progress/pending_acceptance/problem)→ 回库 / 作废 / 取消工单
│
└─ 工单终止(打手撤单 / 取消指派 / 退回大厅 / 撤单 / 打手退单申请 / 超时)→ 同事务解绑现货回库(本轮新增)
```
---
## 三、已验证问题清单
> **状态说明(2027-02-21 更新)**:下列清单为第一轮审计结论。本轮已落地批次 A/B 的核心内容,
> 逐条状态见每项标题后的标记;未完成项保留原文,作为下一轮输入。
> 已交付的改动见 `consignment-repo.ts` / `consignment-fulfillment-service.ts` / `consignment-deposit-repo.ts` 与迁移 `079_drop_consignment_collect_links.sql`。
> (原设计稿《寄卖库存功能设计与实施方案》已随代码淘汰删除,历史全文见 git 记录。)
### ✅ 本轮已落地(2027-02-21)
| 项 | 内容 | 落点 |
| :--- | :--- | :--- |
| H5 收集链接整条下线 | 删公开路由/服务/页面/服务端封装 + 迁移 `079` 删表 `consignment_collect_links`、删列 `link_id`,`source_type` 归并为 `worker_portal` | 前后端各 4 处删除 + `079` |
| 凭据/截图静默丢弃(P0-2) | 后端改读 `credentialsJson ?? credentials`、`screenshotsJson ?? screenshots` | `consignment-admin-service.ts` 三处入口 |
| 撤销状态机(P0-3 / P1-3 / P1-4) | 状态白名单(`in_progress`/`pending_acceptance`/`problem`)+ 事务内 `FOR UPDATE` + `UPDATE ... AND status=$n` + 原因必填 + 清空指派/时限/验收/问题痕迹 | `consignment-fulfillment-service.ts` |
| 库存绑定唯一性(P0-4) | 撤销/退回复库/作废统一 `AND status='consumed' AND bound_work_order_id=$工单`(作废与回库额外要求"未被进行中工单占用"),命中 0 行 409 且提示具体工单;部分唯一索引兜底 | `consignment-repo.ts`、`consignment-admin-service.ts`、`079` |
| 生命周期收口(P0-5) | 新增 `releaseConsignmentInventoryBindingForWorkOrderWithClient`,接入撤单仓库 5 条链路 + 打手退单申请 + 超时处置 | `work-order-cancel-repo.ts`、`work-order-issue-repo.ts`、`worker-cancel-request-repo.ts` |
| 占库写入工单绑定(P2-19) | 指派占库时写 `bound_order_id/bound_work_order_id/bound_platform_order_id/shop_name` | `consignment-fulfillment-service.ts`、`consignment-repo.ts` |
| 卖家/履约人拆列(P1-1) | 新增 `assigned_worker_id`,占库只写指派列,回滚不再把卖家置 NULL | `079` + `consignment-repo.ts` |
| 押金归属/金额取台账(P1-2、P2-1) | 冻结/释放的归属与金额改为读取不可变的冻结核销行,审核改派不再导致"钱退给错人/向错人扣款" | `consignment-deposit-repo.ts` |
| 编辑接口不再能改状态(P1-7) | `PUT /inventories/:id` 显式拒绝 `status`,仓库层移除 `status` 入参 | `consignment-admin-service.ts`、`consignment-repo.ts` |
| 打手端补审核门禁(P1-9) | `POST /consignment/publish` 加 `requireActiveWorker` | `routes/worker/consignment.ts` |
| 押金变动推送钱包(P2-4) | 发布冻结、驳回释放、作废归档、退回复库成功后推送钱包实时事件 | 三个 consignment service |
| 台账幂等键(P2-3) | 同一提报单的发布冻结加部分唯一索引 | `079` |
| 回归测试 | 新增 5 例:占库绑定断言、撤销状态白名单、解绑范围、作废/回库守卫、终态链路接线计数 | `worker-platform-service.test.ts` |
### ✅ 本轮已落地(批次 C+D,接续上表)
| 项 | 内容 | 落点 |
| :--- | :--- | :--- |
| 指派部分成功补偿(P1-2) | `compensateFailedConsignmentAssignment`:取消指派(解冻押金 + 工单回大厅 + 解绑现货)→ 兜底释放占用 → 摘除工单寄售标记 | `consignment-fulfillment-service.ts` |
| 凭证核销如实反馈(P1-5) | 失败写 `consignment_fulfill_voucher_consume_failed` 事件;前端按 `voucherConsume.ok` 弹警告并提示人工核销 | `consignment-fulfillment-service.ts`、`WorkOrdersPanel.tsx`、`services/admin/consignment.ts` |
| 空规格不放行(P1-6) | `compareSpec` 三态判定 + 钻级不再兜底 1;未知项一律不进 exact,`reason` 列出缺失字段 | `consignment-matching-service.ts` |
| 履约/指派服务端复检 | `assertConsignmentSpecMatchedOrder`:冲突 409,非 exact 需显式 `allowPartial`;前端危险确认后放行 | `consignment-fulfillment-service.ts`、履约/指派路由、`WorkOrdersPanel.tsx` |
| 人工解绑守卫收窄 | `NO_LIVE_BOUND_WORK_ORDER_SQL` 由"状态黑名单"改为只拦交付中三态,与撤销白名单对齐 | `consignment-repo.ts` |
| 陈旧寄售标记清理 | 新增 `detachConsignmentMaterialFromWorkOrderWithClient`,接入解绑回库、人工退回复库、作废三处 | `consignment-repo.ts` |
| 迁移 079 顺序修复 | 先删旧 `source_type` CHECK 再回填,全新空库 `001→079` 已验证跑通 | `079_drop_consignment_collect_links.sql` |
| 回归测试 | 新增 8 例:匹配三态/空规格/库存侧缺字段/通用件冲突 + 补偿接线 + 复检接线 + 凭证事件 + 摘标记 SQL(含旧绑定先锁行) | 两个测试文件 |
**一处刻意偏离原始批次 B 表述**:原始方案写"所有回库路径恢复 `allow_matching`",实际实现为**保留运营的开关设置**(`e828e677` 已按此方向改过撤销路径)。理由:该开关是运营"暂停匹配"的闸门,自动回库替运营打开会静默解除风控;两条路径(撤销回库、退回复库)现在语义一致,UI 文案也已同步更正(不再宣称"重新进入待匹配池")。若业务坚持"回库即上架",改一处常量即可。
### P0 · 直接导致功能不可用或重复出货
**P0-1 H5 定向收集页 100% 无法提交(截图 URL 取错层级)—— ✅ 已随渠道下线废弃**
- `services/consignment-public.ts:14-33`:`uploadConsignmentImage` 声明返回 `{url, assetId}`,实际 `return result?.data || result`。
- 后端上传响应信封是 `{code,msg,time,data:{file:{url,...}}}`(`utils/http.ts:21-28`,`services/file-storage/file-storage-service.ts:100-135` 返回 `{ file: … }`),正确写法见 `services/files.ts:19`(`response.data.file`)。
- 页面 `ConsignmentCollectPage.tsx:120-122` 取 `f.response?.url || f.url`;而 antd Upload 的 `customRequest` 只把结果塞进 `f.response`(`ConsignmentCollectPage.tsx:528-534` 传的是 `{file:{...}}`),且从不设置 `f.url`。
⇒ 上传成功、缩略图能看,但 `screenshots` 恒为空数组,点提交永远弹「请上传游戏主页截图以供核验!」,且文件已落 OSS 变成孤儿资源。
- **结论**:H5 渠道已整条移除,此缺陷连同页面、服务、公开路由、`consignment_collect_links` 表与 `link_id` 列一并作废。
**P0-2 手工入库 / 编辑的账号密码与截图被静默丢弃(前后端字段名不一致)—— ✅ 已修复(本轮)**
- 前端发 `credentialsJson` / `screenshotsJson`:`ConsignmentInventoryPanel.tsx:211-217`(新增)、`:264-269`(编辑)。
- 后端读 `payload.credentials` / `payload.screenshots`:`consignment-admin-service.ts:255-259`、`:308-312`;全仓无 camelCase→snake 映射中间件。
⇒ 客服录入的登录方式/账号/密码/安全码**永不落库**,详情弹窗(`ConsignmentInventoryPanel.tsx:1206-1215`)永远是 `-`;打手履约写入 `material.consignment.credentials` 只能是 `{}`(`consignment-fulfillment-service.ts:214`),**无法上号交付**。
- **修复**:新增 `pickCredentialsJson` / `pickScreenshotsJson`(`payload.credentialsJson ?? payload.credentials`、`screenshotsJson ?? screenshots`),统一用于手工入库、编辑、审核入库三个入口。
**P0-3 撤销寄售履约没有状态守卫,已验收(钱已结算、货已交付)的工单仍可被撤销 → 同一件现货二次出货 —— ✅ 已修复(本轮)**
- 撤销入口只校验 material 里有没有 `inventoryId`、是不是押金模式:`consignment-fulfillment-service.ts:419-442`。
- 事务内**不重新加锁、不校验工单状态**:`:456-463` → `:539-570`(`UPDATE work_orders ... WHERE id = $4`,无 status 条件)。
- 库存写回 `in_stock` 的 UPDATE 同样无 status 条件:`:497-518`。
- 验收结算只释放押金,**不清除 `material.consignment`**:`work-order-settlement-repo.ts:33-78`、`:92-103`。
- 前端按钮无状态白名单:`WorkOrdersPanel.tsx:1457-1465`;无二次确认;撤单原因标 `required` 但没挂 `rules`(`:3042-3050`),空原因也能提交,后端也不校验。
- **修复**:`assertConsignmentRevocableWorkOrderStatus`(白名单 `in_progress|pending_acceptance|problem`,事务内外各校验一次)+ 事务内 `SELECT ... FOR UPDATE` 锁单 + `UPDATE ... WHERE id=$4 AND status=$5` + 原因必填(前端同步校验)+ 清空指派/时限/验收草稿/问题单痕迹;前端 `isConsignmentRevocableOrder` + `consignment.fulfill` 权限门控。
**P0-4 库存被按 id 无条件改写 → 抢占其它工单的绑定、复活已作废件(一件现货被三张单履约)—— ✅ 已修复(本轮)**
- 撤销分支 `WHERE id = $3` 且无条件清空 `bound_order_id / bound_work_order_id / bound_platform_order_id`:`consignment-fulfillment-service.ts:475-494`、`:497-518`。
- 仓库侧【退回复库】只校验 `status='consumed'`:`consignment-repo.ts:640-669`;【作废】连状态都不校验:`:620-638` + `consignment-admin-service.ts:349-374`。
- **修复**:撤销分支加 `AND status='consumed' AND bound_work_order_id = $工单`(0 行 → 409 `consignment_inventory_binding_changed`);【作废】/【退回复库】加 `NO_LIVE_BOUND_WORK_ORDER_SQL`(绑定工单状态不在 `accepted`/`cancelled` 即拒绝,提示具体工单号);迁移 `079` 加部分唯一索引 `uniq_consignment_inventories_bound_work_order` 兜底,并清理历史重复绑定。
**P0-5 工单生命周期对寄卖零感知:撤单 / 超时 / 售后 / 重开都不解绑 —— ✅ 已修复(本轮)**
- 本轮新增 `releaseConsignmentInventoryBindingForWorkOrderWithClient(client, workOrderId, {now, reason})` 作为唯一出口,并接入:`work-order-cancel-repo.ts`(打手撤单 / 取消指派 / 退回大厅 / 撤单取消订单 / 已取消工单重新启用 共 5 处)、`worker-cancel-request-repo.ts`(打手退单申请审核通过)、`work-order-issue-repo.ts`(超时处置 `reopen`/`cancel_release`/`cancel_deduct`)。
- 押金口径:现货回库等同于"重新挂卖",发布押金**保持冻结**(不释放也不重复冻结),与「发布冻结、驳回/验收释放」一致;卖家彻底取回物品走运营【下架作废】→ 释放押金。
- 售后不需要接入:`resolveAdminAfterSalesCase` 只对已验收工单做资金追缴/退赔,不改工单状态(`fromStatus/toStatus` 均为 `accepted`),货物已交付、无待解绑库存;买家退回实物时由运营在仓库执行【退回复库】(已验收工单不再是"进行中",守卫放行)。
- 效果:撤单/超时后现货立刻回到在库池(`in_stock`,保留运营的匹配开关设置),重开工单不会再重复消耗第二件库存(绑定在撤单事务内已清)。
### P1 · 数据正确性 / 权限 / 资金安全
**P1-1 `consignment_inventories.worker_id` 一列两义:既是"卖家"又是"被指派的打手" —— ✅ 已修复(本轮)**
- 审核入库写卖家:`consignment-repo.ts:1095,1129`;指派占库**覆盖**为受指派打手:`:557-570`;回滚直接置 `NULL`:`:588-609`。
- 打手"我的寄卖"(`consignment-worker-service.ts:185`)与后台筛选(`consignment-repo.ts:146`)都按这一列。
⇒ 客服指派**另一位**打手时卖家丢件、被指派者凭空多件;指派失败回滚(置 NULL)同样让卖家永久丢件。
- **修复**:迁移 `079` 新增 `assigned_worker_id`(含注释与索引);占库只写 `assigned_worker_id`,回滚只清该列,`worker_id` 语义固定为卖家且入库后不再变更。
**P1-2 指派打手存在"部分成功"窗口:库存放回了,工单仍挂在该打手身上 —— ⏳ 未处理(下一轮批次 C)**
- `consignment-fulfillment-service.ts:318-327` 先占库(**独立事务已提交**)→ `:331-335` 指派(另一独立事务:冻结打手押金 `work-order-grab-repo.ts:180-226`、核销凭证 `admin-work-order-assignment-service.ts:67`)→ `:368 updateWorkOrder`、`:374 createWorkOrderEvent`、`:390` 每一步都可能抛错。
- `:397-409` 的 catch **只放回库存**,不撤销指派/押金/凭证/资料/金额。
⇒ 事件写入失败、死锁、超时或两次提交之间重启:工单 `in_progress` + 打手押金已冻结 + 凭证已核销,`material.consignment` 从未写入,现货却回到可匹配池。
- **本轮已缓解**:占库现在会写入 `bound_work_order_id`,补偿回滚的放回 UPDATE 带 `WHERE status='consumed'`;但"指派事务化"本身未做。
- **修复(待做)**:claim + assign + material/reward + event 合并同一事务(共用 `PoolClient`);指派后失败改补偿式撤销(解绑 + 解冻)。
**P1-2b 押金归属取自可变的 `consignment_submissions.worker_id` —— ✅ 已修复(本轮)**
- 归属原本按提报单可变的 `worker_id` 反查钱包(`consignment-deposit-repo.ts:173-202`),而审核可覆盖它(`consignment-repo.ts:985`,前端有打手下拉)。
⇒ 审核改派后:释放按新 worker 算得 outstanding=0 → **静默返回 0**,发布人那笔押金永久冻结且无补救入口;反向地,回库重冻会向从未发布过的打手扣款。
- **修复**:新增 `getConsignmentDepositLedgerSnapshot`,押金归属与金额以**不可变的冻结核销行**(`payload_json->>'consignmentSubmissionId'` + `ledger_type='deposit_freeze'`)为准;`ensure...` 重冻金额也改用台账历史金额,避免审核改额后按新金额扣款;提报驳回释放改走 `releaseConsignmentPublishDepositBySubmissionWithClient`。迁移 `079` 补部分唯一索引,防重复冻结。
**P1-3 撤销的工单 UPDATE 无锁无条件,用陈旧快照写回 → 覆盖并发验收(丢失更新)+ 双击双提交 —— ✅ 已修复(本轮)**
- 事务外读工单与 material(`:456-463`),事务内整体覆写 `status` + 整个 `material_json` + `RESET_GIFT_FLOW_SQL`(`:555-570`)。
⇒ 与"验收"并发时把 `accepted`、`acceptance_json`、`submitted_at`、押金释放全部抹掉并回退为 `unassigned`。
- **修复**:事务内 `SELECT ... FOR UPDATE` 重新读单并以锁行为准(material 也取锁行),状态白名单校验两次,`UPDATE ... WHERE id=$4 AND status=$5`(0 行 → 409 `work_order_status_changed`),库存 UPDATE 作为最终仲裁(0 行 → 409)。
**P1-4 撤销只重置冷静期字段,验收/时限/奖金残留 —— ✅ 已修复(本轮)**
- 原 `:560` 只应用 `RESET_GIFT_FLOW_SQL`(`work-order-gift-repo.ts:34-39`)。
- **修复**:撤销的 `UPDATE work_orders` 现同时清空 `assigned_worker_id / assigned_at / deadline_at / submitted_at / accepted_at / acceptance_json / draft_acceptance_json / problem_note / published_at / pinned_at`,与 `reopenCancelledWorkOrder` 的清理口径一致。
**P1-5 电子凭证在事务外核销、失败被吞**
- 指派路径在状态变更**之前**核销(`admin-work-order-assignment-service.ts:67-85`),逐张提交(`publish-voucher-service.ts:64-113`)⇒ 指派被拒时可能已有凭证被核销。
- 履约路径在事务**之后**核销(`consignment-fulfillment-service.ts:248-264`),失败只写进响应体;前端 `WorkOrdersPanel.tsx:716-726` **完全不读** `voucherConsume`,无条件弹"成功"。
- **修复**:核销并入状态变更事务(或异步编排 + 补偿);`!ok` 时接口失败并提示。
**P1-6 匹配引擎对空值放行,且履约时服务端不复检 → 错发武器**
- `consignment-matching-service.ts:125-128`:订单侧为空时 `sameSpec` 直接 `return true`;`:150` 系列只在双方都有值时才强制相等;`:119` 钻级解析失败兜底为 1(`invDiamond >= 1` 恒真)。
⇒ 商品名没写"安卓/微信/暗潮/1钻"(例如只写"苏尔南手枪")时,**任意在库库存都会被推荐为 100 分"精确匹配"**。
- `consignment-fulfillment-service.ts:109-153`:履约接口只校验"在库 + 允许匹配",不校验规格(匹配结果纯前端展示,`routes/admin/worker-platform/consignment.ts:239-274` 直接透传 `inventoryId`)。
- **修复**:空规格不得给"精确匹配";履约事务内用 `matchPeacekeeperOrderWithInventory` 复检,非 exact 一律 409(partial 需显式覆盖参数)。
**P1-7 `PUT /inventories/:id` 可绕过整个状态机 —— ✅ 已修复(本轮)**
- 原来透传 `status / workerId / depositAmountFen`;`consignment-repo.ts:404-540` 直接拼 SET:可把 `consumed` 改回 `in_stock`(不清绑定、不动押金、无审计),可改 `deposit_amount_fen`。
- **修复**:服务层显式拒绝 `payload.status`(400 `consignment_status_update_forbidden`,前端编辑弹窗本就不提交 status),仓库层移除 `status` 入参 —— 状态只能经由占库/解绑/回库/作废/撤销这些带守卫的函数变更。
**P1-8 前端对四个 consignment 权限零引用 → 权限一旦被取消,按钮照旧显示、点击必然 403**
- 19 个管理端接口已全部挂 `requireAdminPermission`(无遗漏),但前端全仓 grep 不到 `consignment.view/manage/review/fulfill`(引用数 0)。
- 关键路径:工单列表发布按钮只需 `worker_order.publish` 即可打开拦截弹窗(`WorkOrdersPanel.tsx:1391-1402`、`:698-714`),弹窗内两个按钮**无条件渲染**并直接调 `/fulfill`、`/assign-worker`(需 `consignment.fulfill`,`:2952-2968`)。`AdminUsersPage.tsx:456-471` 的角色权限页可以取消任一 consignment 权限(本次 commit 修白名单的正是这一页),取消后 UI 毫无变化 → 点击必 403(`routes/admin/session.ts:83-102`)。
- 同源:`ConsignmentInventoryPanel` / `ConsignmentSubmissionsPanel` 全部写按钮(`:192/:245/:172/:293/:307`、`CSP:97/:131`)也无校验;`consignment.view` 这个只读权限在 UI 层无法体现。
- **修复**:像 `WorkOrdersPanel.tsx:182-221` 那样读 `fetchAdminSession().user.permissions`,把 canView/canManage/canReview/canFulfill 透传到面板与弹窗按钮。
**P1-9 未审核 / 已冻结打手仍可发布寄卖(绕过接单账号审核)—— ✅ 已修复(本轮)**
- 前端菜单无条件渲染:`WorkerLayout.tsx:35,42,90`(⏳ UI 未收口);后端 `POST /consignment/publish` 原只用 `getRequiredWorkerSession`。
- **修复**:发布接口已挂 `requireActiveWorker`(对齐 `routes/worker/hall.ts:17-20`);`GET /consignment/my-items` 保持仅需会话(只读自己的数据)。前端菜单项的后置处理留待批次 E。
**P1-10 收集链接没有后台管理 UI,服务层是死代码 —— ✅ 已随渠道下线废弃**
- `services/admin/consignment.ts:67-82`(fetch/create/update link)零调用;后端三个接口(`routes/admin/worker-platform/consignment.ts:136-167`)无入口。
- **结论**:收集链接整条线已删除,不再需要后台管理 UI。
### P2 · 运营体验 / 合规 / 性能(按影响排序)
1. **搜索骗人**:`ConsignmentInventoryPanel.tsx:678` 占位符承诺"物品名/编号/收购人/店铺/卖家",后端 ILIKE 只覆盖部分字段(`consignment-repo.ts:184-189`)→ 按收购人/店铺搜恒空。
2. **统计每次刷新跑 5 条全表聚合**(`consignment-repo.ts:215-303`),且列表无排序/无列筛选。
3. **凭据无独立权限**:`mapConsignmentInventory` 直接返回 `credentialsJson`(游戏账号/密码,`consignment-admin-service.ts:77-80`),列表接口全量下发,仅需 `consignment.view`。
4. **`is_multi_use=false` 从未生效**:字段只落库(`consignment-repo.ts:788`),`submitPublicConsignment` 全文不读 ⇒ 单次链接可无限提报。**✅ 随渠道下线废弃**
5. **公开提交按手机号自动绑定打手**(`consignment-public-service.ts:113-124`)⇒ 知道打手手机号的第三方可让其被动冻结押金。**✅ 随渠道下线废弃**(现只有打手端本人发布)
6. **押金可能长期冻结无人收口**:`pending_review` 无过期策略;打手端只有 `publish` + `my-items`(`routes/worker/consignment.ts`),没有"撤回我的提报"。**⏳ 未处理**(本轮已补:冻结归属取台账,运营手动驳回即可释放)
7. **统计报表与设计文档不符**:文档 §六/§七 的【累计节省赏金】【本月出库单数】【店铺节省赏金/人效】【批次回款】均未实现;`getConsignmentInventoryStats` 只返回数量与成本 + 4 个 GROUP BY,前端指标卡也只 4 项(`ConsignmentInventoryPanel.tsx:559-587`)。
8. **列表无"匹配内部库存"角标 + 匹配懒加载 + 全表入内存**:`checkWorkOrderConsignmentMatches` 仅在点发布时调用(`WorkOrdersPanel.tsx:700`),且异常被空 catch 吞掉(`:710-712`,缺 view 权限时拦截永不出现);`listActiveConsignmentInventoriesForMatching`(`consignment-repo.ts:683-707`)全表读内存再 JS 匹配。
9. **待审徽标只统计当前页**(`ConsignmentSubmissionsPanel.tsx:85` + `:316`)。
10. **编辑无法解除打手关联**:`ConsignmentInventoryPanel.tsx:260` 的 `workerId || undefined` 撞后端 `!== undefined` 判定(`consignment-admin-service.ts:316-321`)。
11. **编辑弹窗押金被静默改成 ¥100**(`ConsignmentInventoryPanel.tsx:513`),而该值会被"回库重新冻结"按金额使用(`consignment-deposit-repo.ts:242-251`)。
12. **冷静期文案硬编码"默认 3 天"**(`WorkOrdersPanel.tsx:2852-2853`),与产品规则(`work-order-gift-repo.ts:101-105`)及同页自算的 `cooldownLabel`(`:1040-1042`)自相矛盾。
13. **冷静期倒计时时区错位**:后端把时间统一格式化为 Asia/Shanghai 无时区串(`utils/http.ts:4,212-235`),前端按浏览器本地时区 `Date.parse`(`worker-order-utils.tsx:344`、`DeadlineCountdown.tsx:18`)⇒ 非 UTC+8 环境显示偏移 8 小时(真实门禁在 DB,仅显示错)。
14. **接口失败静默成 0/空**:两个面板只看 `isLoading` 无 `isError`(`ConsignmentInventoryPanel.tsx:129-163`、`ConsignmentSubmissionsPanel.tsx:66-85`);打手下拉(`:117-120`)失败后指派按钮消失。
15. **批量入库最多 50 次串行 POST,无回滚无 loading**(`ConsignmentInventoryPanel.tsx:191-222`),失败重试会重复建单(无幂等键)。
16. **破坏性弹窗缺 `confirmLoading` → 双提交**(`:1235-1242`、`CSP:361-369/518-525`)。
17. **两条回库路径 `allow_matching` 语义分叉**:`consignment-repo.ts:657` 强制 TRUE,撤销回库保留原值,但两处 UI 文案都宣称"重新进入待匹配池"。**✅ 已统一(本轮)**:所有回库路径一律**保留**运营的开关设置,UI 文案同步更正(决策理由见本节开头的"刻意偏离"说明)。
18. **类型/契约漂移**:~~`services/consignment-public.ts:5-11` 声明 `ConsignmentSubmission`~~(文件已删除);`types/consignment.ts` 与后端返回结构不符、`services/worker.ts:375-391` 缺 `depositAmountFen`。**⏳ 部分未处理**(本轮已将 `sourceType` 收敛为 `manual|worker_portal|recycle`、新增 `assignedWorkerId`)。
19. **契约约束缺失**:`allocation_status` 中文自由文本无 CHECK(`077_consignment_inventory.sql:27`);指派占库**完全不写 `bound_work_order_id`**(`consignment-repo.ts:552-572`)。**✅ 占库已写绑定(本轮)**;`allocation_status` 的 CHECK 约束仍未加。
20. **小项**:`archive` 不记录操作人与原因;`remark` 拼接式审计无长度上限;履约成功后广播 `hallChanged: true`(`consignment-fulfillment-service.ts:264`)但工单从未进大厅(无效广播);RBAC 体系混用(寄卖下拉依赖的 `/worker-platform/workers` 仍是 `requireAdminRoles`,`workers.ts:20-22`)。
---
## 四、已核实为"正确"的部分(改动时不要破坏)
- 内部现货履约事务是原子的:事务内重新锁单(`consignment-fulfillment-service.ts:157-168`)+ 条件更新占库(`:170-199`),并发下不会两单占一件;占库同时写入工单绑定(本轮新增,含 `bound_work_order_id`)。
- 打手指派占库是单条条件 UPDATE(`consignment-repo.ts` claim 函数),回滚带 `WHERE status='consumed'`(有测试覆盖)。
- 押金账务并发安全:先锁钱包 `FOR UPDATE` 再读流水,缺口时拒绝部分退款,重复冻结幂等(`consignment-deposit-repo.ts`;本轮另加"归属/金额以冻结核销行为准"与部分唯一索引)。
- 审核入库/驳回原子且带状态条件。
- 押金履约模式禁止走撤销(`resolveRevocableConsignmentInventoryId`),且该模式下的库存已改为按工单绑定,撤单时由工单终态链路自动解绑。
- RBAC 权限链正确:四权限已在 `077` 注册并授予 admin/operator/support,`requireAdminPermission` 对 `admin` 直通;19 个接口迁移无遗漏,没有写接口只挂 view。
- 上传有服务端大小/类型限制(`routes/file-upload.ts` 10MB + magic-byte 白名单)。
- 订单级押金与寄卖发布押金互不误伤:前者按 `related_work_order_id` 匹配,后者 `related_work_order_id IS NULL` 且按 `payload_json.consignmentSubmissionId` 匹配。
- 手工钱包调整只写 `available_amount`,不减少 `frozen_deposit_amount`,不破坏冻结池不变量。
---
## 五、优化路线(分批,每批可独立发布)
### 批次 A · 修复两条断掉的入口链路 —— ✅ 已完成(性质已变)
1. ~~H5 上传返回值取 `data.file`~~ → **H5 渠道整体下线**(表、路由、页面、服务、`link_id` 列一并删除;迁移 `079`),提报统一走接单平台打手端。
2. ✅ 手工入库/编辑兼容 `credentialsJson/screenshotsJson`,三处入口统一走 `pickCredentialsJson` / `pickScreenshotsJson`。
3. 验收:`npm test` 401 例(398 通过 / 3 skip)、`typecheck`/`lint:check`/`format:check`/`check:sql` 全绿。
### 批次 B · 状态机封闭与库存绑定唯一性 —— ✅ 已完成
1. ✅ 撤销:`FOR UPDATE` 锁单 + 状态白名单(`in_progress|pending_acceptance|problem`)+ `UPDATE ... AND status=$n` + 原因必填 + 前端按状态与 `consignment.fulfill` 权限渲染。
2. ✅ 库存绑定收口:撤销加 `bound_work_order_id = $工单`;退回复库/作废加"未被进行中工单占用"守卫;0 行 409 且给出具体工单号;`079` 加部分唯一索引兜底并清理历史重复绑定。
3. ✅ 生命周期收口:`releaseConsignmentInventoryBindingForWorkOrderWithClient` 接入撤单仓库 5 条链路 + 打手退单申请 + 超时处置(重开/取消)。
4. ✅ 回归测试 5 例(占库绑定、状态白名单、解绑范围、作废/回库守卫、终态链路接线)。
5. 验收偏差:原始表述"回库路径一律恢复 `allow_matching`"改为"保留运营开关设置",理由见第三节"刻意偏离"。
### 批次 C · 指派事务化、资金/凭证一致性(P1)—— ✅ 已完成(本轮)
1. ✅ **指派"部分成功"窗口(P1-2)**:指派失败不再只把现货放回在库池,改为 `compensateFailedConsignmentAssignment` 三步补偿 ——
① 指派事务若已提交(工单 `in_progress` + 已指派打手)走管理端「取消指派」:解冻打手押金、工单退回大厅,并顺带解绑现货;
② 兜底释放库存占用(指派未提交时的主路径);
③ 摘掉工单上的 `material.consignment` 并推入 `consignmentHistory`。
每步独立 try:补偿失败不掩盖原始错误,但会留下备注/解绑痕迹。
2. ✅ **凭证核销不再假成功(P1-5)**:核销需调用快手外部接口,无法与数据库事务合并回滚,因此改为"显式失败 + 留痕 + 如实反馈"——
核销失败写事件 `consignment_fulfill_voucher_consume_failed`(对齐既有发布链路 `publish_voucher_consume_failed` 口径),
返回 `voucherConsume.ok=false`;前端 `notifyVoucherConsume` 改为 `message.warning`(含失败原因与"请人工核销"),不再无条件弹成功。
指派链路的凭证失败本来就由 `assignAdminWorkOrderToWorker` 抛 409 + 写 `assign_voucher_consume_failed`(失败即回滚,不产生部分状态)。
3. ⏳ 押金没收(P2-2 死分支)与编辑接口改额的台账一致性校验仍未做:前者需要业务先定"卖家违约扣没"规则。
4. **意外收获(必修)**:迁移 `079` 的语句顺序有 bug —— 旧 `source_type` CHECK 只允许 `manual/collection_link/recycle`,
而回填 `worker_portal` 写在删旧约束之前,导致**任何全新库在 079 必然失败**(本地 dev 库就是这样启动失败的)。
已改为"先删旧约束 → 回填 → 挂新约束",并在**全新空库**验证 `001 → 079` 一次性跑通(79 条迁移全部记录)。
因为 079 尚未发布(所在提交仅本地未推送),修复按仓库 `check:sql` 守卫要求合入引入它的那个提交,而不是新增补救迁移。
### 批次 D · 匹配准确性与防错发(P1)—— ✅ 已完成(本轮)
1. ✅ **空规格不再放行、钻级不再兜底 1(P1-6)**:`sameSpec`(任一侧为空即视为相等)改为 `compareSpec` 三态判定
(`match` / `mismatch` / `unknown`),`extractPeacekeeperSpecs` 的钻级兜底由 `|| 1` 改为 `|| 0`(未知)。
规则:系统/区服/系列冲突 → 直接排除;订单侧没写清楚的字段计入 `unknownFields`,**只要有未知项就绝不判"精确匹配"**,
降级为 `partial` 并在 `reason` 里列出缺失项(如"订单规格信息不全(未写: 系统、区服、钻级),需人工确认")。
2. ✅ **履约/指派服务端复检**:新增 `assertConsignmentSpecMatchedOrder`,两条路径(内部现货履约、指派打手)在占库前都要过:
规格冲突 → 409 `consignment_spec_mismatch`;非 exact 且未显式传 `allowPartial` → 409 `consignment_spec_partial_requires_confirm`。
前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。
3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。
### 批次 Y · 生产 079 迁移失败修复:先去重重复冻结再建幂等索引(本轮)
**现象**:生产部署在 079 的 `uniq_worker_wallet_ledgers_consignment_publish_freeze` 唯一索引上
23505(提报单 5 有两条 `consignment_publish` 冻结流水),迁移整体回滚、数据库无损。
重复冻结正是该索引要防缺陷的历史脏数据(批次 F2 之前发布链路允许二次冻结)。
**修法**(服务器从未成功应用 079,经确认直接改原迁移,与 079 自己"重复绑定工单先清后约束"同套路):
每组(打手+提报单)保留最早一条冻结流水;多余行仅改 payload 标记 `consignment_publish_superseded`
(移出 outstanding 口径与索引范围,原行留审计);多冻的钱退回钱包
(退额 = LEAST(多冻总额, 冻结池实有),绝不无中生有)并写 `consignment_publish_dedup_refund`
修正流水;同一打手多条多余流水先按钱包聚合再退;然后才建索引。静态测试钉住"去重退还必须先于建索引"。
**验证**:一次性 Postgres 完整复现生产路径——停在 078 + 注入提报单 5 双冻结脏数据 →
新 079/080 迁移成功;钱包可用 +2000(多冻退回)、冻结池回落单份 2000;修正流水留痕;
第三次冻结被索引拒绝。全新空库 001→080 亦验证通过。后端 484 测试全绿。
### 批次 X · 发布寄卖自动带「接单模板」建议价(本轮)
**需求**:接单平台已按规格配好模板价(生产库几十条,如 安卓暗潮1钻 ¥120 / 6钻 ¥500),
卖家发布寄卖时按规格自动带出,不用手填/猜价。
1. ✅ **后端**:纯函数 `suggestConsignmentPriceFromRules` —— 模板商品名(「安卓暗潮1钻」)
用与订单同一套 `extractPeacekeeperSpecs` 解析,系统+系列+钻级全对上才命中(系统按通配口径,
模板写「通用/双端」两端都算;模板没写系统则不命中,宁可不建议不给错价);多条命中取最高价。
新端点 `GET /worker/consignment/price-suggestion`(requireActiveWorker)。
2. ✅ **前端**:发布表单规格选齐(系统+系列+钻级)后 300ms 防抖请求;同规格去重、过期响应丢弃;
命中则自动填入寄售价并提示「已按接单模板匹配 N 条规则,建议价 ¥X(可修改)」;
**卖家手动改过价则只提示不覆盖**,换规格后重新允许自动带出;提交成功后状态复位。
建议价接口失败静默(锦上添花,不影响发布)。本地没配模板时无建议价、保持手填,行为不变。
3. ✅ **测试**:建议价纯函数行为测试(命中/系统不符/钻级不符/系列不符/多命中取最高/通用模板两端命中/无系统模板不命中)。
**验证**:后端 483 / 前端 54 测试全绿;两侧 `typecheck` / `lint:check` / `format:check` / `check:sql` 通过。
### 批次 Z · 建议价精准对齐璀璨数 + 璀璨不超过钻石等级(本轮)
**背景**:生产库接单模板为同钻级配置了不同璀璨的规则(如“苹果怒嚎5钻”分无璀璨、1璀、2璀、3璀),
但因忽略璀璨数导致 5 钻的 4 条规则全部命中并强制取最高价 ¥280,且切换璀璨时不刷新价格;同时业务要求璀璨数不得超过钻石等级。
1. ✅ **规格正则支持「璀」字缩写**:`parseSpecsFromText` 支持 `([1-7])\s*璀(?:璨)?` 与 `无\s*璀(?:璨)?|0\s*璀(?:璨)?`,
避免生产规则名称写 `3璀` 时被误识别为 0 璀。
2. ✅ **建议价精准对齐璀璨数**:`suggestConsignmentPriceFromRules` 及路由/服务透传 `brillianceCount`,
要求 `parsed.brillianceCount === wantedBrilliance`;0璀对0璀、1璀对1璀、3璀对3璀,未配对应模板则返回空,
消除同钻级多规则全命中取最高价的缺陷。
3. ✅ **发布前端璀璨数不能超过钻石等级**:
- 动态禁用:`BRILLIANCE_OPTIONS` 中 `value > diamondLevel` 自动置灰禁用(1钻最多1璀,2钻最多2璀…)。
- 自动收敛:调小钻级时(如 5钻改为 2钻),原选璀璨数超出时自动收敛至新钻级(2璀),并联动重算建议价与物品名。
- 默认值与联动:璀璨数默认 `initialValue={0}`(无),`queryKey` 纳入 `${bri}`,切换璀璨即刻重新带价。
4. ✅ **高规格溢出自动按当前配置最高档位报价**:
- 璀璨溢出:当号主提报的璀璨数高于后台已配置上限(如后台该钻级只配了 0~2 璀,号主出 3/4/5 璀等),自动按当前钻级已配置的最高璀璨档位报价。
- 钻级溢出:当号主提报的钻级高于后台配置上限(如后台只配了 1~6 钻,号主出 7 钻),自动按后台当前已配置的最大钻级报价(7钻报6钻价)。
- 边界安全:仅在超出配置上限时触发兜底;未超出配置上限时严格精确对齐(低钻绝不报高钻价,中间未配价规格返回 null 由号主手动填写,防错价)。
**验证**:后端 488 / 前端 54 测试全绿;两侧 `typecheck` / `lint:check` / `format:check` 通过。
### 批次 W · 规格口径:互通通配 + 后台全量/发布端分物品展示(本轮)
**业务定案**:
1. **操作后台全量选项**:系统 苹果/安卓/通用;区服 微信区/QQ区/互通。提报审核与库存编辑
的系统/区服从自由文本 Input 改为受控下拉(单一来源 `utils/consignment-spec.ts`)。
2. **打手端发布按物品类型展示**:苏尔南 = 系统只有 苹果/安卓(必须区分端,不给「通用」),
区服**只展示「互通」一个选项且默认选中**(互通 = QQ/微信都能收)。后续其他品类规则不同
(有的不分端),在共享常量里按品类加条目即可。
3. **匹配引擎通配符对称化**:`compareSpec` 支持按字段传通配值集合——区服通配 =「互通」
(订单文案里的「通用/全区/不限」归一化成通配别名),系统通配 =「通用」(「双端/全端」同理)。
以前只认**库存侧**是通用件;现在订单侧写通配值(买家不限)同样放行,只增不减匹配面。
`describeConsignmentSpecDifferences` 同口径:通配对不算差异,不再吓唬客服。
4. **提报白名单校验(API 层)**:系统只收 苹果/安卓(400 `consignment_system_invalid`),
区服只收 微信区/QQ区/互通(400 `consignment_server_zone_invalid`)——旧页面缓存/直调接口绕不过。
5. **物品名拼接**:区服为「互通」时不拼进默认物品名("安卓互通暗潮…"对买家无信息量),
前端自动拼名与后端默认拼名同口径。
**验证**:后端 481 测试(互通双向匹配/通配不算差异/真冲突仍排除/白名单校验均有用例)、
前端 54 测试(选项口径/表单引用/默认值静态钉住)全绿;两侧 `typecheck` / `lint:check` /
`format:check` / `check:sql` 通过。
### 批次 V · 复核遗留:份额验收钩子 + 手动匹配弹窗三处(本轮)
1. ✅ **拼单份额全部验收(母单转已验收)补寄卖钩子**:`acceptWorkOrderShareAndSettle` 里最后一个份额把母单转 `accepted` 的分支,此前既不释放发布押金也不结算供货货款(两个钩子只在统一验收里)。现在与 `acceptWorkOrderAndSettle` 同口径:有 `material.consignment.sourceSubmissionId` 就释放发布押金;供货货款固定传 `assignedWorkerId: 0`(拼单母单没有单一接单人,份额打手已各自按 share_reward 结算,不会重复)。
2. ✅ **手动匹配弹窗 · 数据过期的 409 不再死循环**:`consignment_inventory_race_condition / consignment_inventory_unavailable / consignment_spec_mismatch / consignment_spec_partial_requires_confirm` 四个错误码清空已选并自动重拉列表,按最新判定重来,不再对着失效候选反复撞错。
3. ✅ **手动匹配弹窗 · 库存侧搜索与指派死路**:关键字 + `pageSize:100` 透传后端(原来只拉默认 50 条、keyword 是从未传出去的死参数,第 51 单以后搜不到);关键字 300ms 防抖触发后端搜索;无供货打手的现货禁用"指派供货打手履约"选项(原来选了必 400 `consignment_worker_required` 且弹窗里没有打手选择器,是死路),提交前再拦一道;库存侧选中订单时从响应 inventory 带出 `workerId/workerName` 回显。
4. ✅ **凭证核销反馈抽共享出口**:`utils/voucher-consume.ts` 统一 `notifyVoucherConsume`(含"已提前核销 N 张"提示),`WorkOrdersPanel` 本地实现删除、手动匹配弹窗弃用内联子集版,口径不再漂移。
**验证**:后端 477 测试(+4)全绿、前端 50(+1)全绿;两侧 `typecheck` / `lint:check` / `format:check` / `check:sql` 通过。
### 批次 U · 复核修复:占库 500 / 指派双付 / 手动匹配权限门控(本轮)
对 `94328ac6..0476339e` 做了一轮对抗复核(两个并行审查 + 人工验证),修三处:
1. ✅ **P0 指派打手占库在真实库必 500**:`claimConsignmentInventoryForWorker` 的
`allocation_status` 默认值是自由文本「已指派打手(押金履约中)」,撞上迁移 `080` 的
`CHECK ('待安排','已安排')`。假 client 单测不跑真 SQL 一直没暴露(与当年 42804 同病)。
默认值改为「已安排」,并加静态守卫测试(剥掉注释后断言默认值必须在枚举内,
防止注释里引用历史值误伤)。
2. ✅ **P1 指派非供货卖家时货款双付**:`resolveConsignmentAssignRewardFen` 以前无条件把
工单报酬改写成结算价。指派了别的打手时(API 层允许,界面不会主动构造):打手按结算价
拿一份 + 卖家再拿一份供货货款(`settleConsignmentSupplierPayout` 只在"接单人===卖家"时
跳过)= 平台双付。现在纯函数加 `assigneeIsSupplier` 参数:卖家本人履约 → 报酬=结算价
(货款走工单报酬渠道);别的打手 → 报酬保持原值(货款走供货货款渠道),两条渠道互斥。
补行为级测试 + 调用点静态钉住(判定基准必须是 `inventory.worker_id`)。
3. ✅ **P1 手动匹配入口权限错位**:库存面板「手动匹配订单」挂在 `consignment.manage` 门下、
拦截弹窗 footer「手动指定现货匹配」没门控,而两个动作提交打到要求 `consignment.fulfill`
的 `/fulfill`、`/assign-worker` —— 只有 manage 的运营能点进去、选完单、点确认后必 403。
两处入口统一按 `canFulfillConsignment` 门控(编辑/作废仍为 manage,与后端权限一一对应),
mount 测试静态钉住。
**验证**:后端 473 测试(+3)全绿、前端 49(+1)全绿;两侧 `typecheck` / `lint:check` /
`format:check` / `check:sql` / `build` 通过。
**复核中确认无需改动**:手动指定现货的服务端复检与留痕、售后回落卖家、卖家操作归属校验、
删除类提交的残留引用 —— 均已核对无发现。遗留待办(拼单份额验收补寄卖钩子、文件接口登录态、
弹窗 409 后刷新)见复核报告,未在本批处理。
### 批次 T · 手动匹配的入口修复(本轮)
**问题**(用户):在库待匹配列表点「手动匹配订单」**不弹窗**。
**根因**:弹窗被挂到了「编辑寄售物品」弹窗**内部**(JSX 嵌套位置错),
外层弹窗没打开时它根本没被挂载 → 点击只改状态、什么都不发生。
**这类"点了没反应"的问题在浏览器里才看得出来**,所以顺手加了静态守卫。
| 修复 | 说明 |
| --- | --- |
| ✅ 挂载位置 | 弹窗移到面板顶层(与其它弹窗同级),点击即弹 |
| ✅ 静态守卫 | 新增 3 例测试:断言手动匹配弹窗**前面不能有未闭合的 Modal**,按钮必须真的 setState、open 由该状态驱动(已验证该守卫能拦下"嵌在别的弹窗里"的写法) |
| ✅ 入口补全 | 订单侧原来只在「检测到候选」的提醒弹窗里给入口,**订单没有任何系统候选时反而进不去**(而那正是最需要人工指定的时候)→ 工单行内新增常驻「手动匹配现货」(仅待指派/待补资料,与后端前置校验同口径) |
| ✅ 失败前收敛 | 已暂停匹配的现货点按钮会直接 409 → 改为按钮禁用 + 提示"先打开允许匹配开关" |
| ✅ 顺带 | `destroyOnClose` 换成 antd 6 的 `destroyOnHidden`(前者已废弃) |
验证:前端 typecheck / lint / format / 48 例测试 / build 全过。
### 批次 S · 客服手动指定现货匹配(本轮)
**需求**(用户):客服要能在后台**人工指定**现货与订单匹配起来,而不是只能用系统算出的候选。
**先说清楚现状**:后端其实早就留了"人工选定现货"的口径(`allowPartial` + 宽松规格复检),
只是**没有界面入口**,客服只能从"规格完全对得上"的自动候选里挑;遇到订单规格没写全、
订单录错、需要高配充低配时无从下手。
**改法**(三项决策均由用户确认:允许强制匹配但必须留痕 / 两种交付方式都保留 / 双向入口都加):
1. ✅ **判定与解释**:新增 `listConsignmentMatchOptions()`/`classifyConsignmentMatch()`,
把某订单的**全部**在库现货逐件判定 exact / partial / conflict,conflict 时给出**字段级差异**
(`describeConsignmentSpecDifferences`,例如「系统:订单「苹果」/ 现货「安卓」」)。
正反两个方向共用同一套判定,避免"界面说能选、接口却拒绝"。
2. ✅ **强制匹配 + 留痕**:履约/指派两条链路新增 `allowSpecMismatch` 与 `matchNote`。
规格真冲突默认仍拒绝(409 `consignment_spec_mismatch`);显式确认后放行,但
**必填原因**(400 `consignment_match_note_required`),原因与差异明细写入工单事件与
`material.consignment`(`manualMatch` / `matchType` / `matchNote` / `specConflicts`)。
3. ✅ **双向入口**:订单侧 `GET /consignment/matches/:workOrderId/options`(全部在库现货+判定),
库存侧 `GET /consignment/inventories/:id/match-orders`(该现货可匹配的待指派/待补资料订单+判定)。
4. ✅ **前端一个共享弹窗**:`ConsignmentManualMatchModal`,订单侧(匹配弹窗里「手动指定现货匹配」)
与库存侧(寄售仓库行内「手动匹配订单」)共用,避免两套 UI 逻辑漂移。
交互:搜索 → 选中(带判定标签与差异说明)→ 选交付方式(内部现货履约 / 指派供货打手)
→ 非精确勾选确认 / 冲突勾选「确认规格不一致」+ 必填原因 → 落地。
判定规则收敛在纯函数 `consignment-manual-match.ts` 并有单测(4 例)。
**真实库验证**(订单要「苹果微信区暗潮2钻」,现货为「安卓QQ区苍龙4钻」):
| 步骤 | 结果 |
| --- | --- |
| 订单侧选项 | 该件判定 `conflict`,列出 4 项差异(系统/区服/系列/钻石等级) |
| 库存侧可匹配订单 | 该订单判定 `conflict`,要求规格解析正确 |
| 不填原因强制匹配 | ✅ 被拒 `consignment_match_note_required` |
| 不勾选强制、直接履约 | ✅ 被规格复检拒绝 `consignment_spec_mismatch` |
| 勾选 + 填原因 | ✅ 成功:订单 `in_progress`、现货 `consumed`,事件与 material 均留痕 |
| 接口鉴权 | 未登录访问两个新接口均 401(路由已挂载、权限位生效) |
临时数据已清理(残留 0)。
### 批次 R · 售后立案也认寄卖关系(本轮)
补货款时顺手排查"只认接单人"的地方,发现同类第三处:**售后立案**。
`createAfterSalesCase` 里责任人取 `work_orders.assigned_worker_id`,取不到就
`failureReason: 'worker_missing'` → 客服界面上是 409「请指定已验收的责任打手或拼单份额」。
内部现货履约的寄卖单**没有接单人**,于是这类单子**连售后都立不了案**;
而货款修好后,钱已经结算给供货卖家了,担责的人本来就该是他。
**改法**:把"从工单反查供货卖家"抽成共享查询
`getConsignmentSupplierByWorkOrderWithClient()`(`consignment-supplier-repo.ts`),
货款结算与售后立案共用同一份口径(工单 `material_json.consignment.inventoryId`
或库存 `bound_work_order_id` → 库存 → 提报单),售后在找不到接单人时回落到供货卖家,
并在验收快照里标记 `consignmentSupplier: true` 与提报单号留痕。
**真实库验证**(内部现货履约,接单人为空):
| 步骤 | 结果 |
| --- | --- |
| 从工单反查供货卖家 | `workerId=3`、结算价 12400、提报单号正确 ✅(不是无关的打手 4) |
| 售后立案 | ✅ 成功,`case_no=VERIFY-AS-…`,责任人 `worker_id=3`(供货卖家) |
临时数据已清理(残留 0)。至此"寄卖单没有接单人"这一类坑的三处(货款、售后、卖家按钮)都已收口。
### 批次 Q · 补上寄卖**货款**结算(本轮)
**问题(用户对账发现)**:工单 #100171 验收完成时,卖家钱包只有两笔——发布押金 -20 又 +20。
**货卖出去了,但货款的收益没进卖家钱包。**
**查证**:
| 项 | 值 |
| --- | --- |
| 工单报酬 `reward_amount` | 14500 分(¥145.00,发单方付给平台) |
| 货的结算价 `cost_price_fen` | 12400 分(¥124.00,卖家应得) |
| 接单人 `assigned_worker_id` | 空(内部现货履约,客服/内部仓代交付) |
通用验收结算的规则是"把工单报酬付给**接单人**"。内部现货履约的单子**没有接单人**,
于是这笔钱谁也没拿到;而卖家交付了自己的货,只拿回了自己的押金。差价(¥21)本该是平台的。
**改法**:
1. ✅ 新增 `settleConsignmentSupplierPayoutByWorkOrderWithClient()`:验收完成时,
把货的结算价(`cost_price_fen`,缺省退回提报的 `asking_price_fen`)补给**供货卖家**,
金额单位与通用报酬一致(分),并同样做售后欠款抵扣、按售后策略决定是否延迟解冻、
写 `reward_settlement` / `reward_pending_unfreeze` 流水(说明「寄卖货款结算 #单号」)。
2. ✅ **不重复付款**:接单人就是供货卖家本人时跳过——指派打手线里接单人正是卖家本人,
货款已经随工单报酬付过了(见下一条),只有"没人收"的那一份才在这里补。
3. ✅ **顺带修掉一个 100 倍单位错**:指派打手线把工单报酬改写成"货的结算价"时,
写的是 `Number((cost_price_fen / 100).toFixed(2))` → **¥124 被写成 ¥1.24**,
卖家只能拿到结算价的百分之一。`reward_amount` 的单位是**分**(与钱包、流水一致,
已用真实数据核对:`reward_amount=2500` 对应流水 `2500`、界面 ¥25.00)。
现在抽成纯函数 `resolveConsignmentAssignRewardFen()` 并加测试钉住单位。
**真实库端到端验证**(内部现货履约,结算价 ¥124 / 发单方付 ¥145):
| 步骤 | 结果 |
| --- | --- |
| 验收前卖家钱包 | 可用 ¥205.50,累计结算 ¥297.50 |
| 走真实验收结算 | 订单转 `accepted` |
| 本次流水 | `reward_settlement ¥124.00 寄卖货款结算 #100175` |
| 验收后卖家钱包 | 可用 **¥329.50**(+¥124.00),累计结算 ¥421.50(+¥124.00)✅ 等于货的结算价 |
验证脚本改动的开发库数据已还原(钱包恢复 ¥205.50 / 累计结算 ¥297.50,等级未被触发变更),临时数据残留 0。
**遗留**:用户对账时看到的 #100171 是在本批修复**之前**结算的,所以它当时确实没发货款(¥124.00)。
要不要给这类历史单补一笔,等用户确认。
### 批次 P · 「确认加好友 / 上传赠送截图」恢复为卖家操作(本轮)
**问题(用户截图:卖家端的货点不了确认加好友)**:卖家「我的寄卖」里那件"已匹配-待加好友"的货,
提示写着「等待客服确认加好友」,**没有按钮**。
**原因**:批次 I 把这两个动作的能力位绑在"订单接单人 = 当前卖家"上(`selfFulfilling`),
而内部现货履约的订单**根本没有接单打手**(`assigned_worker_id` 为空),于是:
状态机把按钮关掉 + 提示写成"等客服";后端归属校验也会 403(`consignment_fulfillment_not_owned`)。
**业务定调**(用户):**卖家可以确认加好友,冷却到期后也能自己上传赠送成功截图**,客服只是兜底。
**改法**:
1. ✅ 状态机能力位改成按阶段给(不再看接单人):
- `material_submitted`(赠送资料已交)→ 卖家可点「确认加好友」;
- 内部现货履约没有"先交赠送资料"这一步 → `material_required` 阶段卖家也可直接确认(与客服侧能力一致);
- 冷却结束(`gift_ready`)→ 卖家可「提交赠送截图」。
- 提示统一改成卖家视角:「请先加上买家好友,加好后点『确认加好友』:确认即进入赠送冷却期,
未完成赠送押金不退(客服也可代为确认)」。
2. ✅ 后端归属只认「这张提报单就是当前卖家的」(别人的单仍 404 `consignment_item_not_found`),
删掉"订单接单人必须是卖家"的 403 —— 内部现货单没有接单人,按接单人卡会把卖家自己的货挡在外面。
3. ✅ 卖家提交赠送凭证走 `submitWorkerOrderAcceptance(..., { asConsignmentSupplier: true })`:
跳过"必须是接单打手"的校验(归属已由寄卖单保证),**冷却是否结束、必须带图**等校验全部保留。
4. ✅ 客服侧按钮不变,谁先点谁生效,后点的会拿到明确的 409 提示。
**真实库端到端验证**(造一件内部现货履约的货,订单无接单打手):
| 步骤 | 结果 |
| --- | --- |
| 卖家列表 | 阶段「已匹配-待加好友」,`确认加好友按钮=true`,提示为卖家操作口径 |
| 卖家点确认加好友 | `gift_phase=friend_countdown`,冷却到 +72h |
| 冷却中传截图 | 被拒 `work_order_gift_cooldown_active` |
| 冷却到期 | 阶段「待赠送」,`上传截图按钮=true` |
| 卖家上传赠送截图 | 成功 → 工单 `pending_acceptance`(交客服验收) |
临时数据已清理(残留 0)。
### 批次 O · 去掉文件链接的签名/有效期(本轮)
**问题(用户截图:客服看到的凭证是裂图)**:寄卖凭证的图片地址是**带签名的临时链接**:
```
/api/v1/files/object?key=consignment_screenshot/2026/09/15/xxx.webp&expires=1789463063&signature=...
```
签名有效期只有 **15 分钟**,而**入库时会把当时的链接原样存进 `screenshots_json`**。
工单验收那条线在出参时用 `refreshStoredFileUrl()` 重签,所以工单图片正常;
寄卖这条线(提报凭证 / 赠送凭证 / 卖家列表)漏了这一步 → 存下来的链接早过期 → 客服看到裂图。
图片本身没丢,是"取的时候没重签"。
**业务定调**(用户):签名这套意义不大,直接去掉,用普通链接。
**改法**:
1. ✅ `buildFileUrl()` 现在只产出**不带签名、不带有效期**的普通链接
(对象 key 带随机段,本身不可枚举),删掉签名生成/校验与 15 分钟 TTL 常量,
文件读取路由不再校验签名。
2. ✅ `refreshStoredFileUrl()` 保留但语义变成"**归一化**":把历史存的签名链接洗成普通链接,
老数据不用改也能看(`normalizeStoredFileUrl` 保留为同义别名)。
3. ✅ 寄卖侧出参统一走归一化:提报凭证、赠送凭证(`imageUrls` + `files[]`)、库存截图、卖家列表截图;
这样新老数据都稳定可看,不再依赖"每一处读取都记得重签"。
**真实库验证**:库里那条**早在 2026-09-15 就过期**的老链接,现在真实请求返回
`HTTP 200 / image/webp / 52632 字节`(改造前是 403);客服接口下发的地址已是不含签名的普通链接;
不存在的 key 仍然 404。
**取舍说明**:文件路由本身没有登录校验,原来唯一把关的就是签名。去掉签名后,
**知道链接的人就能看图**(key 里有 24 位随机段,实际不可枚举,等价于一条长口令)。
寄卖凭证可能包含游戏账号/主页信息,如需收紧,可以后续给该路由加"仅登录后台可读"的校验。
### 批次 N · 删掉"按库存号操作"的卖家入口(本轮)
批次 I 为了兜住"客服手工入库的现货没有提报单"这种情况,给卖家动作额外开了两个按库存号的入口
(`/worker/consignment/inventories/:inventoryId/...`)。手动入库停用后这类货已不可能再产生,
这两个入口就是纯负担(还要维护"一个 id 两义/分路由"的复杂度)。
**改法**(测试阶段,无历史数据包袱,直接删):
1. ✅ 删掉两个按库存号的路由、两个服务函数、前端两个请求函数与页面里的锚点分派逻辑;
卖家动作**只按提报单号(寄卖单号)定位**。
2. ✅ 卖家列表里"没有提报单"的兜底行不再暴露内部仓库号(标题改「客服手工入库」),
且一律不给操作能力位——不给用户一个点下去必然失败的按钮。
3. ✅ 加静态守卫测试钉住这两点,防止以后又长出按库存号的入口。
### 批次 M · 停用客服手动入库(本轮)
**背景**:手动入库是唯一会造出「没有提报单的现货」的入口,而这类货**没有寄卖单号**,
于是全流程(统一状态机、凭证链、搜索、卖家端的单号锚定)都没法用一个号串起来——
它只能在界面上回退显示内部仓库号 INV…,正是"一件货两个号"的残留。
**业务定调**:寄卖货源**只走「打手在平台提报 → 客服审核入库」这一条路**。
**改法**:
1. ✅ 前端「寄售仓库」的「手动入库」入口整块删除(按钮 + 入库弹窗 + 提交逻辑 + 服务函数),
客服无法再建无提报单的货。
2. ✅ 后端同时挡住,避免旧页面缓存或直接调接口绕过:
`POST /worker-platform/consignment/inventories` 返回 409 `consignment_manual_inbound_disabled`
(保留明确的拒绝而不是静默删除,将来要恢复只需回退这个守卫)。死代码一并清掉:
服务层的原实现与仓库层的 `createConsignmentInventory` 都已删除(要恢复从 git 历史取)。
3. ✅ 提报审核入库(正常路径)不受影响:它照旧生成内部仓库号 INV… 并写入 `source_submission_id`,
即**每件货都必然挂着一个寄卖单号**,INV 只作为内部编号存在、界面不展示。
**真实库验证**:调用手动入库接口被拒(409 `consignment_manual_inbound_disabled`),
且库里「没有提报单的现货行数」前后均为 0(没有偷偷写入)。
### 批次 L · 匹配弹窗防点错(本轮)
**问题**:匹配弹窗里「走接单平台指派打手(冻结押金交付)」与「内部现货履约」两个按钮
上下紧挨、样式相近,而且**只有"部分匹配"才弹确认**——精确匹配时两个按钮点下去都立刻执行,
点错就要走「撤销寄售履约」才能退回来。
**改法**:
1. ✅ 两个动作**一律二次确认**(不再只有部分匹配才确认),确认框把后果写清楚:
寄卖单号(可复制)、动作(给谁/要不要冻结押金)、结果(立刻占用现货、其它订单不能再匹配它、
内部履约还会跳过「待打手提交资料」直接进冷却)、以及"退回要走撤销寄售履约";
部分匹配时额外红字提示"不是精确匹配、无法自动回退",并把确认按钮降为 danger。
2. ✅ 按钮区做成独立分区的「选择履约方式」小卡片:两个按钮各占一行、`block` 铺满(点击目标更大、
不再是小按钮挨着)、间距拉开到 14px、各加图标(打手=TeamOutlined,库存=PushpinOutlined),
每个按钮下方一句后果说明("由该供货打手接单,需冻结履约押金" / "不外派打手,直接用库存账号赠送")。
### 批次 K · 匹配弹窗也改用寄卖单号,内部仓库号只留内部(本轮)
**问题**:内部寄售库存匹配弹窗(发单时拦一下"有现货可直接履约"那个)显示的是
`寄卖单号 INV26091711253394`——因为候选现货走的是**另一个查询** `listActiveConsignmentInventoriesForMatching`,
它没 join 提报单,`sourceSubmissionNo` 取不到,前端就回退显示内部仓库号。
结果是客服在弹窗里看到 INV,拿去寄卖仓库搜索又搜不到,还得自己换算。
**改法**:
1. ✅ 匹配候选查询补 `LEFT JOIN consignment_submissions` 带出 `source_submission_no`。
注意这个查询原来是 `SELECT *` + 不带前缀的列名,而 `consignment_submissions` 同样有
`status / game_name / category_id`——加 join 会让这些条件变成 ambiguous column 直接 500,
所以列名一律加 `ci.` 前缀并显式列出字段(已加静态守卫测试钉住这一点)。
2. ✅ 客服界面**只展示寄卖单号**:匹配弹窗候选卡片、工单上的寄售标识、工单详情、寄卖仓库列表/详情
全部去掉内部仓库号;客服手工入库的现货明确写「客服手工入库(无提报单号)」,
历史老单写「老单无提报单号」,都不再抛内部编号。
3. ✅ 内部仓库号 **保留**:仍在数据里、仍在接口里、**按它搜索依然搜得到**(后端两个关键字条件都覆盖),
撤销/解绑等链路一行没动。
**真实库验证**:仓库层候选查询与匹配接口(`checkWorkOrderInventoryMatches`)在构造数据上都带出了
寄卖单号(`VERIFY-MATCH-INV → VERIFY-MATCH-SUB`)。临时数据已清理。
### 批次 J · 凭证链可视化:提报凭证 + 赠送凭证(本轮)
**问题**:一件寄卖有两类凭证,但客服界面只看得见第一类,而且是 48×48 一张缩略图,看不出是物品还是主页:
1. **提报凭证** = 卖家提报时的「游戏主页截图」(含物品/仓库详情),存在提报表 `screenshots_json`。
2. **赠送凭证** = 履约方冷却结束后上传的**赠送完成截图**(证明"这件货真送出去了"),
存在**绑定工单的验收资料** `work_orders.acceptance_json` 里。
第一类只在寄卖审核界面可见;第二类只有在工单验收界面才看得到。客服要证明一件寄卖的赠送
是否完成,得自己在两个模块之间翻,且没有任何地方把两类凭证并排呈现。
**改法**:
1. ✅ 后端把**凭证链**从客服两个列表/详情接口一起下发(提报表 → 结果库存 → 绑定工单):
`submissionScreenshots`(提报凭证)、`giftEvidenceImages`(赠送凭证)、`giftEvidenceNote`、
`giftEvidenceSubmittedAt`、`giftWorkOrderNo`、`giftWorkOrderStatus`、
`giftEvidenceReviewStatus`(`none` 未提交 / `pending` 待客服验收 / `accepted` 已验收 / `submitted` 其他在途)。
赠送凭证的图片从工单验收资料的 `imageUrls` 与 `files[].url|mediumUrl|thumbnailUrl` 两处取并去重。
2. ✅ 提报凭证口径统一:有来源提报单就用提报单的主页截图,客服手工入库的现货回退用库存自己的截图,
前端只需要看 `submissionScreenshots` 一个字段,不会再拿错。
3. ✅ 前端新增共享组件 `ConsignmentEvidenceGallery`(缩略图 + 大图预览 + 共 N 张),
审核面板与仓库面板复用;审核面板原来那个「凭据截图」列拆成
「提报凭证(游戏主页截图)」与「赠送凭证(赠送完成截图,带待验收/已验收 Tag 与关联工单)」两列,
详情抽屉同样分组,并给出 `describeGiftEvidence()` 的一句话说明。
**真实库验证**:赠送凭证解析在人工构造的工单验收资料上正确取到 3 张图(`imageUrls` 1 张 + `files` 2 张)
并能识别 `pending_acceptance → 待客服验收`;提报凭证在真实数据上取到 1 张。临时数据已清理。
### 批次 I · 一个单号锚定 + 统一状态机(本轮)
**问题**:一件货在系统里有三层互不相通的状态——提报单(`SUB…`,审核态)、库存(`INV…`,仓库态)、
工单赠送(`material_required / friend_countdown / pending_acceptance / accepted`)。
`my-items` 同时返回两个数组、前端分两个列表渲染,于是**同一件货两行、两个号**;
更糟的是卖家端只映射了前两套状态,"已出库"之后流程就断在黑暗里,
卖家不知道已经匹配、不知道该加好友、不知道在冷却、不知道该赠送。
**改法**:
1. ✅ **提报单号 `SUB…` 作为唯一「寄卖单号」**,从发布锚定到结算(零迁移,历史数据直接可用)。
`listWorkerConsignments` 改成**一个提报单一条记录**:库存、绑定工单的信息挂上去,
客服手工入库的现货(没有提报单)才用库存号当锚点。真实库验证:打手 3 的 2 件货从 4 行收敛成 2 行。
2. ✅ **统一状态机** `domain/consignment-item-stage.ts`(纯函数、可单测),把三套底层状态推导成
卖家看得懂的一个阶段 + 一句"下一步该做什么":
待审核 → 已通过(在库待匹配)→ 已匹配-待加好友 → 加好友赠送冷却中 → 待赠送
→ 已赠送待审核 → 已完成;另有 已驳回 / 已下架作废 / 已取消,以及标准验收单的 履约中 / 待验收。
列表筛选与 KPI 也改由它驱动(待审核 / 履约中 / 已完成)。
3. ✅ **卖家可以在「我的寄卖」操作**(以前只有 publish + my-items 两个接口,卖家只能干看):
- `POST /worker/consignment/items/:submissionId/confirm-friend`:确认已加好买家好友 →
进入赠送冷却期;前端弹窗明确写「确认后进入赠送冷却期、未完成赠送押金不退」。
- `POST /worker/consignment/items/:submissionId/gift-evidence`:上传赠送截图给客服审核。
- 客服手工入库的现货没有提报单,另开 `POST /worker/consignment/inventories/:inventoryId/...`
两个路由走同一套动作(**不做"一个 id 两义"**,避免提报单号与库存号撞号时误操作别人的货)。
- 两个动作都**复用既有工单链路**(`confirmWorkOrderFriendAdded` / `submitWorkerOrderAcceptance`),
只加一道归属校验:不是本人履约的订单直接 403,卖家碰不到别人的单。
顺带白拿了既有的强校验:**冷却没结束不许提交凭证**、必须带图。
4. ✅ **客服端同号**:库存列表/详情接口补 `sourceSubmissionNo`(LEFT JOIN 提报单),
履约/指派写进工单的寄售信息也带上 `sourceSubmissionNo`,客服面板与工单详情显示同一个寄卖单号
(历史老单回退显示仓库号)。
**真实库端到端验证**(提报单线与手工入库线各跑一遍):卖家「确认加好友」→
`gift_phase=friend_countdown`、`friend_added_at` 落库、`gift_available_at` 正好 +72 小时;
冷却期内提交赠送截图被拒(`work_order_gift_cooldown_active`);
换另一个打手来操作被拒(`consignment_item_not_found`)。临时数据已清理。
**遗留**:列表按 `pageSize: 200` 拉全量再聚合,单个卖家寄卖件数超过 200 时需改成游标分页;
`pending_acceptance` 仍在寄卖可撤销白名单内(见 P2-7);客服长时间不点「确认加好友」没有超时策略(按业务确认先不管)。
### 批次 H · 修掉"乱弹匹配提醒"与"赏金没换算成元"(本轮)
1. ✅ **发布时不再乱弹「内部寄售库存匹配提醒」**:
根因在 `matchPeacekeeperOrderWithInventory`——精确匹配判定失败后**无条件 fall through 到
部分匹配**。而对"订单没写系统/区服/系列/钻级"这类字段,`compareSpec` 返回 `unknown`(不是
mismatch)会直接放行,于是**任何只写了游戏名的订单都会命中仓库里全部在库货**,
表现为"随便发布点什么都会弹一下",提醒失去意义。
现在严格模式加两道门槛:① 系统/区服/系列必须都能确认一致;② 还要有正信号(库存配置更高,
或订单压根没提钻级)。"订单没写规格"不再算命中。
2. ✅ **人工履约复检不被连带收紧**:`matchPeacekeeperOrderWithInventory` 增加
`allowUnspecifiedSpecs` 选项。运营已人工选定现货时走宽松模式(标题没写全规格不硬拦,
但真冲突仍然拦),自动提醒永远走严格模式,两个语义互不污染。
3. ✅ **接单赏金换算成元**:`WorkOrdersPanel.tsx` 里弹窗直接用 `rewardAmount`(单位是分)
渲染,把 ¥40 显示成 ¥4000。同文件其他 30 余处都是 `/100`,只有这里漏了。已修正,
并核对弹窗内 `内部收购成本` / `履约押金` 本来就是 `/100`(正确)。
### 批次 G · 现货作废口径 + 押金处置可选(本轮)
1. ✅ **确认加好友后现货一律作废下架,不再回池**(严口径):
`确认加好友` 意味着买家好友已加上、货随时可能送出去,回池会被下一个工单再次占用
(等于同一件货二次出货)。这条口径收口在寄卖自己的
`releaseConsignmentInventoryBindingForWorkOrderWithClient` 里:解绑前先读工单的
`acceptance_mode / friend_added_at`,命中则 `status='archived'` + `allow_matching=FALSE`
+ 备注留痕,否则仍按原逻辑 `in_stock` 回池。所有解绑路径(取消指派/撤单/退回大厅/退单/
售后/撤销寄售履约)自动继承,不需要各自判断,发单系统的接口一行没动。
2. ✅ **撤销寄售履约弹窗支持两笔押金各自可选**(只加在寄卖专属入口
`POST /consignment/revoke` 上):
- 打手履约押金:`auto`(已确认加好友则扣除,否则全额退还)/ `refund` / `forfeit`
- 卖家发布押金:`auto`(已确认加好友则没收;否则作废退还、回库保持冻结)/ `keep_frozen` / `forfeit`
默认全部 `auto` = 原行为;覆盖后原因照旧必填,并把选项与金额写进工单事件
(`workerDeposit` / `consignmentDeposit` / `inventoryArchived`)便于对账追责。
策略判定抽成纯函数 `domain/consignment-deposit-policy.ts`,可单测。
3. ✅ **修掉一个资金漏洞:撤销寄售履约从不结算打手履约押金**。
它以前只清 `assigned_worker_id`、只处理卖家发布押金,打手的履约押金没有任何出口,
`worker_deposit`(指派打手)单撤销后押金会**一直冻在钱包里**。现在撤销时按上面的
选项调用 `settleWorkOrderDepositWithClient` 结算(退还或没收,金额取冻结台账与钱包冻结额的较小值)。
4. ✅ **修掉一个真实库必炸的 500(42804)**:`detachConsignmentMaterialFromWorkOrderWithClient`
里 `$1` 既作 `$1::text`(写进 jsonb 的 `detachedAt`)又赋值给 `updated_at`,Postgres 把它
整体推断成 text,赋值给 timestamptz 列直接报错。凡解绑现货时摘寄售标记的路径
(取消指派/撤单/退单/售后/撤销履约/人工回库)在真实库上全都会炸,而假 client 的单测不跑
真 SQL,一直没暴露。已显式 `$1::timestamptz`,并补了一道类级静态护栏
(`sql-param-type-safety.test.ts`)扫描同类"占位符既当 text 又当时间戳"的写法。
5. ⏳ 待你拍板:`撤销寄售履约` 的状态白名单仍含 `pending_acceptance`(打手已提交赠送凭证)。
未确认加好友的单子在这一步撤销仍会回池——如果这类单也可能已经交付,需要一并收紧。
### 批次 F · 待加好友流程与押金不退(本轮)
1. ✅ **内部现货履约不再自动进冷却**:新增 `parkWorkOrderWaitingFriendAddWithClient`,占库落地时把工单停在
`acceptance_mode=friend_gift` + `gift_phase=material_required` 的**待加好友**阶段(`friend_added_at` /
`gift_available_at` / `deadline_at` 全部留空),客服线下通知加好友后手动点「确认加好友」
(复用已有 `POST /worker-platform/orders/:id/confirm-friend`)才进入赠送冷却。
冷却时长按商品规则落到工单,两者都为 0 时补默认 72 小时(否则确认后会得到 0 秒冷却)。
同时删除已无调用方的 `forceFriendGift` 开关,避免再出现"自动确认加好友"的旁路。
2. ✅ **前端文案**:内部现货单在该阶段显示「待加好友」(打手线仍是「待打手提交资料」),
确认按钮统一为「确认加好友」;阶段文案抽到 `utils/gift-phase.ts` 便于单测。
3. ✅ **后台备注快捷项**:「已通知加好友」一键追加「[已通知加好友] 操作人 时间」,仍可手动补充(复用 `admin_note`)。
4. ✅ **确认加好友后押金不退(P2-2 `deposit_deduction` 死分支落地)**:
- 打手履约押金:沿用既有的 `isWorkOrderGiftDepositForfeitable`(`friend_added_at` 有值即不退);
- **寄卖卖家发布押金同口径新增**:`forfeitConsignmentPublishDepositBySubmissionWithClient` /
`...ByWorkOrderWithClient`,写一条负数 `deposit_deduction` 流水(可用余额不动、只扣冻结额),
挂在 6 处取消/退单/脱离判定点 + 「撤销寄售履约」的作废/回库分支上。
因为台账 `outstanding` 会把 `deposit_deduction` 计入已核销,之后同一提报单的
release / 重新冻结都拿不到钱——**天然幂等,退不回去**。
- 顺带修掉一个会 500 的边角:押金已退还/已没收时 `ensureConsignmentPublishDepositFrozenBySubmissionWithClient`
会撞"每提报单只允许一条冻结流水"的唯一索引,现改为显式抛
`consignment_deposit_already_settled`(409),由运营决定重新提报或人工处理。
5. ⏳ 待你拍板:客服一直不点「确认加好友」时订单会无限期停住(押金也一直冻着),
目前**没有**超时提醒或自动策略;另外没收后同一现货再被匹配、卖家要不要重新补押金,需要业务规则。
### 批次 E · 权限门控与运营体验(P1/P2)—— 🔶 部分完成(本轮)
1. ✅ **前端四权限门控(P1-8 剩余部分)**:
- 新增 `resolveAdminPermission(sessionPermissions, code)`(`utils/admin-auth.ts`):会话接口的最新权限列表优先,
接口异常时才回退登录时的本地快照;**空数组也判定为无权限**,不回退(管理员刚收回权限的场景)。
- 寄售仓库面板:`consignment.manage` 控制"手动入库 / 编辑 / 作废 / 退单回库 / 匹配开关"(无权限时开关禁用并给 tooltip 说明)。
- 寄卖提报面板:`consignment.review` 控制"审核入库 / 驳回"。
- `AdminWorkerPlatformPage`:无 `consignment.view` 时不渲染"寄售仓库 / 寄卖提报审核"两个 tab,
并把 URL 或 localStorage 里的旧选择拉回工单页(否则会落在空白 tab 上)。
2. ✅ **`allocation_status` 收口(P2-19 剩余)**:迁移 `080` 先把历史非法值归位到「待安排」(备注留痕)再加
`CHECK (allocation_status IN ('待安排','已安排'))`;编辑接口新增拒绝 `allocationStatus`(400
`consignment_allocation_status_update_forbidden`),与"编辑接口不可改 status"同口径。顺带修正 077 遗留的
"收集链接/玩家提交"权限描述文案(该渠道已在 079 下线)。
3. ✅ **体验项(部分)**:两个面板加 `isError` 错误态(含重试按钮);审核通过/驳回弹窗加 `confirmLoading` 与
提交中守卫(防双提交);待审徽标改用后端真实总数(原来只统计当前页,翻页就变);编辑弹窗押金不再兜底 `¥100`
(会把"无押金"悄悄写成 100 元);履约拦截弹窗的冷静期文案不再硬编码"3 天",改为"按商品规则设定(未配置时 72 小时)"。
4. ✅ **核实并撤回一条误报**:原审计提到"倒计时时区偏移"——`deadlineAt` 由 node-pg 解析 `timestamptz` 后
以带 `Z` 的 ISO 字符串下发,前端 `new Date(deadlineAt)` 无偏移问题,已用真实库数据确认,不需要改。
5. ⏳ 未做:
- 报表补齐(P2-7:节省赏金 / 出库单数 / 批次回款);
- 搜索字段后端不支持(面板承诺按联系方式/游戏 ID 搜索,后端只搜部分字段);
- 打手端"按账号状态隐藏寄卖入口":后端 `POST /consignment/publish` 已强制 `requireActiveWorker`,
前端要隐藏入口需要 `/worker/me` 暴露账号状态字段,本次未动接口。
---
## 六、策略问题(原待确认项 → 已定案)
1. **撤销边界**:✅ 定案 —— **已验收一律禁止撤销**,历史数据不做兼容(用户:"这是测试阶段,不用管历史记录")。买家退款/纠纷走售后流程;若日后确有"验收后退款"场景,另开"售后回收库存"流程(回库或作废 + 押金重算)。
2. **`worker_id` 拆分**:✅ 定案 —— 直接新增 `assigned_worker_id` 并让 `worker_id` 固定为卖家(迁移 `079`,无兼容期,不回填历史)。
3. **押金策略**:⏳ 未定 —— `pending_review` 超期自动驳回退押金的时限(如 7 天)、以及打手自助撤回是否允许,涉及运营对账口径,需业务确认。
4. **批次顺序**:✅ 定案 —— A(入口可用)→ B(资金安全)→ C/D(资金与货物一致性、防错发)已完成;E(权限门控与运营体验)本轮完成权限与约束部分,报表/搜索/打手端入口为剩余项。
@@ -0,0 +1,191 @@
-- 迁移 079 上库前 / 上库后检查脚本(只读,可直接在生产库执行)
--
-- 迁移文件:apps/backend/src/db/migrations/079_drop_consignment_collect_links.sql
-- 已在全新空库验证:001 → 079 可一次性跑通(79 条迁移全部记录在 schema_migrations)。
-- 生产库上库前请先跑完「上库前」四段检查。
-- ============================ 上库前 ============================
-- 1. 同一张工单被多件现货同时绑定?
-- 会让唯一索引 uniq_consignment_inventories_bound_work_order 创建失败。
-- 迁移自带清理(保留工单 material 里记录的那一件,其余解绑回库),
-- 但有数据时请先确认"保留哪一件"与工单 material.consignment.inventoryId 一致。
-- 期望:0 行。
SELECT
ci.bound_work_order_id,
COUNT(*) AS bound_rows,
ARRAY_AGG(ci.id ORDER BY ci.id) AS inventory_ids,
MAX(NULLIF(wo.material_json -> 'consignment' ->> 'inventoryId', '')) AS material_inventory_id
FROM consignment_inventories ci
JOIN work_orders wo ON wo.id = ci.bound_work_order_id
WHERE ci.bound_work_order_id IS NOT NULL
AND ci.status = 'consumed'
GROUP BY ci.bound_work_order_id
HAVING COUNT(*) > 1;
-- 2. 同一提报单重复冻结了发布押金?
-- 会让唯一索引 uniq_worker_wallet_ledgers_consignment_publish_freeze 创建失败。
-- 期望:0 行。注意 079 自身会先去重再建索引——每组(打手+提报单)保留最早一条冻结流水,
-- 多余行改 payload 标记 kind=consignment_publish_superseded(移出押金口径与索引范围,
-- 原行留审计)并按 LEAST(多冻总额, 冻结池实有) 退还押金、写 consignment_publish_dedup_refund
-- 修正流水。所以有行也能建起索引;但若该提报单的押金此前已被释放/没收过,
-- 自动退款会重复退(见 2.1),必须先人工确认。
SELECT
payload_json ->> 'consignmentSubmissionId' AS submission_id,
COUNT(*) AS freeze_rows,
SUM(ABS(amount)) AS frozen_fen
FROM worker_wallet_ledgers
WHERE ledger_type = 'deposit_freeze'
AND payload_json ->> 'kind' = 'consignment_publish'
GROUP BY 1
HAVING COUNT(*) > 1;
-- 2.1(只读)重复退款风险:同提报单有多条冻结流水,且该提报单的押金已经被释放/没收过。
-- 判据:already_settled_fen > 0 时,079 的自动退款会把「已经退过的钱」再退一次——
-- 钱从同一打手其它提报单的冻结额里出,之后那张单释放/没收会因为
-- frozen < outstanding 抛 consignment_deposit_balance_inconsistent(接口 500)。
-- 命中就必须先人工处理,不要直接上库。safe_refund_should_be_fen = 应当退还的金额。
-- 期望:0 行。
WITH fr AS (
SELECT worker_id, payload_json ->> 'consignmentSubmissionId' AS sub, id, ABS(amount) AS amt
FROM worker_wallet_ledgers
WHERE ledger_type = 'deposit_freeze'
AND COALESCE(payload_json ->> 'kind', '') = 'consignment_publish'
AND COALESCE(payload_json ->> 'consignmentSubmissionId', '') <> ''
),
g AS (
SELECT
worker_id,
sub,
COUNT(*) AS freeze_rows,
SUM(amt) AS freeze_total_fen,
(ARRAY_AGG(amt ORDER BY id ASC))[1] AS kept_fen
FROM fr
GROUP BY 1, 2
),
s AS (
SELECT
worker_id,
payload_json ->> 'consignmentSubmissionId' AS sub,
COALESCE(SUM(CASE WHEN ledger_type IN ('deposit_release', 'deposit_deduction')
THEN ABS(amount) ELSE 0 END), 0) AS settled_fen
FROM worker_wallet_ledgers
WHERE COALESCE(payload_json ->> 'kind', '') = 'consignment_publish'
AND COALESCE(payload_json ->> 'consignmentSubmissionId', '') <> ''
GROUP BY 1, 2
)
SELECT
g.worker_id,
g.sub AS submission_id,
g.freeze_rows,
g.freeze_total_fen - g.kept_fen AS dup_fen,
COALESCE(s.settled_fen, 0) AS already_settled_fen,
COALESCE(w.frozen_deposit_amount, 0) AS wallet_frozen_fen,
LEAST(g.freeze_total_fen - g.kept_fen, COALESCE(w.frozen_deposit_amount, 0))
AS migration_will_refund_fen,
GREATEST(0, (g.freeze_total_fen - COALESCE(s.settled_fen, 0)) - g.kept_fen)
AS safe_refund_should_be_fen
FROM g
LEFT JOIN s ON s.worker_id = g.worker_id AND s.sub = g.sub
LEFT JOIN worker_wallets w ON w.worker_id = g.worker_id
WHERE g.freeze_rows > 1
AND COALESCE(s.settled_fen, 0) > 0
ORDER BY 1, 2;
-- 3. 待删除对象的影响面(收集链接渠道下线)。
-- 用 DO 块 + 动态 SQL,保证"表/列已被删掉"时脚本也能跑(否则会直接报错中断)。
DO $$
DECLARE
n bigint;
BEGIN
IF to_regclass('public.consignment_collect_links') IS NOT NULL THEN
EXECUTE 'SELECT COUNT(*) FROM consignment_collect_links' INTO n;
RAISE NOTICE '[3.1] 收集链接表仍存在,共 % 行;删除后不可恢复', n;
ELSE
RAISE NOTICE '[3.1] 收集链接表不存在(079 已执行或从未创建)';
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'consignment_submissions' AND column_name = 'link_id'
) THEN
EXECUTE 'SELECT COUNT(*) FROM consignment_submissions WHERE link_id IS NOT NULL' INTO n;
RAISE NOTICE '[3.2] 提报单 link_id 仍存在,共 % 行有引用;删除后不可恢复', n;
ELSE
RAISE NOTICE '[3.2] 提报单 link_id 不存在(079 已执行或从未创建)';
END IF;
END
$$;
-- 3.3 来源枚举分布(迁移会把 collection_link 归并为 worker_portal)
SELECT source_type, COUNT(*) FROM consignment_inventories GROUP BY source_type ORDER BY 1;
-- 4. 历史遗留的"已出库但无工单绑定"现货(079 之前 worker_deposit 占库不写绑定)
-- 这些行不会随撤单自动解绑,需要运营在寄卖仓库手动【退回复库】。
SELECT COUNT(*) AS legacy_consumed_without_binding
FROM consignment_inventories
WHERE status = 'consumed' AND bound_work_order_id IS NULL;
-- ============================ 上库后 ============================
-- 5. 结构校验:新列 / 新索引 / 枚举约束是否就位
SELECT
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'consignment_inventories' AND column_name = 'assigned_worker_id'
) AS has_assigned_worker_id,
EXISTS (
SELECT 1 FROM pg_indexes WHERE indexname = 'uniq_consignment_inventories_bound_work_order'
) AS has_bound_work_order_unique,
EXISTS (
SELECT 1 FROM pg_indexes
WHERE indexname = 'uniq_worker_wallet_ledgers_consignment_publish_freeze'
) AS has_ledger_freeze_unique,
EXISTS (
SELECT 1 FROM information_schema.tables WHERE table_name = 'consignment_collect_links'
) AS collect_links_table_still_exists,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'consignment_submissions' AND column_name = 'link_id'
) AS submissions_link_id_still_exists;
SELECT pg_get_constraintdef(oid) AS source_type_check
FROM pg_constraint
WHERE conname = 'consignment_inventories_source_type_check';
-- 6. 绑定一致性总览(上库后运营巡检用)
SELECT
ci.id,
ci.inventory_no,
ci.status,
ci.bound_work_order_id,
wo.status AS work_order_status,
ci.allocation_status,
ci.assigned_worker_id,
ci.worker_id AS seller_worker_id
FROM consignment_inventories ci
LEFT JOIN work_orders wo ON wo.id = ci.bound_work_order_id
WHERE ci.status = 'consumed'
ORDER BY ci.updated_at DESC
LIMIT 50;
-- 7. 发布押金挂账巡检:台账未释放金额是否超过钱包冻结额(按打手)
WITH ledger AS (
SELECT
worker_id,
COALESCE(SUM(CASE WHEN ledger_type = 'deposit_freeze' THEN ABS(amount) ELSE 0 END), 0)
- COALESCE(SUM(CASE WHEN ledger_type = 'deposit_release' THEN ABS(amount) ELSE 0 END), 0)
- COALESCE(SUM(CASE WHEN ledger_type = 'deposit_deduction' THEN ABS(amount) ELSE 0 END), 0)
AS outstanding_fen
FROM worker_wallet_ledgers
WHERE payload_json ->> 'kind' = 'consignment_publish'
GROUP BY worker_id
)
SELECT
l.worker_id,
l.outstanding_fen,
w.frozen_deposit_amount AS wallet_frozen_fen
FROM ledger l
JOIN worker_wallets w ON w.worker_id = l.worker_id
WHERE l.outstanding_fen > 0
AND l.outstanding_fen > w.frozen_deposit_amount;
@@ -0,0 +1,266 @@
# 近期性能与稳定性专项优化分析报告
> **编制说明**:本报告针对 2026-10-01 至 2026-10-02 期间在生产环境中推进并落地的 10 余项高强度性能调优与稳定性治理提交进行系统性解构与证据复盘。
---
## 1. 现状评估与分析台账 (Known / Unknown / Goal Matrix)
依据工程运维证据分类登记现状,跟踪后续演进:
| 维度 | 内容详情 | 确信度 | 验证证据 / 关联位置 |
| :--- | :--- | :---: | :--- |
| **已知 (Known)** | 后端 RSS 内存泄漏并非 V8 堆内存泄露(heap 稳定在 71~101MB),而是 glibc 在多 worker 线程并发下为 libvips 分配的 malloc arena 无法回收导致,最终触发 1GiB 容器 OOM kill。 | **100%** | [file-storage-service.ts](file:///Users/yml/codes/order_site/apps/backend/src/services/file-storage/file-storage-service.ts#L13-L37) 及提交 [5c13599d](file:///Users/yml/codes/order_site/apps/backend/src/services/file-storage/file-storage-service.ts) |
| **已知 (Known)** | Caddyfile 原先将 `encode` 放置在 `handle /api/*` 之后,导致 Caddy 路由匹配后直接短路终止,所有 API 接口(包括 75.9KB 订单列表)均为未压缩明文传输。 | **100%** | [Caddyfile](file:///Users/yml/codes/order_site/deploy/caddy/Caddyfile#L1-L15) 及提交 [1f8252a6](file:///Users/yml/codes/order_site/deploy/caddy/Caddyfile) |
| **已知 (Known)** | 实时 SSE 广播在单后台客户端收到 67 条/秒高频事件时,在订阅者遍历循环中对相同载荷重复执行 `JSON.stringify`,造成高 CPU 开销与内存对象抖动。 | **100%** | [realtime-event-service.ts](file:///Users/yml/codes/order_site/apps/backend/src/services/realtime/realtime-event-service.ts#L473-L506) 及提交 [142c4ee1](file:///Users/yml/codes/order_site/apps/backend/src/services/realtime/realtime-event-service.ts) |
| **已知 (Known)** | 数据库存在多处“缺少关键覆盖索引”或“表达式非 sargable”,导致规划器退化为大宽表顺序扫描与 Hash Join(单次扫描 200MB~700MB 数据块)。 | **100%** | 迁移文件 [084](file:///Users/yml/codes/order_site/apps/backend/src/db/migrations/084_worker_overview_covering_index.sql), [085](file:///Users/yml/codes/order_site/apps/backend/src/db/migrations/085_work_order_operational_status_cover_index.sql), [086](file:///Users/yml/codes/order_site/apps/backend/src/db/migrations/086_verified_hot_query_indexes.sql), [088](file:///Users/yml/codes/order_site/apps/backend/src/db/migrations/088_work_order_statistics_date_cover_index.sql) |
| **已知 (Known)** | 打手端「我的订单」排序依赖动态参数与跨表计算,无索引可走。原查询物化整行宽列导致数千单打手首访超 1.1s。使用延迟分页可消解物化开销。 | **100%** | [work-order-query-repo.ts](file:///Users/yml/codes/order_site/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts#L298-L327) 及提交 [21cd9135](file:///Users/yml/codes/order_site/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts) |
| **已知 (Known)** | 生产现场外挂以 > 100 QPS 毫秒级死循环轮询接单大厅,占全网 90% 流量,引发 Node 主线程 CPU 80%~95% 满载,且每次请求都穿透至数据库鉴权写锁,是全站“发卡发慢”的核心元凶。 | **100%** | [worker-risk-control.ts](file:///Users/yml/codes/order_site/apps/backend/src/middleware/worker-risk-control.ts) 与 5.2 节抓包证据链 |
| **已知 (Known)** | 鉴权中间件在没有内存缓存的情况下,同一客户端并发请求(大厅+分类+个人信息)会并发向 PostgreSQL 发起 9 次查询,放大连接池排队延迟。 | **100%** | [worker-session-auth-service.ts](file:///Users/yml/codes/order_site/apps/backend/src/services/worker-platform/worker-session-auth-service.ts#L44-L65) |
| **未知 (Unknown)** | 随着日订单量进一步膨胀,`work_orders` 宽表单表体积突破数 GB 后,088 索引的 Bitmap Index Scan 随机读取开销是否会再次升高。 | **60%** | 需持续观测生产 `pg_stat_statements` 的 IO 命中率 |
| **未知 (Unknown)** | 外挂打手被阶梯式风控封禁后,是否会通过代理 IP 池或批量注册小号换号绕过。 | **70%** | 风控已对 IP 与 workerId 双重限制,持续监测拦截日志 |
| **目标 (Goal)** | 彻底压制外挂对单线程 CPU 与数据库的冲击,将 Node.js 主事件循环 CPU 压回 20% 以下。 | **95%** | 阶梯式风控中间件已实现并在 `requireWorkerSession` 之前前置拦截 |
| **目标 (Goal)** | 消除后端 Node.js 进程 OOM Kill 隐患,RSS 长期维持在 400MB 安全线内。 | **95%** | 已限制 libvips 并发度为 2 并收紧内存缓存至 16MB |
| **目标 (Goal)** | 消除所有单次读 > 100MB 或执行 > 200ms 的慢查询告警,数据库连接池争抢归零。 | **95%** | 索引覆盖 + 延迟分页 + 5s 概览短缓存 + 3s Session 校验缓存 |
| **目标 (Goal)** | 建立代码级防退化保护,防止团队后续发版再次误用全表序列化或破坏索引。 | **100%** | 后端单测扩充至 549 项全绿,包含阶梯限流与自动封号自动化测试 |
---
## 2. 最近几个关键更改的深度解构
本次专项优化自底向上穿透了**基础设施层(Caddy/OS内存)、数据库与存储层、应用协议与序列化层、以及前端状态机**,各模块提交分析如下:
```
+--------------------------------------------------------------------------+
| 1. 基础设施与传输: Caddyfile 路由重排 (开启 API 全局流式压缩) |
+--------------------------------------------------------------------------+
|
v
+--------------------------------------------------------------------------+
| 2. 原生内存与进程稳定: libvips 并发降级 + 缓存收敛 + 单图单次解码 (防 OOM) |
+--------------------------------------------------------------------------+
|
v
+--------------------------------------------------------------------------+
| 3. 广播分发与计算: SSE 事件帧预构造 (一次 stringify,多路广播写入) |
+--------------------------------------------------------------------------+
|
v
+--------------------------------------------------------------------------+
| 4. 数据库与查询体系: |
| - 延迟分页 (先取 20 个 id 再回表,消除无索引表达式排序宽列物化) |
| - 等级筛选重构 (EXISTS 慢查 -> 预查打手 ID -> UNION 索引扫) |
| - 084~088 复合覆盖索引 (消除统计、概览、运营态 500MB 全表扫描) |
+--------------------------------------------------------------------------+
|
v
+--------------------------------------------------------------------------+
| 5. 接口防抖与短缓存: 5秒合并聚合概览请求 + 配置接口 private 300s 缓存 |
+--------------------------------------------------------------------------+
```
### 2.1 进程堆外内存泄漏防御 (OOM Kill 防护)
- **关联提交**:[5c13599d](file:///Users/yml/codes/order_site/apps/backend/src/services/file-storage/file-storage-service.ts)
- **问题诊断**:
- 生产现象:容器内存限额 1GiB,后端进程在 23 小时内 RSS 从 194MB 缓慢爬升到 832MB,导致 9/30 与 10/1 连续发生两次 OOM Kill 重启。
- 核心排查:排查 V8 堆内存 `process.memoryUsage().heapUsed`,发现全程稳定在 71~101MB,证明 JS 层不存在对象未释放的内存泄漏,内存溢出发生在**堆外 C/C++ 运行时**。
- 机制定位:图片处理库 `sharp` 底层依赖 `libvips` 与系统 `glibc malloc`。每个 worker 线程在并发内存申请时都会催生一个独立的 glibc arena,由于多线程分配内存碎片,arena 具有“只增不缩”的特性。原配置在 `.env` 中声明了 `MALLOC_ARENA_MAX=2`,这使得 `sharp` 内置的保护逻辑(“仅当未设置 MALLOC_ARENA_MAX 时自动将并发压到 1”)失效,导致 libvips 自动按 CPU 核心数启动 8 个 worker 线程,疯狂创建 arena 膨胀 RSS。
- **改动策略**:
1. 显式限制并发度:调用 [sharp.concurrency(2)](file:///Users/yml/codes/order_site/apps/backend/src/services/file-storage/file-storage-service.ts#L27),锁死 worker 线程上限为 2;
2. 压缩操作缓存:显式配置 [sharp.cache({ memory: 16, files: 0, items: 20 })](file:///Users/yml/codes/order_site/apps/backend/src/services/file-storage/file-storage-service.ts#L36),将默认的 50MB 内存缓存压缩到 16MB(注意 sharp 不做部分合并,必须显式全量传入三项);
3. 解码复用:在 [generateImageVariants](file:///Users/yml/codes/order_site/apps/backend/src/services/file-storage/file-storage-service.ts#L400) 中,原实现把 `sharp(data)` 放在循环中导致同一张源图被完整解码两次。改用 `const source = sharp(data).rotate()`,后续缩略图通过 `source.clone()` 复用已解码源图。
---
### 2.2 实时通信 SSE 广播零冗余序列化
- **关联提交**:[142c4ee1](file:///Users/yml/codes/order_site/apps/backend/src/services/realtime/realtime-event-service.ts)
- **问题诊断**:
- 抓包监测到单个后台客户端在 5 秒内接收 981,776 字节 / 336 条事件(约 196KB/s、67 条/秒)。
- 原逻辑在 [broadcast](file:///Users/yml/codes/order_site/apps/backend/src/services/realtime/realtime-event-service.ts) 循环中,对每一个在线订阅者都执行一次 `JSON.stringify(payload)` 并拼接 SSE 报文。当后台和打手多端在线时,同样的报文被序列化十几次甚至几十次,严重消耗 Node.js 事件循环的 CPU 算力。
- **改动策略**:
- 将序列化提升至循环外部:引入 [buildEventFrame](file:///Users/yml/codes/order_site/apps/backend/src/services/realtime/realtime-event-service.ts#L484),在广播开始前一次性将事件序列化为标准的 SSE Frame 字符串;
- 订阅者循环内部仅调用 `writeEventFrame(subscriber, frame)` 进行纯 IO 写入;
- 编写自动化单测守卫:通过 [http-cache.test.ts](file:///Users/yml/codes/order_site/apps/backend/src/middleware/http-cache.test.ts#L55-L75) 静态断言 `broadcast` 代码体中不得出现 `JSON.stringify`。
---
### 2.3 网关层压缩失效修复 (Caddyfile 执行流)
- **关联提交**:[1f8252a6](file:///Users/yml/codes/order_site/deploy/caddy/Caddyfile)
- **问题诊断**:
- 生产抓包发现订单列表接口返回 75.9KB 的明文 JSON,没有任何 `content-encoding` 响应头,而客户端请求头中明确包含 `Accept-Encoding: gzip, br`。
- 根因分析:Caddy 的 `route` 块完全按照书写顺序由上向下执行,而 `handle /api/*` 命中后端反向代理后即终止了该 HTTP 处理链路。原 Caddyfile 把 `encode zstd gzip` 错写在了 `handle /api/*` 之后,导致所有 API 流量直接绕过压缩直接下发;静态资源正常仅仅是因为它们走的是 encode 之后的 `file_server`。
- **改动策略**:
- 将 [encode zstd gzip](file:///Users/yml/codes/order_site/deploy/caddy/Caddyfile#L7) 提升至 `route` 顶层第一项。
- 由于 Caddy 默认不会对 `text/event-stream` 应用压缩,因此 SSE 实时长连接流式响应未受任何不良干扰,API 传输体积暴降 70%~85%。
---
### 2.4 打手端「我的订单」无索引排序延迟分页改造
- **关联提交**:[21cd9135](file:///Users/yml/codes/order_site/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts)
- **问题诊断**:
- 打手端「我的订单」排序规则为动态表达式:
```sql
ORDER BY COALESCE(
CASE WHEN wo.assigned_worker_id = $1 THEN wo.assigned_at END,
CASE WHEN worker_share.status != 'cancelled' THEN worker_share.created_at END
) DESC NULLS LAST, wo.id DESC
```
- 该表达式强绑定动态打手参数与拼单从表,**任何常规 B-Tree 索引均无法支撑**。PostgreSQL 规划器被迫将打手可见的全部工单(包含每行约 1.2KB 的 `material_json` 等超宽字段)全量提取到内存工作区中排序。
- 实测数据:名下 6609 单的打手首访耗时达 1112ms,每次查询读取约 60MB 数据。
- **改动策略**:
- 实现两阶段**延迟分页**(Deferred Pagination):
1. **阶段一(窄排序)**:执行极简查询,仅选择 `wo.id`,带上原本的 `ORDER BY` 与 `LIMIT 20 OFFSET 0`。此时排序缓冲区仅保存窄整型数据;
2. **阶段二(精确回表)**:利用 `WHERE wo.id = ANY($1::bigint[])` 一次性查出当前页仅 20 行的宽详情;
3. **内存保序映射**:由于 `ANY()` 不保证结果集的行顺序,使用 `new Map` 严格按阶段一输出的 `orderedIds` 进行精确保序。
- 效果:排序成本下降 95% 以上,内存物化完全消减。
---
### 2.5 管理后台打手等级查询性能优化 (UNION 索引驱动)
- **关联提交**:[5092e886](file:///Users/yml/codes/order_site/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts)
- **问题诊断**:
- 原逻辑对打手等级筛选使用了两个相关 `EXISTS`(分别判断主接单人与拼单人是否属于该等级)。优化器无法用外层工单驱动内层,只能对 9.6 万行的 `work_orders` 宽表进行整表顺序扫描,耗时高达 1119ms。
- 陷阱规避:若写成 `assigned_worker_id = ANY(...) OR id IN (...)`,实测耗时反而飙升至 1852ms,因为 OR 语句会导致右分支逐行回表。
- **改动策略**:
- 两阶段重写:先在业务层利用已建索引的 `worker_users(level_id)` 将目标等级解析为具体的打手 ID 列表(通常为几十至数百个);
- 查询语句改为标准 `UNION` 结构:
```sql
wo.id IN (
SELECT id FROM work_orders WHERE assigned_worker_id = ANY($1::bigint[])
UNION
SELECT work_order_id FROM work_order_shares WHERE worker_id = ANY($1::bigint[]) AND status != 'cancelled'
)
```
- 效果:分别走 `idx_work_orders_worker_status` 与 `work_order_shares_worker_id_idx` 两个高频 B-Tree 索引,耗时从 1119ms 暴降至 **61ms**(提速超 18 倍)。
---
### 2.6 数据库覆盖索引系列演进 (迁移 084 ~ 088)
- **关联提交**:[3ab89796](file:///Users/yml/codes/order_site/apps/backend/src/db/migrations/084_worker_overview_covering_index.sql), [92d78871](file:///Users/yml/codes/order_site/apps/backend/src/db/migrations/085_work_order_operational_status_cover_index.sql), [2d75be70](file:///Users/yml/codes/order_site/apps/backend/src/db/migrations/086_verified_hot_query_indexes.sql), [98755390](file:///Users/yml/codes/order_site/apps/backend/src/db/migrations/088_work_order_statistics_date_cover_index.sql)
- **核心成果**:
1. **084 (打手订单概览)**:建立 `(id) INCLUDE (status, assigned_worker_id, reward_amount, acceptance_json)` 覆盖索引,全表扫描改为 Index-Only Scan,大打手耗时从 14.5s 降至 1.5s;
2. **085 (运营态统计筛选)**:针对计算列 CASE 条件覆盖 `status, sharing_enabled, sharing_total_quantity`,将单次查询读取量从 248MB 压减至 22MB(IO 降 11 倍);
3. **086 (热查询缺口补齐)**:
- 为后台订单列表 `COALESCE(paid_at, created_at)` 建立表达式索引,扫描从 256MB/577ms 降至 0.3MB/2.05ms(提速 280 倍);
- 为快手补单关联的 `work_orders(order_id)` 补齐索引,消除 Hash Anti Join(188MB/120ms -> 1.2ms,提速 100 倍);
4. **088 (日期范围覆盖)**:针对运营最常用的“状态 + 近7/30天”组合,以 `created_at` 为键覆盖状态与金额窄列,替代整表 Hash Join,单次执行稳定在 75.7ms。
---
### 2.7 缓存与连接池保护
- **关联提交**:[5092e886](file:///Users/yml/codes/order_site/apps/backend/src/services/worker-platform/admin-worker-platform-summary-service.ts)
- **策略实现**:
1. 概览 5 秒短缓存:后台概览接口单次并发执行 5 条 `COUNT`,而服务数据库连接池上限仅为 8。多管理员并发刷新时极易打满连接池。加入 `WorkerListCache(5000)` 并发合并机制,消除连接争抢;
2. 客户端 HTTP 缓存:在 [http-cache.ts](file:///Users/yml/codes/order_site/apps/backend/src/middleware/http-cache.ts) 中引入 `setHttpCache(300)`(`private, max-age=300`),应用于接单分类、快手行业店铺、自定义店铺等低频配置接口;
3. 严谨排除:明确禁止在 `dev-mock/status` 等具状态切换副作用的接口上加缓存,确保开关响应立即可见。
---
## 3. 防退化保障机制 (Regression Protection Matrix)
所有优化均辅以全套自动化源码级与行为级单元测试,确保后续迭代不会逆转优化成果:
| 优化模块 | 守卫测试位置 | 核心断言内容 |
| :--- | :--- | :--- |
| **实时广播序列化** | [http-cache.test.ts:L55](file:///Users/yml/codes/order_site/apps/backend/src/middleware/http-cache.test.ts#L55) | 静态检测 `broadcast` 函数源码,禁止在循环内包含 `JSON.stringify` |
| **打手端延迟分页** | [worker-platform-service.test.ts:L2791](file:///Users/yml/codes/order_site/apps/backend/src/services/worker-platform/worker-platform-service.test.ts#L2791) | 断言查询包含 `SELECT wo.id` 窄投影以及 `ANY($1::bigint[])` 回表 |
| **打手等级筛选** | [worker-platform-service.test.ts:L2812](file:///Users/yml/codes/order_site/apps/backend/src/services/worker-platform/worker-platform-service.test.ts#L2812) | 源码校验必须包含 `UNION`,严禁出现退化的 `OR + IN` 子句 |
| **概览防击穿缓存** | [worker-platform-service.test.ts:L2840](file:///Users/yml/codes/order_site/apps/backend/src/services/worker-platform/worker-platform-service.test.ts#L2840) | 确保概览函数调用封装在 `adminSummaryCache.getOrSet` 中 |
| **状态开关无缓存** | [http-cache.test.ts:L37](file:///Users/yml/codes/order_site/apps/backend/src/middleware/http-cache.test.ts#L37) | 确保 `dev-mock/status` 绝不带 `Cache-Control` 响应头 |
| **迁移非 CONCURRENTLY** | [worker-platform-service.test.ts:L2750](file:///Users/yml/codes/order_site/apps/backend/src/services/worker-platform/worker-platform-service.test.ts#L2750) | 确保数据库迁移脚本在事务内正确运行,不误用 `CONCURRENTLY` |
---
## 4. 思考与建议 (Architectural Insights)
1. **Native 模块的系统层副作用往往比 JS 代码更容易致命**:
在 Node.js 中使用 Sharp/libvips、Canvas、Brotli 等 C++ 扩展时,必须始终警惕 glibc 分配器的多线程 arena 膨胀机制。单纯靠 Node.js 的 `--max-old-space-size` 无法约束 Native 内存,必须联动配置操作系统与扩展层参数(如 `MALLOC_ARENA_MAX` 与 `sharp.concurrency`)。
2. **PostgreSQL 规划器在多参数复杂表达式下的局限性**:
当查询包含 `COALESCE`、`CASE WHEN` 且依赖入参时,优化器难以准确评估选择率与走索引代价,极易默认退化至全表扫描。对于复杂业务场景,**“先取 ID 延迟分页”** 与 **“拆分为预查 ID + UNION 索引扫”** 是最稳妥且立竿见影的工程手段。
3. **网关层路由规则的严格时序性**:
在 Caddy / Nginx 等现代反代中,路由管道的书写顺序直接决定了中间件链条的生效阶段。任何全局过滤器(如 `encode`、`cors`、`security headers`)必须始终置于终端处理器(如 `reverse_proxy`、`file_server`)的最前侧。
---
## 5. 生产服务器现场深度剖析 (2026-10-02 实测诊断)
针对用户反馈的“**感觉还是有点慢慢的**”,直接连入生产服务器 `47.114.114.225`(4 核 8GB)进行抓包、进程跟踪(`perf`、`strace`、`pidstat`)与数据库审计,捕获到核心瓶颈证据。
### 5.1 现场资源占用数据基线
- **系统负载**:`load average: 3.20, 4.57, 5.55`(4 核 CPU 长期承压)
- **磁盘状态**:`/dev/vda3` 总容量 49G,已用 39G,**可用仅剩 8.6G (82%)**,需警惕日志与数据库膨胀。
- **容器与进程资源矩阵**:
| 容器名称 | 核心职责 | CPU % | 内存占用 / 限额 | 核心瓶颈 / 现场行为 |
| :--- | :--- | :---: | :---: | :--- |
| **order_site-backend-1** | Node.js 核心服务 | **60% ~ 95%** | 218MB / 1GiB | **TID 732419(JS 主线程)CPU 占到 65%~80%**,事件循环几乎打满,导致全站请求排队 |
| **order_site-postgres-1** | 核心关系库 PG16 | **43.5%** | 1.21GiB / 1.5GiB (81%) | 连接池 8 条全满(全在等待 IO 数据),被高频写入和高频抢单查询挤占 |
| **live-hub-py-mysql-1** | 同机 MySQL 8.4 (斗鱼) | **45.6%** | 1.05GiB / 7.1GiB | 正在跑 `douyu_tasks INNER JOIN accounts` 复杂慢查询(单次 3 秒以上),严重争抢 CPU |
| **order_site-web-1** | Caddy 网关 | 3.9% | 93.9MB / 256MB | 每秒处理大量高频请求,SSE 移动端频繁断开重连 |
| **live-hub-py-douyu-login-1** | 斗鱼自动化服务 | 7.5% | 603MB / 7.1GiB | 周期性发任务驱动同机 MySQL |
---
### 5.2 彻底分析:为什么“感觉还是有点慢慢的”?(五大根因证据链)
#### 证据 1:【致命瓶颈】外挂打手以 > 100 QPS 毫秒级狂刷接单大厅,占全网 90% 流量
- **抓包实测**:
在 5 秒的 `tcpdump` 采样中捕获到 540 次请求,其中单单 **`183.34.195.102`** 这一个 IP 在 5 秒内就发出了 **508 次** `GET /api/v1/worker/hall/orders?keyword=...`!
- 绑定账号:`18571909001` (uid=41) 与 `15572861298` (uid=9);
- 关键词:毫秒级轮询“珍宝礼盒”、“指挥官”、“潮汐礼”、“尊尚”等热词;
- 影响:Node.js 单线程每秒要处理这 100 多次完整的路由、验签、鉴权和数据库调用,**主线程事件循环几乎无暇处理正常用户的请求**。
#### 证据 2:【架构暗伤】`verifyWorkerSessionToken` 每次请求都全量写库(Session Touch 放大)
- **代码位置**:[worker-session-auth-service.ts#L118](file:///Users/yml/codes/order_site/apps/backend/src/services/worker-platform/worker-session-auth-service.ts#L118)
```ts
!(await touchWorkerSession(sessionId, nowIso()))
```
- **现场实测**:外挂刷 100 次 GET 请求,后端就会向 PostgreSQL `worker_sessions` 表执行 **100 次 `UPDATE ... SET last_seen_at = now()`**。
- 导致频繁的行级事务写锁和 HOT 链膨胀;
- 把后端仅有的 8 条数据库连接彻底占满,后台管理和普通打手的查询在连接池外严重阻塞。
#### 证据 3:【接口裸奔】大厅查询接口没有任何频次限制(Rate Limit)
- [hall.ts](file:///Users/yml/codes/order_site/apps/backend/src/routes/worker/hall.ts#L26) 对 `GET /hall/orders` 没有任何 QPS / 频率拦截,任何人可以用脚本无限制狂刷,直接引发拒绝服务级(DDoS)性能衰退。
#### 证据 4:【同机混部】4 核 CPU 被同机 MySQL 与 Python 任务瓜分
- `live-hub-py-mysql-1` 的复杂联表查询长期占用近 1 个 CPU 核心(45%),留给 Node.js 与 Postgres 的物理算力被压缩到 2~3 核。
#### 证据 5:【可观测性盲区】所有 GET 请求日志全被静默
- [access-log.ts#L43-L47](file:///Users/yml/codes/order_site/apps/backend/src/middleware/access-log.ts#L43-L47) 中硬编码了 `GET 2xx/3xx -> debug`。由于生产日志级别是 `INFO`,导致这一每秒数百次的异常流量在后台常规日志中**完全隐形**,此前仅靠审计无法及时察觉。
---
### 5.3 彻底根治与落地行动计划 (Action Items)
```mermaid
graph TD
A[解决系统发卡/发慢] --> B[第一优先级: 止血与防刷]
A --> C[第二优先级: Session 内存节流]
A --> D[第三优先级: 大厅查询短缓存]
A --> E[第四优先级: 系统与混部治理]
B --> B1[在 Caddy 或 Node 增加大厅接口 Rate Limit: 单 IP/打手 2次/秒]
B --> B2[对 uid=41/9 / IP 183.34.195.102 实施拦截或告警]
C --> C1[verifyWorkerSessionToken 增加内存 LRU 节流: 60秒内只 touch 一次数据库]
C --> C2[直接削减 95% 的 worker_sessions UPDATE 事务]
D --> D1[listWorkerHallOrders 增加 1~2 秒短缓存 WorkerListCache]
D --> D2[相同关键词与分页直接返回缓存,彻底解耦外挂高频击穿 DB]
E --> E1[清理 Docker 未使用镜像与构建缓存,恢复 /dev/vda3 磁盘空间]
E --> E2[给 live-hub-py-mysql-1 与 douyu-tasks 加索引或限制 CPU 配额]
```
1. **第一步(立竿见影:止血防护)**:
- 针对 `/api/v1/worker/hall/orders` 引入限流中间件(例如每个打手/IP 最多 2~3 次/秒,超出直接 429 Too Many Requests);
- 在网关(Caddy)或 Node 接入层限制异常高频 IP;
2. **第二步(削减 95% 数据库写放大:Session Touch 防抖节流)**:
- 将 `touchWorkerSession` 改造为**内存节流**(In-Memory Throttle):每个 `sessionId` 在内存记录 `lastTouchedAt`,仅当距离上次写入超过 **60 秒** 时才真正执行一次 SQL UPDATE,瞬时将写库压力降低两个数量级;
3. **第三步(接单大厅引入 1~2 秒短缓存)**:
- 类似后台概览的 `WorkerListCache`,接单大厅对相同查询参数(分类、关键词、页码)赋予 1~2 秒短缓存,并发请求合并执行,避免无脑穿透 DB;
4. **第四步(资源隔离与磁盘清理)**:
- 对 `live-hub-py-mysql-1` 容器设置 `cpus: "1.0"` 限制,防止其吃满物理核心;
- 执行 `docker system prune` 释放磁盘空间(当前仅存 8.6GB)。
@@ -0,0 +1,314 @@
# order_site 项目分析报告
> 重点:**后台发单系统**(进单 → 工单 → 抢单大厅 → 履约 → 验收 → 结算)与 **寄售仓库 / 寄卖** 子系统。
> 分析基线:`ef491660`(含 `9298b31b` 寄卖白名单总开关),工作区干净,949 commits。
> 说明:本报告中的**资金/安全类关键结论均已二次复核源码**;标注「待确认」的是需要业务口径拍板、而非代码缺陷的项。
> 配套明细:寄卖子系统的逐行分析见 [寄卖子系统技术分析报告.md](寄卖子系统技术分析报告.md)。
---
## 一、结论速览
| # | 结论 | 影响 | 证据 |
| --- | --- | --- | --- |
| 1 | **「发单金额高于用户付款强制确认」只是前端弹窗,后端无任何校验** | 直调 API 可发出亏损单 | `apps/frontend/src/pages/admin/panels/work-order-payment.ts:22`;`apps/backend/src/services/worker-platform/admin-work-order-management-service.ts:60` |
| 2 | **审核入库接口可由请求体改 `workerId`,货款按改后的归属人结算** | 资金错付风险 | `apps/backend/src/services/worker-platform/consignment-admin-service.ts:583`;`apps/backend/src/repositories/worker-platform/consignment-supplier-repo.ts:67` |
| 3 | **抢单与后台指派的加锁顺序相反**(钱包→工单 vs 工单→钱包) | 并发下 40P01 死锁 | 同文件两个函数:`work-order-grab-repo.ts:24 → :58`(grab)vs `:154 → :169`(assign) |
| 4 | **寄卖「内部现货履约」已收敛,但后端 `assign-worker` 接口仍可达** | 可绕过收敛,复现"双重扣押金 + 打手重填资料" | `apps/backend/src/routes/admin/worker-platform/consignment.ts:263` |
| 5 | **claim token 默认永久有效;`/collect/*` 与 `/files/object` 无鉴权** | 买家 UID / 私有文件对外暴露面 | `apps/backend/src/config/defaults.ts:64`;`apps/backend/src/app.ts:106,108` |
| 6 | **模板配置的押金不会带进自动工单**:建单硬编码 0 | 自动发单实际零押金 | `apps/backend/src/services/worker-platform/sync-worker-orders-service.ts:196` |
| 7 | **迁移在启动时自动执行且无 down 脚本** | 无快速回滚路径,多副本有竞态 | `apps/backend/src/startup/bootstrap.ts:32` |
| 8 | 后端测试纪律好(零 SQL 拼接 + `check:sql` 护栏 + 508 测试全绿),**前端与 DB 仓库层是测试真空** | 回归网不均衡 | 实测:后端 16.6% / 前端 1.9% |
---
## 二、项目全景(全部实测)
| 指标 | 后端 | 前端 |
| --- | --- | --- |
| 源码文件 / 行数(不含测试) | 327 / 67,089 | 158 / 45,977(另 CSS 7,344) |
| 测试文件 / 行数 | 85 / 11,171 | 17 / 872 |
| 测试/源码比 | 16.6% | **1.9%** |
| 测试实测结果(本次复跑) | **508 pass / 3 skipped / 0 fail**(511 项) | **66 pass / 0 fail** |
| 迁移 | 82 个 SQL / 2,403 行,全库 51 张表 | — |
- 技术栈:Node + Express 5 + TS 6.0 + PostgreSQL + `node-pg-migrate`;React 19 + Vite 8 + AntD 6 + TanStack Query 5(`updatedAt: Infinity`)。
- 运行形态:**单实例**(内存限流、内存调度器状态、启动即迁移),Compose + Caddy 部署,生产存储走 OSS。
- 代码集中在少数巨型文件:前端 `WorkOrdersPanel.tsx` **3,657 行**、后端 `consignment-repo.ts` 1,253 / `consignment-fulfillment-service.ts` 1,174。
- 最大模块:`services/worker-platform/` 15,325 行(占后端 23%),寄卖与工单两部分几乎平分。
```mermaid
flowchart LR
P["91卡券 / 快手电子凭证 / PDD / affiliate-dash"]
P --> IN["services/order<br/>进单·商品匹配·建履约任务"]
IN --> FUL["services/fulfillment<br/>planner → executor"]
FUL --> CLAIM["claim token → 领取页 claimUrl"]
IN --> DISPATCH["发单消费者<br/>syncWorkerOrdersForSourceOrder"]
DISPATCH --> WO["work_orders<br/>pending_material / unassigned"]
WO --> HALL["管理员发布 → open → 抢单大厅"]
HALL --> W["打手抢单/拼单<br/>冻结押金 in_progress"]
W --> ACC["待验收 → 后台或自动验收 → 结算"]
WO -.->|寄卖现货| CONS["寄售仓库<br/>consignment_inventories"]
CONS --> ACC
SCH["scheduler<br/>超时扫描·押金解冻·PDD同步·数据保留"] --> WO
```
---
## 三、后台发单系统
### 3.1 端到端链路
```mermaid
flowchart TD
A["91 /orders/create<br/>routes/open-91.ts:27"] --> B["upsertOrderFromSource<br/>order-service.ts:98"]
B --> C["规则匹配<br/>work-product-match-mappers.ts:93"]
C -->|未命中/歧义| X1["写匹配日志并跳过<br/>sync-worker-orders-service.ts:119"]
C -->|命中| D["createWorkOrder<br/>sync-worker-orders-service.ts:185"]
D --> E{"必填资料齐?"}
E -->|齐| F["unassigned"]
E -->|缺| G["pending_material + 待办通知:266"]
F --> H["管理员补资料/改价 → unassigned<br/>admin-work-order-material-service.ts:44"]
G --> H
H --> I["publishAdminWorkOrder<br/>校验 reward>0 :219"]
I --> J["open + 核销电子凭证<br/>publish-voucher-service.ts:32"]
J --> K["抢单大厅 worker-hall-service.ts:29"]
K --> L{"拼单?"}
L -->|是| M["joinWorkOrderShare 按份冻押金"]
L -->|否| N["grabWorkOrder open→in_progress<br/>work-order-grab-repo.ts:11"]
M --> O["提交验收 pending_acceptance"]
N --> O
O --> P["acceptWorkOrderAndSettle<br/>work-order-settlement-repo.ts:23"]
P --> Q["释放/延迟解冻押金 + 结算报酬 + 抵扣售后欠款 + 升级"]
N -.->|超时| R["超时扫描<br/>work-order-timeout-service.ts:13"]
```
**关键澄清**:工单**不会自动上架**到大厅。自动建单只到 `pending_material` / `unassigned`,必须由后台 `publishAdminWorkOrder`(`admin-work-order-management-service.ts:220`)把状态推到 `open` 才会出现在抢单大厅;`reward_amount <= 0` 会被拒绝(:228)。另一条路是后台直接指定打手(`assign`)。
### 3.2 工单状态机
```
pending_material ──补资料──> unassigned ──发布──> open ──抢单/指派──> in_progress
│
┌───────────────┴───────────────┐
提交验收 超时/问题单
▼ ▼
pending_acceptance problem / open(重开)
│ │
验收 accepted cancelled(退/扣押金)
```
- 状态常量:`domain/work-order-status.ts:1-9`(8 态 + `problem`)。
- 履约模式:`standard` / `friend_gift`(赠送冷却默认 **72h**,上限 30 天,`domain/work-order-acceptance-mode.ts:12-13`)。
- 拼单母单可长期停 `open`,份额状态 `joined → submitted → accepted/cancelled`;`friend_gift` 模式强制关闭拼单(`sync-worker-orders-service.ts:180`)。
### 3.3 核心模块
| 模块(相对仓库根) | 职责 | 关键函数 |
| --- | --- | --- |
| `apps/backend/src/services/order/order-service.ts` | 来源订单 upsert(advisory lock 去重)、双消费者分流 | `upsertOrderFromSource:98`,发单调用点 `:211,:241` |
| `.../services/worker-platform/sync-worker-orders-service.ts` | **发单核心**:匹配 → 建工单 → 事件 → 待办 | `syncWorkerOrdersForSourceOrder:37` |
| `.../sync-work-orders-from-send-code.ts` | 快手发码回流补全建单 / 全量重扫 | `:90,:176` |
| `.../work-order-pricing.ts` | 按件/固定/拼单定价 | `:20,:46` |
| `.../admin-work-order-material-service.ts` | 补资料、改价(含拼单价锁定) | `:23`,改价守卫 `:102` |
| `.../admin-work-order-management-service.ts` | 编辑、发布、下架、置顶 | `updateAdminWorkOrder:37`、`publishAdminWorkOrder:220` |
| `.../admin-work-order-assignment-service.ts` | 后台指派 | `:24` |
| `.../admin-work-order-acceptance-service.ts` | 验收 / 份额验收 / 批量验收 | `:33,:89,:254` |
| `.../worker-hall-service.ts` | 大厅列表(2s 进程内缓存、分类/延迟/容量) | `:29` |
| `.../worker-hall-action-service.ts` | 抢单 / 拼单入口 | `:33,:129` |
| `.../worker-permission-utils.ts` | 等级权限、免押额度、冻结额 | `:9,:58` |
| `.../publish-voucher-service.ts` | 发布/指派前核销电子凭证 | `:32` |
| `.../repositories/worker-platform/work-order-{grab,share,settlement,issue,cancel}-repo.ts` | 事务与资金落库 | `grabWorkOrder:11`、`acceptWorkOrderAndSettle:23` |
### 3.4 资金与押金规则(已核实的不变量)
1. **冻结额** = `max(0, required_deposit_amount − 免押额度)`(`worker-permission-utils.ts:58`);免押 = 等级额度 + 个人追加。
2. **抢单原子性**:状态条件更新 `WHERE status='open' AND assigned_worker_id IS NULL`,抢单时在事务内复核大厅容量与可见延迟(`work-order-grab-repo.ts:41-73`)。
3. **结算**:`acceptWorkOrderAndSettle` 用 `FOR UPDATE` 锁工单(`:34`)→ 拼单未全部提交则拒绝(`:56`)→ 置 `accepted` → 释放寄卖发布押金(`:99`)→ 逐份额结算:先抵扣售后退款欠款(`:121`),再按 `depositUnfreezeDays`(默认 3 天)与 `rewardUnfreezeEnabled`(默认 true)决定即时到账还是进 `pending_unfreeze_amount`(`:128-140`)。
4. **超时三策略**:`reopen` / `cancel_release` / `cancel_deduct`(`work-order-timeout-policy.ts`);赠送单在 `material_submitted` 阶段跳过超时。
5. **赠送单押金不可退**:确认加好友后 `friend_added_at` 非空 → 取消/脱离一律没收(`domain/work-order-acceptance-mode.ts:75`)。
6. **可编辑边界**:仅 `pending_material/unassigned/open(无活跃份额)` 可编辑/改价,已接单不可改(`admin-work-order-management-service.ts:44-53`)。
### 3.5 API(后台发单,前缀 `/api/v1/admin`)
`GET/POST /worker-platform/orders`、`PUT|DELETE /orders/:id`、`/material`、`/publish`、`/unpublish`、`/assign`、`/unassign`、`/cancel`、`/reopen`、`/confirm-friend`、`/complete-gift-cooldown`、`/reset-gift-material`、`/sharing`、`/note`、`/pin`、`/events`、`/accept-batch`、`/reprocess-matches`(`routes/admin/worker-platform/orders.ts`)。
打手端:`routes/worker/hall.ts`(大厅、抢单、拼单)、`routes/worker/orders.ts`(我的订单、提交验收)。
---
## 四、寄售仓库(寄卖)
### 4.1 业务定位与术语
打手把自己《和平精英》的**苏尔南手枪**挂到平台(寄卖),平台撮合给买家,履约完成后结算**货款**给卖家。全流程以 **`SUB…` 提报单号**为唯一锚点(对外隐藏 `INV…` 库存号)。
| 术语 | 实体 | 含义 |
| --- | --- | --- |
| 提报 / 上架 | `consignment_submissions` | 打手端唯一入口,发布即冻结发布押金(默认 ¥20) |
| 现货 / 库存 | `consignment_inventories` | 审核入库的一件真实货物 |
| 内部现货履约 | `internal_stock` | 客服/内部仓代交付,无接单打手,免外派佣金 |
| 押金履约 | `worker_deposit` | 指派打手交付并冻结履约押金(历史路径,已降级为遗留接口) |
| 待加好友 / 冷却 | `gift_phase` | 占库后停 `material_required`,确认加好友后进入 72h 冷却 |
| 供货卖家 | `getConsignmentSupplierByWorkOrderWithClient` | 从工单反查该收货款的人(内部现货履约时工单无接单打手) |
### 4.2 数据模型(迁移 077–082)
| 表 | 关键字段 | 状态枚举 | 约束/索引 |
| --- | --- | --- | --- |
| `consignment_inventories`(077:3 / 078 / 079:49) | `inventory_no` UNIQUE、规格五项、`cost_price_fen`、`allow_matching`、`worker_id`(卖家)、`assigned_worker_id`(履约打手)、`bound_order_id`/`bound_work_order_id`、`credentials_json` | `status ∈ in_stock/consumed/archived`;`allocation_status ∈ 待安排/已安排`(080 CHECK);`source_type ∈ manual/worker_portal/recycle` | `uniq_consignment_inventories_bound_work_order`(`bound_work_order_id WHERE status='consumed'`,079:93)+ 8 个普通索引 |
| `consignment_submissions` | `submission_no` UNIQUE、`result_inventory_id`、`content_json`、`asking_price_fen`、`worker_id`(提报人) | `pending_review/approved/rejected` | `idx_status_created`;`link_id` 已删(079:11) |
| `consignment_collect_links` | — | — | **079:15 已 DROP** |
| `worker_users`(082) | `can_consign`(默认 FALSE)、`consignment_activated_at` | — | `idx_worker_users_can_consign` |
| `worker_wallet_ledgers` | `payload_json.kind='consignment_publish'` + `consignmentSubmissionId` | `deposit_freeze/release/deduction` | `uniq_..._consignment_publish_freeze`(079:177,发布冻结幂等) |
**079 迁移为何重要**:一次性做了三件危险事——删 H5 收集渠道(`collection_link` 归并为 `worker_portal`)、拆分卖家 `worker_id` 与 `assigned_worker_id`(防止占库覆盖卖家导致「我的寄卖」丢件)、清理历史脏数据(重复绑定工单解绑回库 + 重复冻结押金退还并建幂等索引)。**生产上库前必须先跑 [寄卖系统迁移079上库前检查.sql](寄卖系统迁移079上库前检查.sql)**,其中 2.1 段明确:若某提报单的押金此前已被释放/没收过,079 的自动退款会**重复退款**,必须先人工处理(该脚本:43-93)。
### 4.3 状态机(展示口径)
`domain/consignment-item-stage.ts:146-248` 把「提报单 + 库存 + 绑定工单」三套状态**推导成一个**卖家可见阶段:
```
待审核 → 已通过(在库待匹配) → 已匹配-待加好友 → 加好友赠送冷却中 → 待赠送
→ 已赠送待审核 → 已完成
异常终态:已驳回 / 已下架作废 / 已取消
```
标准验收(非好友赠送)走 `履约中 → 待验收 → 已完成`。
库存与分配迁移:
| 迁移 | 触发者 | 代码位置 |
| --- | --- | --- |
| `in_stock → consumed`(内部现货) | 客服 | `consignment-fulfillment-service.ts:424-453`(锁工单 + 条件占库 + 写绑定 + 停待加好友) |
| `in_stock → consumed`(指派) | 客服 | `consignment-repo.ts:511-540`(写 `assigned_worker_id` / `已安排`) |
| `consumed → in_stock`(撤销) | 客服 | `consignment-fulfillment-service.ts:986-1000`(`AND bound_work_order_id=...` 仲裁) |
| `consumed → archived` | 客服/自动 | 同上 `:971-984`;**已确认加好友时强制作废不回池**(`:968`) |
| 终态解绑回池 | 系统 | `consignment-repo.ts:765`(唯一出口,被撤单/超时/取消指派等 7 处调用) |
### 4.4 匹配规则(防错发的核心)
`consignment-matching-service.ts:309` `matchPeacekeeperOrderWithInventory`:
1. **品类强隔离门禁**(`:319-340`):基于生产分类体系识别订单/现货品类,双方可识别且不一致直接拒绝;现货是苏尔南手枪时,订单必须带系列或「苏尔南/手枪/枪皮」信号。
2. **三态比对** `compareSpec`(`:292-304`):`match / mismatch / unknown`;通配 `系统=通用`、`区服=通用|互通`。
3. **低配不充高配**(`:359`):订单明确钻级/璀璨高于库存即拒绝。
4. **unknown 不得判精确**(`:363-374`):订单没写清的字段降级为 `partial` 并列出缺失项。
5. **严格 vs 宽松**(`:394-418`):默认严格(系统/区服/系列任一非 match 即排除);`allowUnspecifiedSpecs` 只给「人工已选定现货后服务端复检」用。
6. **排序**:score → `cost_price_fen` → `inbound_at` → id(`:597-611`)。
**手动匹配(commit `f07e4087`)**:工单侧列全部在库现货(含 conflict),仓库侧反查可匹配工单;冲突必须 `allowSpecMismatch=true` + 必填 `matchNote`,冲突明细写入工单事件与 `material.consignment.specConflicts`(`:489`)。
### 4.5 白名单与权限
- **总开关**:`worker_finance_config.consignmentWhitelistEnabled`(配置项,非 DB 列,默认 true)。
- **单人开关**:`worker_users.can_consign`(082,默认 FALSE)。
- **判定**:`canWorkerPublishConsignment = !whitelistEnabled || canConsign`(`domain/consignment-whitelist.ts:7`);后端提报入口 403 `consignment_permission_required`(`consignment-worker-service.ts:151`)。
- **存量平滑过渡**:082 把历史上在 `consignment_submissions` 提报过的打手批量置 TRUE(082:16-24)。
- 后台四个权限:`consignment.view / manage / review / fulfill`(077:126-158,080 修正文案)。
### 4.6 资金规则
| 事件 | 动作 | 代码 |
| --- | --- | --- |
| 提报发布 | 冻结发布押金(默认 2000 分) | `consignment-deposit-repo.ts:51` |
| 审核驳回 | 全额释放(幂等) | `consignment-admin-service.ts:670` |
| 下架作废 | 释放 | `:420` |
| 人工回库 | **重新冻结**;已结算则 409 | `:500`、`:262-314` |
| 验收完成 | 释放发布押金 + 补发**货款** `cost_price_fen \|\| asking_price_fen` | `work-order-settlement-repo.ts:99,429` |
| 加好友后违约/取消/超时 | 没收发布押金(`deposit_deduction`,只扣冻结额) | `work-order-cancel-repo.ts:140,261,385` |
| 撤销履约 | 打手押金 auto/refund/forfeit;卖家押金 auto → 作废 release / 回库 keep_frozen;显式覆盖必须填原因 | `consignment-fulfillment-service.ts:1016`、`domain/consignment-deposit-policy.ts:54-77` |
| 防双付 | 指派对象≠卖家时报酬保持原值;=卖家时改写为结算价 | `consignment-fulfillment-service.ts:590,689` |
### 4.7 API
- 打手端(`/api/v1/worker`):`POST /consignment/publish`、`GET /consignment/price-suggestion`、`GET /consignment/my-items`、`POST /consignment/items/:submissionId/confirm-friend`、`.../gift-evidence`。
- 后台(`/api/v1/admin`):`GET /worker-platform/consignment/inventories|stats|inventories/:id`(view)、`PUT inventories/:id`、`PATCH inventories/:id/matching`、`archive`、`return`(manage)、`GET submissions`(view)、`POST submissions/:id/approve|reject`(review)、`POST fulfill|assign-worker|revoke`(fulfill)、`POST /worker-platform/workers/:workerId/consignment-permission`。
---
## 五、风险总表(按优先级,已去重)
### P0 · 资金安全
| # | 问题 | 证据 | 建议 |
| --- | --- | --- | --- |
| 1 | **发单金额 > 用户付款仅前端确认**,后端改价/发布/指派三处均不做金额上限或与付款额比对 | 前端 `work-order-payment.ts:22`;后端 `admin-work-order-management-service.ts:60`、`admin-work-order-material-service.ts:102`(仅拼单价锁定)、发布仅校验 `>0`(`:228`) | 后端补齐同一校验并写事件留痕;发布/指派/改价三入口共用 |
| 2 | **审核入库可由请求体 `workerId` 改归属**,货款按 `inventory.worker_id` 结算 | `consignment-admin-service.ts:587`(`payload.workerId` 优先);`consignment-supplier-repo.ts:67`(库存优先于提报);`consignment-repo.ts:1227`(`COALESCE($4, worker_id)`) | 若改派非刻意玩法:审核接口禁止改 `worker_id`,或改派必须留痕 + 双人复核 |
| 3 | **同路径仍存在「按手机号自动绑定打手」**:审核时若未传 workerId,会用卖家手机号反查并绑定 | `consignment-admin-service.ts:597` | 与 079 下线 H5 的初衷一致化:审核不再自动绑定,需显式选择 |
| 4 | 审核接口全量返回 `credentialsJson`(游戏账号密码),仅需 `consignment.view` | `consignment-admin-service.ts:179` | 列表脱敏、详情按权限拆分或二次校验 |
| 5 | 模板 `required_deposit_amount` 可配但**建单硬编码 0** | `sync-worker-orders-service.ts:196`;规则侧 `work-product-rule-repo.ts:375,421` | 明确设计意图:要么带上,要么删字段避免误配 |
| 6 | `deposit_threshold_amount` 只被读写与展示,**无任何冻结/结算逻辑读取** | 全仓 grep 仅 `work-order-management-repo.ts` / `admin-work-order-management-service.ts:68` / `work-order-display-mappers.ts:385` | 确认语义后删除或接线 |
### P0 · 并发与数据一致性
| # | 问题 | 证据 | 建议 |
| --- | --- | --- | --- |
| 7 | **加锁顺序相反可死锁**:`grabWorkOrder` 先锁钱包(`:24` FOR UPDATE)再更新工单(`:58`);`assignWorkOrderToWorker` 先锁工单(`:154` FOR UPDATE)再锁钱包(`:169`)。同一单并发指派+抢单构成环 | `repositories/worker-platform/work-order-grab-repo.ts:24→:58` 与 `:154→:169`;钱包锁见 `repositories/worker-platform/shared.ts:28` | 统一锁序,或抢单先 `SELECT ... FOR UPDATE` 工单 |
| 8 | 寄卖指派链路**跨事务部分成功**,补偿三步全部 `catch {}` 吞错 | `consignment-fulfillment-service.ts:663/680/742/748`,补偿 `:802-849` | 合并为单事务;补偿失败必须告警而非静默 |
| 9 | 改派会让原卖家「我的寄卖」永远停在「正在入库」,被改派者看到未提报的货 | `consignment-worker-service.ts:352-368`;`consignment-item-stage.ts:158` | 与 #2 一并收敛 |
| 10 | 待加好友阶段可无限期挂起,货与押金同时冻结,无超时策略 | `寄卖库存优化现状与进度追踪.md:93,98` 仍标「待业务拍板」 | 加超时预警 + 自动回退/催促 |
### P1 · 安全
| # | 问题 | 证据 | 建议 |
| --- | --- | --- | --- |
| 11 | **claim token 默认永久有效**(`tokenTtlHours: 0`),链接即唯一凭据 | `config/defaults.ts:64`;`services/claim/claim-service.ts:40`;迁移 `015` | 生产强制 TTL > 0,并考虑首次访问后才可见 |
| 12 | **`/collect/*` 完全免鉴权**,凭订单号可读工单公开投影;限流 key 含攻击者可控的 `orderNo` → 每个新单号即新桶 | `app.ts:106`;`routes/collect.ts:13-27`;`middleware/rate-limit.ts:88` | 一次性 lookup token + 纯 IP 维度限流 |
| 13 | **`/files/object` 无鉴权**,仅靠 key 不可枚举 | `app.ts:108`;`routes/files.ts:12`;`file-storage-service.ts:233` | 私有 scene 恢复短期签名(需同步改写入侧) |
| 14 | 无安全响应头(无 helmet:CSP/HSTS/X-Frame-Options 缺失) | `app.ts:35-45`;依赖列表无 helmet | 应用内或 Caddy 统一注入 |
| 15 | 后台 token 与权限列表存 localStorage | `apps/frontend/src/utils/admin-auth.ts:1,44` | XSS 即接管后台,建议 httpOnly Cookie + CSRF |
| 16 | PDD 扩展路由回显任意 Origin | `routes/pdd-extension.ts:26-30` | 白名单 `chrome-extension://<id>` |
| 17 | 备份脚本会把 `data/`(含 PDD RSA 私钥)打包上传 OSS | `deploy/backup-db.sh:3-6`;`services/platforms/pdd/pdd-crypto-service.ts:112` | 私钥移出备份范围或备份加密 |
**做得好**:91 / 快手 / affiliate-dash 三个回调**全部强制验签 + 时间戳容差**(`open-91/order-create-service.ts:32`);dev-mock **双重门禁**(admin 会话 + `isDevMockEnabled`,非生产或显式开关,`services/dev-mock/dev-mock-service.ts:54`);admin 口令 scrypt + 盐;会话 HMAC + DB 会话表 + `session_version` 三查;请求体 2MB 上限;日志脱敏并有单测。
### P2 · 性能与可维护性
| # | 问题 | 证据 |
| --- | --- | --- |
| 18 | 建单对每个 order_item 串行 3 次查询(含循环内重复查同平台单号) | `sync-worker-orders-service.ts:73,87,150` |
| 19 | 匹配候选**全表入内存**、无分页,每次 `checkWorkOrderInventoryMatches` 都触发 | `consignment-repo.ts:854-887`;`consignment-fulfillment-service.ts:190,318` |
| 20 | 寄卖统计每次 5 条全表聚合 | `consignment-repo.ts:218-303` |
| 21 | 仓库搜索未覆盖前端占位符承诺的 `buyer_name/shop_name/seller_phone/worker_name` | `consignment-repo.ts:178` vs `ConsignmentInventoryPanel.tsx:789` |
| 22 | 「我的寄卖」固定 `pageSize: 200`,超出静默不可见 | `consignment-worker-service.ts:353` |
| 23 | 6 个 God file:前端 `WorkOrdersPanel.tsx` 3,657、后端 `consignment-repo.ts` 1,253、`consignment-fulfillment-service.ts` 1,174、`work-order-query-repo.ts` 1,057 | 实测 |
### 工程与运维
| # | 问题 | 证据 |
| --- | --- | --- |
| 24 | **无 CI**(无 `.github/workflows`),质量门禁只靠本机 pre-push,可 `SKIP_CHECKS=1` 绕过 | `.githooks/pre-push`、`scripts/install-git-hooks.sh` |
| 25 | **迁移启动时自动执行且无 down**;多副本会并发迁移 | `startup/bootstrap.ts:32`(失败每 5s 重试) |
| 26 | 无指标 / APM / 告警;调度器运行态只在进程内存 | `services/scheduler/scheduler-service.ts:11` |
| 27 | 单实例耦合:内存限流 + 内存调度器 | `middleware/rate-limit.ts:26` |
| 28 | Caddyfile 硬编码其他项目 vhost | `deploy/caddy/Caddyfile:22-30` |
| 29 | bus factor ≈ 1(949 commits,2 作者,其中 704 来自一人) | `git shortlog -sn` |
### 测试缺口(真实)
1. 无抢单/拼单的**并发与押金事务**测试(死锁、重复抢单、余额不足回滚无回归保护)。
2. 无超时扫描 `settleOverdueWorkOrders` 的测试(三策略 × 赠送单 forfeit 组合)。
3. 无 `syncWorkerOrdersForSourceOrder` 端到端分支测试(`already_exists` / `platform_order_exists` / `auto_create_disabled` / 状态落点)。
4. 无「发单金额 vs 用户付款」后端门禁测试(因为后端逻辑不存在)。
5. 无结算**金额守恒不变量**测试(报酬 + 押金 + 欠款抵扣 == 钱包变化)。
6. 无 `approveAdminConsignmentSubmission` 端到端测试(改派 + 押金归属 + 货款归属链无回归保护)。
7. `repositories/` 16,484 行仅约 10 个测试文件——**DB 层几乎无回归网**,而这正是 SQL 最复杂处。
8. 前端 `pages/` 45,977 行仅 872 行纯函数测试,**无组件渲染测试**。
---
## 六、改进路线建议(按性价比排序)
1. **建 CI**(跑两侧 `npm run check` + PG service 集成测试,把 pre-push 降级为可选)。当前最大杠杆:102 个测试与 `check:sql` 护栏现在只在本机生效。
2. **资金门禁后端化**:发单金额 vs 付款额(P0-1)、审核改派与手机号自动绑定(P0-2/3)、押金模板漏带(P0-5)三件事都在既有文件内可闭环,且都能补上测试。
3. **统一加锁顺序**(P0-7)并把寄卖指派链路事务化(P0-8);这两处是唯一可能造成"钱和货同时错"的并发窗口。
4. **收紧对外凭据**:claim token 生产强制 TTL、`/collect/*` 一次性 token + IP 限流、私有文件短期签名(P1-11/12/13)。
5. **拆分 6 个巨型文件**,先动 `WorkOrdersPanel.tsx` 与 `consignment-repo.ts`,同步补测试,把前端测试比从 1.9% 拉到 15% 量级。
6. **迁移与发布解耦**:部署脚本先 `npm run db:migrate` 再滚动重启,为多副本与蓝绿留出空间;高风险迁移补 `down` 段。
7. **补最小可观测栈**:`/metrics`(进程 + 连接池 + 调度运行态)+ 调度失败推送 + helmet。
---
## 七、一句话总结
后端是一个**分层清晰、DB 纪律与配置治理明显优于同类项目、交付与备份脚本相当完备**的成熟业务系统;发单主链路(进单→匹配→建单→发布→抢单→验收→结算)状态机与押金规则闭合度高,寄卖子系统的规格匹配门禁与押金/货款双账设计也相当讲究。
当前最该优先处理的三件事是:**① 资金门禁从「前端提醒」下沉到后端强制;② 抢单/指派的加锁顺序与寄卖指派事务化;③ 对外三处凭据薄弱/无鉴权接口**。中长期短板集中在**前端与 DB 仓库层的测试真空、6 个巨型文件、单实例耦合、以及无 CI/无指标的运维盲区**。
+22
View File
@@ -0,0 +1,22 @@
# 历史文档归档
> 这里的文档只用于追溯当时的分析过程,**不是现行实现说明**。
> 代码、数据库结构与线上行为以仓库当前 `main` 和 `docs/README.md` 中标记为「现行」的文档为准。
## 2026-10-08
| 文档 | 归档原因 |
| --- | --- |
| `项目分析报告-发单系统与寄售仓库.md` | 基线为 `ef491660`,测试数、迁移数、表数与当前实现已明显不一致;由《项目全量分析报告-2026-10-17》替代 |
| `寄卖子系统技术分析报告.md` | 同上,且部分行号已漂移;寄卖代码仍可参考,但结论需按现行代码复核 |
| `寄卖系统优化方案.md` | 阶段性优化方案,部分事项已完成,不能作为现行路线图 |
| `寄卖库存优化现状与进度追踪.md` | 阶段性进度追踪,完成状态已过时 |
| `近期性能与稳定性专项优化分析报告.md` | 2026-10-01 / 10-02 的生产快照与专项复盘,保留作性能证据,不代表当前容量 |
| `寄卖系统迁移079上库前检查.sql` | 迁移 079 的一次性上库前检查脚本,生产已执行;保留作审计追溯 |
## 使用建议
- 需要当前架构与风险:看 `docs/项目全量分析报告-2026-10-17.md`。
- 需要财务系统设计:看 `docs/财务系统集成方案.md`。
- 需要某个平台协议:看对应的厂商文档目录(`91卡券/`、`行业电子凭证/`、`发货平台-2/`、`抖音-码速达/接口API/`、`抖音-码速达/消息推送/`)。
- 历史文档中的代码行号、测试数量、迁移数量不要直接引用。