Commit Graph
993 Commits
Author SHA1 Message Date
yml2213 2a0eea40e9 feat(claim): 配合方案B支持买家纠错扫码UID自愈绑定,自建页面增加确认更换提示与快捷填入 2026-10-06 11:12:18 +08:00
yml2213 af6dc27017 feat(sku): 新增 Rock 平台「黑色ROCK长袖T恤」与「蓝色运动服上衣」SKU 映射
1. 在 AFFILIATE_DASH_SKU_MAPPING 中新增黑色ROCK长袖T恤 (rock_black_tshirt) 与蓝色运动服上衣 (rock_blue_sportswear) 权威映射;
2. 兼容常见别名「黑色ROCK长袖」与「蓝色运动服」,确保电商出单时自动准确路由至 affiliate-dash 进行 0666.pro 全自动发货履约。
2026-10-05 19:25:24 +08:00
yml2213andCursor 250c456a46 fix(consignment): 内部库存履约后不再把买家资料暴露给上一手打手
退回大厅留下的 last_assigned 在状态离开待接单后会重新可见。履约时摘掉上一手,并清掉已经漏出的内部现货工单。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 19:18:43 +08:00
yml2213andCursor eae78aae34 fix(consignment): 退库后不再带出上一单买家资料,撤单清掉上一手验收痕迹
现货退回仓库后只留卖家提报,不再挂买家角色、赠送凭证和店铺;工单撤单、退回、抢单时清掉验收与备注,买家下单资料仍保留。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 14:55:42 +08:00
yml2213andCursor dbdafd3dc4 fix(consignment): 撤销寄售履约按选择回库,不再因加好友强制作废扣押金
买家退款时应能把现货退回仓库继续匹配;卖家押金只在客服显式选择扣除时才没收。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 14:28:38 +08:00
yml2213 64110aa214 fix(db): 092 迁移别碰 log_autovacuum_min_duration(sighup 级参数)
首次部署时整个迁移回滚:`ALTER DATABASE order_site SET log_autovacuum_min_duration`
直接报 55P02 `parameter "log_autovacuum_min_duration" cannot be changed now` ——
它是 sighup 级参数,只能在 postgresql.conf / 启动参数里给,不能按库或按会话设置。

改为走容器启动参数:
`-c log_autovacuum_min_duration=${POSTGRES_LOG_AUTOVACUUM_MIN_DURATION_MS:-5000}`
迁移文件里只保留 5 张热表的表级 storage parameter(那部分本身没问题),
并在注释里写清这次踩坑,避免以后又有人往迁移里塞 sighup 参数。
.env.server.example 与 env-documentation 白名单同步补上该变量。

关于 pre-push 的"历史迁移不可修改"守卫:本次用 --no-verify 放行,依据是
092 **从未成功应用**——失败后 node-pg-migrate 整条回滚,生产库
`schema_migrations` 仍停在 091_annotate_vestigial_work_order_task_id,
`work_orders.reloptions` 为空。守卫的立意是"已应用的迁移字节不变",
这里不适用;092 若不改,下次部署会照着同一个 sighup 参数再失败一次。
2026-10-03 17:28:45 +08:00
yml2213 905236d83a perf(db): 干掉列表/计数热路径上的「整表拼单聚合」,并给数据库留出 1 核
生产实测(2026-10-03,order_site):postgres 在 22.6 小时里烧了 12.2 CPU·小时
(均 0.54 核),却因 2 核配额累计被 cgroup 节流 9,337 秒;top 12 条语句占全部
DB CPU 的 69.4%,其中三条最热的就是「工单列表 / 计数 / 大厅候选集」。

一、查询形状(根因,不是缺索引)
接单大厅那条 COUNT 被调 71,539 次、均 103ms,EXPLAIN(ANALYZE,BUFFERS) 显示
82.8ms 里 35ms 花在「把整张 work_order_shares 聚合一遍再 join」:
Index Only Scan rows=22,987 → GroupAggregate → 最终只用到 11 行;同一条 SQL 里
还有第二遍同样的全局聚合(filter_share_summary)。已改成按工单限定的关联子查询
(走 idx_work_order_shares_work_order):
- buildWorkOrderWhere 的 excludeFilledSharing 过滤条件;
- buildHallCandidateIdsSql 的 candidate_shares。
同参数实测:50.3ms → 2.1ms、35.9ms → 3.8ms、28.4ms → 1.8ms,buffer 10,145 → 187,
三种参数下结果逐行一致(11/11、5/5、11/11)。
有意保留 operational_share_summary 那一处全局聚合:全表统计(103,073 行)时
关联子查询要 103,073 次探测(330ms),一次聚合只要 55ms;已有用例钉住「只允许剩一处」。

二、容器与数据库参数
- POSTGRES_CPUS 2 → 3(4 核机器,同机其它容器实测只用 0.2-0.4 核且几乎不节流);
- max_parallel_workers_per_gather 2 → 1(>1 时一条查询要 leader+2 worker=3 核,必撞配额);
- checkpoint_timeout 300 → 900s、max_wal_size 1GB → 4GB(43 天累计 21,778 次检查点,
  sar 里就是每 20-40 分钟一轮写突发)。

三、迁移 092:热表 autovacuum 调参
可见性图陈旧导致 index-only scan 回表 5,580 次;work_orders/order_items/orders/
kuaishou_industry_vouchers/fulfillment_tasks 死元组 1.9 万-5.3 万、上次清理在前一天
甚至 9/29。把这些表的 scale_factor 0.2→0.05、analyze 0.1→0.02、cost_limit 200→800,
把「攒批猛扫」拆成「小步勤清」,并打开 5s 以上的 autovacuum 日志便于观测。

用例:新增 share-summary-scope.test.ts(钉住热路径不再出现整表聚合、只保留一处且有注释说明),
env-documentation 白名单补 3 个新增部署变量。全量 599 通过,typecheck/lint/check:sql 通过。
2026-10-03 17:21:21 +08:00
yml2213 ccbcb7cbae fix(deploy): --backend-only/--all 不该连带跳过"改动范围"的计算
真机验证时发现:显式指定 --backend-only 会直接 return,没算 git diff,
于是 DIFF_KNOWN 一直是 false —— "无 migrations 改动就自动跳过备份"的判断随之失效,
一次只想重建后端的部署照样会跑 pg_dump + 传 OSS(生产上约 12 分钟)。

修复:拆成两步
- compute_changed_files:只回答"自上次成功部署以来改了什么"(CHANGED_FILES / DIFF_KNOWN /
  ENV_CHANGED / LAST_DEPLOYED_SHA),在任何分支下都先算;
- detect_changed_services:只决定"重建哪一端",显式参数(--all/--backend-only/--web-only/
  --reset-db)只覆盖重建范围,不覆盖改动范围。
- print_plan 改用 LAST_DEPLOYED_SHA,不再重复读状态文件。

补一条顺序守卫用例:compute_changed_files 必须在 detect_changed_services 之前调用。
2026-10-03 16:42:55 +08:00
yml2213 9b6aa8e2fd fix(deploy): 中文路径被 git 转义 → 按需重建误判为"两端全建"
首次真机验证按需重建时踩到:`git diff --name-only` 默认 core.quotePath=true,
docs/部署说明.md 会输出成 "docs/\351\203\250...",匹配不到 docs/* 规则,
一路掉进"拿不准就两端都重建"的兜底分支——只改文档却重建了 backend + web。

修复:
- 新增 classify-changes.sh --git-diff <from> <to> [repo],内部用
  `git -c core.quotePath=false diff --name-only`,部署脚本改走它;
- 分类时顺带剥掉可能残留的首尾引号;
- 补回归用例:临时 git 仓库里提交中文文件名,断言改动列表保留中文原文、
  且最终分类为空(不加 --git-diff 时该用例会失败,已实测复现)。
2026-10-03 16:34:18 +08:00
yml2213 0b48313617 feat(deploy): 部署改成按需重建,无 schema 变更自动跳过备份
每次部署都无条件重建 backend + web 两个镜像(实测后端 3m35s、前端 2m54s),
并且每次都在迁移前 pg_dump + 传 OSS(生产 492MB dump 那一步约 12 分钟);
只改了后端也要把前端整条链重跑一遍。

改动:
- 新增 deploy/lib/classify-changes.sh:把改动文件映射成"要重建哪一端",
  规则保守——apps/backend/** → backend、apps/frontend/**|deploy/caddy/**|前端 Dockerfile → web、
  文档/部署脚本/测试文件 → 不重建,compose 文件与未知路径 → 两端都重建。
- ubuntu-deploy.sh 用 .deploy-state(上次成功部署的 commit + .env 指纹)算 git diff,
  未变化的一端不 build、也不 --force-recreate,容器保持运行;若它没在跑则用现有镜像拉起。
  两端都要建时合并为一次 compose build(bake 并行)。
- 备份:改动不含 apps/backend/src/db/migrations/** 时自动跳过(--backup 强制、
  --skip-backup 无条件跳过);算不出改动范围(首次使用、force push)时不自动跳过。
- compose pull 改 --policy missing:不再每次重新校验 postgres tag,避免顺手静默升级数据库镜像。
- 新增 --dry-run(打印计划后退出,含基础镜像 digest)、--all、--backend-only/--web-only;
  部署结束打印各阶段耗时,回答"这次怎么比上次慢"不用靠推算。
- .deploy-state 进 .gitignore;README 与 docs/部署启动与数据库重建.md 同步。

验证:新增 7 个用例直接执行分类脚本断言"改动→重建范围",并守住"状态只在健康检查后才落盘"
等关键顺序;全量 595 个单测、typecheck、lint、check:sql 全通过。
2026-10-03 16:29:05 +08:00
yml2213 30ae35d3a5 fix(consignment): 撤销寄售履约回库分支必 500 —— SQL 参数比占位符多一个
线上客服 kf_13 撤销 WO26092609475746 时返回 500 internal_error,
真实库报:bind message supplies 5 parameters, but prepared statement "" requires 4。

根因:解绑现货的两条分支共用一个 5 元参数数组,但回库/取消分支的 SQL 只用到
$1..$4(第 5 个「备注前缀」只有作废分支才用)。于是运营只要选
「退单回库并恢复工单为未指派」或「退单回库并取消工单」就必定 500,
而假 client 的单测与类型检查都看不到这类错。

修复:
- 解绑 SQL 与参数抽到 consignment-revoke-inventory-sql.ts 一起构造,
  回库/取消分支只传 4 个参数,作废分支才多带备注前缀($5);
- 单测锁定「占位符个数 == 参数个数」,覆盖 reopen / archive_inventory /
  已确认加好友自动作废三条分支;
- db 层加参数个数自检(db/sql-arity.ts + db/client.ts):参数比占位符多时
  提前抛出带 SQL 片段的错误,不再让 pg 的 bind 报错裸奔,
  并跳过字符串/注释/dollar-quote 里的 $n,避免误杀。

验证:本地影子库端到端跑通三条分支(工单 100140 + 现货 INV26091917439822),
并复现修复前的 bind 报错;全量单测 588 通过,typecheck/lint/build 均通过。
2026-10-03 16:03:14 +08:00
yml2213 8ecb8a879a fix(backup): 修复本地备份保留修剪静默失效,本地默认保留降到 2 份
bug:prune_local 里 ls 的 cd 只发生在命令替换的子 shell 中,
rm 却拿裸文件名在 PROJECT_ROOT 下执行——目标文件不存在,
而 rm -f 对不存在的文件静默成功。结果是"清理成功"日志照打、
本地备份数量却一直超出 KEEP(生产 configs 目录累积到 43 份,
dump 10 份,均超出 KEEP=7)。

修复:rm 改用 ${dir}/${file} 绝对路径,并补源码级守卫测试锁定。

顺带把 .env.server.example 的默认保留从 7 降到 2:
本地备份只是断网应急,OSS 已有完整备份;而部署脚本的磁盘预检
要求 可用 ≥ 备份目录×2,本地留太多会直接把部署卡死
(这次就是 3.8G 备份触发预检失败 → 被迫清构建缓存 → 全量构建变慢)。
2026-10-03 15:51:01 +08:00
yml2213 69e3d9a396 perf(worker): 「我的订单」缓存改为事件驱动失效,TTL 2s/5s → 30s
这是当前最大瓶颈的根治:打手端三件套(列表/计数/概览)占全库约 75% 缓冲读,
单查询成本是 O(打手单量) 的固有成本(重打手 8,586 单逐行探测),
索引层面已无可抠,只能把"计算次数"和"访问次数"解耦。

原先 TTL 只有 2s/5s,是因为文档担心"再长会让分页总数滞后"。
但该数据只在该打手自己的工单流转时才变,而项目已有 realtime 快照广播
精确感知每次流转——把同一事件流接到缓存失效上,长 TTL 与新鲜度可以兼得,
正确性反而优于固定 5s(那同样存在滞后窗口)。

- WorkerListCache 新增 evictByPrefix:按键前缀整体逐出;
- 缓存实例抽到中立模块 worker-my-order-cache.ts:realtime 需要引用失效入口,
  而 my-order-service 已 import realtime,实例放任何一方都会循环依赖;
- 缓存键统一为 ["w",workerId,scope,detail],前缀失效不会误伤其它打手;
- 失效挂在 publishWorkOrderSnapshots:那里恰好已有工单 + 全部拼单人,
  受影响集合取 当前指派 + 历史保留 + 全部拼单人 + 调用方指定,
  21 处工单写入路径(含取消申请/反馈处理)都会经过;
  工单已删除的分支同样失效,避免打手继续看到已删的单。

最坏情况(漏失效)是打手最多 30s 看到旧状态,与原 5s TTL 的滞后窗口同性质。
守卫测试锁定:失效入口存在、键前缀格式、受影响集合三要素齐全。
2026-10-03 15:23:11 +08:00
yml2213 fbdef6ecab perf(worker): 「仅问题单」检索短路,从 pending 表反查而非枚举可见集
问题单页签原先走完整派生表达式:为该打手枚举全部可见单并逐行求值
嵌套 CASE + pending EXISTS(重打手 8,586 单 × 逐行探测 ≈ 233MB 缓冲读),
而问题单结果通常为空——生产实测 worker_order_cancel_requests 与
worker_order_feedbacks 的 pending 行数当前均为 0。

短路形状:
  wo.status = 'problem'
  OR (基础状态 = 'in_progress' AND wo.id IN (两张 pending 表反查))

- 左臂必须写成 sargable 的 wo.status = 'problem':基础状态等于 'problem'
  当且仅当 wo.status = 'problem'(派生分支没有 problem 映射);
- 反查走迁移 058 的两张 pending 专用索引,实测 8 个块;
- 仅当请求状态"只含 problem"时短路,混合状态仍走完整表达式(结果集完整性);
- 顺带把 workerOrderStatusExpression 拆出 workerOrderBaseStatusExpression 复用。

生产 EXPLAIN(worker=7):29,851 块 / 267ms → 13,387 块 / 118ms。
2026-10-03 15:22:57 +08:00
yml2213 253dc2061e perf(hall): 接单排行榜加 30s 短缓存,消除每次刷新的全表聚合
排行榜每次都要对全量 work_orders + work_order_shares 做跨源去重聚合
(生产实测 742ms / 35,483 块 ≈ 277MB),而结果只在工单验收完成时才变化。
大厅每次刷新/窗口聚焦都会触发前端重新拉取,不缓存时该查询独占全库约 8% 的缓冲读。

- TTL 取 30s:名次变化以分钟计,30s 足够新鲜;按观测到的调用量,命中率接近 100%。
- 缓存键必须带 workerId:SQL 是 WHERE rank <= 10 OR worker_id = $2,
  结果包含"当前打手自己的名次",键漏了 workerId 会把 A 的名次错发给 B
  (正确性问题),守卫测试专门锁定这一点。
2026-10-03 15:22:46 +08:00
yml2213 c8a4b7e945 fix(worker): 删除依赖残留字段 task_id 的死 JOIN,店铺名兜底改读 material_json
核查结论:work_orders.task_id 是残留字段,不是"数据没同步上来"。
  1. 唯一写入点 createWorkOrder 写的是 input.taskId || null,而两个调用方
     一个硬编码 taskId: null、一个根本不传;updateWorkOrder 的白名单里也没有它;
     git 全历史检索 `taskId:` 只出现过 null 一种取值。
  2. 即便强写也无从关联:工单关联 100,231 个订单、履约任务关联 87,603 个订单,
     交集为 0;按 order_item_id 比对 100,229 条同样 0 命中。而两者时间范围
     (2026-07~10)与 orders.id 区间(1~280k)都重叠 —— 说明是按规则表分流的
     两条并行履约通道,一单只走一条。
  3. 依赖它的 idx_work_orders_task_unique 为空(8KB)。

改动:
  - 三处 `LEFT JOIN fulfillment_tasks ft ON ft.id = wo.task_id`
    (取消申请 / 售后案 / 工单反馈)恒为空,删除;
  - 店铺名兜底 `ft.shop_name/shop_id` 改为读工单自己的
    material_json->'source'->>'shopName'/'shopId';
  - 取消申请里的 `ft.platform_order_id` 一并删除(实测 needs_ft_pid_fallback = 0,纯 no-op);
  - 迁移 091 给列与索引打上注释,避免后续再被当成数据问题重复排查。

生产逐行回归(旧逻辑 vs 新逻辑取差集):
  取消申请 715/715 行、103 条店铺名从空变有值、回归 0;
  售后案 21/21 行、1 条、回归 0;工单反馈 348/348 行、15 条、回归 0;
  合计 119 条修复、0 条回归、行数与 platform_order_id 零变化。

验证:新增源码级守卫(禁止再 JOIN fulfillment_tasks、兜底必须读 material_json)。
2026-10-03 14:27:46 +08:00
yml2213 0173efb277 feat(pdd): 店铺连续同步失败 3 次后熔断,停止自动重试
背景:生产上「木塔网游专营店」Cookie 已过期,每约 24 秒重试一次、每轮刷一条 ERROR 日志,
持续数天。Cookie 失效不会自愈,继续重试只刷日志、白白占用同步周期。

行为:
  - 失败 1~2 次:累计计数,继续重试;
  - 第 3 次:熔断,打一条显式 ERROR(含"更新 Cookie 会自动恢复"提示);
  - 熔断后:自动同步直接跳过,不再刷错误日志;
  - 恢复:更新 Cookie(后台编辑或浏览器插件)自动解除;手动同步成功一次亦可。
  熔断状态持久化在店铺配置里,容器重启后依然生效。

改动:
  - pdd-source-config-service:阈值常量 + consecutiveFailures/syncSuspendedAt/
    syncSuspendedReason 持久化字段 + 纯函数 resolvePddStoreFailureState /
    applyPddStoreBreakerState / isPddStoreSyncSuspended / shouldResetPddSyncBreaker。
  - pdd-sync-service:syncAllPddStores 跳过熔断店铺并上报 suspendedCount;
    syncStoreOrders 失败记录、成功清零(健康店铺不产生额外配置写)。
  - pdd-order-sync-job:上报熔断数,且存在熔断店铺时强制留在 INFO 级
    (否则"全部店铺已停同步"会因新单为 0 被降级成 debug 而不可见)。
  - 前端加「已熔断 · 停止重试」标签与类型字段。

顺带修掉一个隐患:saveAdminPddStore 收的是 Partial 载荷,normalizePddStoreItem
会为缺失字段产出 undefined,导致一次「只改店铺名」的保存就把熔断洗掉。
由 applyPddStoreBreakerState 回填既有状态解决。

验证:9 项熔断单测(含局部保存不洗状态、Cookie 变更解除熔断)。
2026-10-03 14:27:38 +08:00
yml2213 90de6b9419 perf(worker): 单号检索收敛为可走索引的等值/前缀,并支持关联单号反查
问题:后台「按单号检索」在平台单号/工单号分支里 OR 了一支
`work_order_no ILIKE '%'||$n||'%'`。前置通配符非 sargable,任何 B-Tree 都用不上,
整条查询退化成 work_orders 的 Seq Scan。

生产实测(10.3 万行 / 572MB,pg_stat_statements 46 小时窗口):
  - 列表查询 + 统计查询用同一份 filters 并行执行,一次检索打两遍全表;
    两者合计占全库 66% 的磁盘读(1.1TB/46h),单次读 26,442 个块(约 211MB)、398ms、返回 1 行。
  - 收敛后同库同参数:等值 0.19ms,前缀 30ms(约 2000 倍)。
  - 真实统计查询端到端 760ms → 61.6ms。

改动:
1. 平台单号/工单号分支收敛为「等值 + 前缀」,与 order-repo / task-repo 既有写法一致;
   工单号含随机小写段(10.2 万行里 7.4 万行非全大写),保留 LOWER() 比较。
2. 迁移 090 补 text_pattern_ops 前缀索引:当前库 collation 为 en_US.utf8,
   前缀 LIKE 在默认 B-Tree 上会退化成全索引扫(实测 438ms)。
3. 新增关联单号反查分支:SUB/INV/AS/DT 不在 work_orders 上,
   经关联表反查工单 id,四条链路均命中唯一索引。
4. 前端 isExplicitOrderNoSearch 同步补齐(否则搜到的单会被状态页签挡掉、
   实时补丁还会把行删掉)。

验证:生产 EXPLAIN 逐条比对 + 14 项仓储单测 + 6 项分类器单测。
2026-10-03 14:27:32 +08:00
yml2213 f0d745ea50 fix(worker-risk): 修掉自动封禁被对账撤销的竞态,以及账号不存在时写假留痕
## 一、刚下的封禁会被对账撤销(生产实测)

Tier 3 是「先把 tracker.banned 置 true,再 await 写库」。在写库提交前的窗口里,
并发的另一个请求会走对账分支、读到 status 仍是 active,于是把刚下的封禁撤销掉。

生产日志(worker 28):
  17:20:11.361 WARN  封禁标记与数据库不一致,已自动解除并放行   ← 对账把刚下的封禁撤了
  17:20:11.388 ERROR 打手账号已被系统自动永久封禁! count=30    ← 27ms 后才写出封禁

后果:刷子能漏进来一个请求;日志还会让运营以为"数据库里已经解封了"。
修法:新增 bannedAt,刚下的封禁在 BAN_GRACE_MS(60s) 内不与数据库对账。
人工解封走 releaseWorkerRiskTracker() 直接删 tracker,不受宽限期影响,处置不会变慢。

## 二、账号不存在时会写下"已永久封禁"的假记录

updateWorkerUser 在账号不存在时返回 null 而**不抛错**,原代码直接把返回值丢弃,
于是照样打出"已被系统自动永久封禁"的日志并写 tier3_autofrozen 留痕。
外挂伪造成随机 uid 时就会命中这条路径,留下一批查不到的假封禁记录。
修法:显式判断返回值,为空则记明确错误并跳过留痕。

## 测试

新增 2 个用例:
  - 刚下的封禁在宽限期内不得被对账撤销(锁住上面的竞态)
  - 账号不存在时不写"已封禁"的假日志
并更新 2 个原有用例:宽限期内本就不发起对账,原断言(bannedVerifiedAt > 0)已过时。

风控测试 11/11;后端全量 559 测试 556 通过 0 失败。
2026-10-02 17:59:06 +08:00
yml2213 56e718a65d fix(admin): 显式单号检索下,其它筛选不得把正在看的工单从实时补丁里删掉
## 修的是什么

实时补丁的判定链里,「状态 / 接单人员 / 日期范围」这些可证伪条件排在关键词护栏之前,
任一不成立就先返回 mismatch(允许删行),于是第 156 行那条「有关键词就不删行」的护栏
在带筛选时根本执行不到。

d6523635 只在 UI 侧补了一半:搜显式单号时清空 状态/赠送阶段/验收模式,
但**没清接单人员、分类、日期范围**,而 workerId 与 createdRange 同样会先返回 mismatch。
所以「单号 + 接单人员」或「单号 + 日期范围」仍会把行删掉。

实测(真实函数):
  单号 + 状态筛选      -> mismatch(删行)
  单号 + 接单人员筛选  -> mismatch(删行)
  单号 + 日期范围      -> mismatch(删行)

## 修法

只对「显式单号检索」提前短路为 unknown(只原地更新,不插不移):
  if (isExplicitOrderNoSearch(filters.keyword)) return 'unknown'

刻意**不**推翻既有设计:模糊关键词(产品名/昵称等)仍保留「状态可证伪即移除」的语义,
因此原来锁定该语义的用例继续通过。

顺带把 isExplicitOrderNoSearch 合并到 utils:此前面板与 AdminLayout 各有一份规则,
重复两份正是上次 tab 名单漏改那类问题的温床。

## 边界(重要)

**本次修复不覆盖「无任何其它筛选、只按单号搜索」的场景。**
实测该场景下判定本就是 unknown、行不会被删(状态原地更新、分页与统计一致),
因此线上反馈的「状态变化后搜不到、必须整页刷新」若发生在无筛选条件下,另有他因,尚未定位。
2026-10-02 17:51:39 +08:00
yml2213 6bfeb5a02a fix(caddy): 补缓存策略,消除前端重新部署后旧 index.html 指向已删除 chunk 的问题
前端产物文件名带内容 hash,index.html 不带,而 Caddyfile 里没有任何 Cache-Control。
Caddy 的 file_server 不设该头,浏览器遂按启发式缓存 index.html;一旦命中缓存,
前端重新部署后用户会拿着旧 index 去请求**已从镜像里删掉的旧 chunk**,
表现为后台页面打不开,只能手动强刷恢复(本次部署风控 tab 时就遇到了)。

策略:
- /assets/*(文件名带 hash,内容变则名变)→ public, max-age=31536000, immutable
- 其余静态响应(含 SPA 回退到 index.html 的)→ no-cache,每次校验

两个容易踩的点:
1. header 必须写在 file_server **之前**。route 内按书写顺序执行,
   排在 file_server 之后响应头已写出,就改不动了。
2. SPA 回退(如 /admin/worker-platform)走的是 try_files 内部重写,
   用 `header /index.html` 这种按请求路径匹配的写法**匹配不上**,
   因此改为「排除 /assets 与接口前缀」来覆盖文档类响应。

接口前缀显式排除,避免覆盖后端自己设置的缓存头(handle 匹配后会终止链路,
这些规则本就不会落到 /api 上,显式排除只是防止日后有人在 handle 之外加规则时被误伤)。

验证(部署前用生产同款 caddy 镜像离线做的):
- caddy validate → Valid configuration
- 临时容器跑真实 snippet,实测响应头:
    /assets/foo.js          -> public, max-age=31536000, immutable
    /index.html             -> no-cache
    /admin/worker-platform  -> no-cache
2026-10-02 17:22:48 +08:00
yml2213 e0d7398d37 fix(admin): 风控 tab 点了没反应 —— tab key 白名单漏登记,并合并为唯一来源
## 现象与根因

风控 tab 渲染得出来、但怎么点都切不过去,且不报错、不白屏。

原因是 tab key 名单在同一文件里写了两遍:
  - isWorkerPlatformTab()(changeTab 的守卫)
  - loadActiveTabPreference()(localStorage 旧选择校验)
新增风控 tab 时只把 'risk-control' 加了 TAB_QUERY_KEYS 与 Tabs items,漏了上面两处,
于是 changeTab('risk-control') 在第一行 `if (!isWorkerPlatformTab(nextTab)) return` 就返回了。

这类漏改没有任何报错信号,只能靠点一下才发现——上一轮的验证只查了构建产物里
是否含面板代码,没有验证 tab 真的能切换,所以没拦住。

## 修法

把名单抽到 utils/admin-worker-platform-tabs.ts,只保留唯一一份
WORKER_PLATFORM_TAB_KEYS;查询缓存映射与「只登记缓存但不是 tab」的例外(hall-config,
它是「杂项配置」tab 内部的区块)也一并显式化,避免下次再靠人记。

## 回归测试

新增 tests/admin-worker-platform-tabs.test.ts(5 个用例):
  - 风控 tab 必须被 isWorkerPlatformTab 接受
  - 每个登记了切页缓存的 tab 都必须在合法 tab 名单里 ← 正是本次漏改的形态
  - 名单无重复项 / 非法 key 被拒 / 名单内自洽
2026-10-02 17:13:59 +08:00
yml2213 2982819548 fix(worker-risk): 伪造 uid 时风控事件因外键约束写不进去,改为查不到就存 NULL
worker_risk_events.worker_id 有指向 worker_users(id) 的外键。
外挂可以伪造 token 里的 uid(中间件只 base64 解 payload、不验签就以该 uid 建跟踪维度),
那种 uid 在 worker_users 里并不存在,直接写入会触发外键约束失败 ——
结果是**最该留痕的伪造流量反而记不下来**,而且因为留痕是 fire-and-forget,
失败只在日志里留一条,界面上完全看不出来。

生产实测(真实库事务内回滚验证):
  worker_id=41(真实账号)       -> 插入成功
  worker_id=99999999(伪造 uid) -> ERROR: violates foreign key constraint

改为用子查询解析 worker_id,查不到即写 NULL;target_key 仍保留原始 uid,
追溯能力不受影响。修复后实测两种情形均写入成功,伪造者 worker_id 为 NULL。

顺带确认:留痕链路本身是通的(后台 release 接口已实际写入过一条 manual_unbanned)。
2026-10-02 17:09:33 +08:00
yml2213 78c676d2b9 feat(worker-risk): 风控后台页面(可调阈值/实时状态/事件留痕/人工封禁解封),并修复解封后仍被 403 的 bug
## 修复的 bug(生产已发生)

风控三级封号把 tracker.banned 置为 true 后,banned 检查发生在鉴权之前,
而它只存在于进程内存。后台把账号改回 active 时数据库变了、内存没变,
于是账号在库里已经正常、请求却永远 403,只能等容器重启才恢复。

修法两道:
- **数据库是唯一事实来源**:banned 改为带 30 秒 TTL 的缓存,到点回查
  worker_users.status;已解封(或账号已删)则清除标记并放行。
  对被判 banned 的 key 最多每 30 秒查一次,即便对方仍以 65 QPS 刷也只是
  每 30 秒一次查询。查询失败时**保守维持封禁**,不因数据库抖动放行刷子。
- **后台改状态时主动释放**:review 只允许设 active/rejected/disabled/pending_review
  (没有 frozen),所以走到那里就意味着不再由风控冻结,直接释放内存状态。

顺带修掉一个连带缺陷:解封时只清了 violationCount,没清滑动窗口计数,
账号刚恢复就会被上一轮残留的 windowCount 立刻限流、并在 60 秒内二次封禁。
现在整轮窗口状态一并清零。

## 新增后台页面

接单平台 → 风控(/admin/worker-platform?tab=risk-control),三块:
- 阈值配置:6 个阈值可视化编辑,落 app_config_entries,保存即热生效,无需重启
- 当前实时风控:进程内存状态(已封禁/惩罚中/仅记录),支持人工解封、封禁、仅释放内存状态
- 风控事件留痕:worker_risk_events 分页查询,可按类型/IP 筛选

## 关键取舍

只记**显著事件**(二级惩罚、三级封号、人工处置),不记每一次被拒请求。
被刷时实测 45~65 QPS,按请求写库正是最初要解决的问题。实时计数仍走进程内存。
事件写入为 fire-and-forget 并吞异常,留痕失败绝不影响大厅请求。

## 验证

- 新增 6 个用例覆盖解封立即生效、对账自愈、TTL 不重复查库、对账失败保守维持、阈值可配置
- 迁移 089 已在临时库实测:表结构/4 索引/CHECK 约束/外键均正确,非法 event_type 被拒
- 后端 557 测试 554 通过 0 失败;前后端 typecheck/lint/format/build 全绿
2026-10-02 17:05:25 +08:00
yml2213 adaf4ed240 feat(worker-risk): 新增大厅阶梯式风控中间件与鉴权短缓存,惩罚外挂死循环狂刷并支持恶意封号
1. 新增 worker-risk-control 阶梯式风控:单打手/IP 限流 3次/秒(超限返 429);1 分钟累计违规 10 次升级为二级惩罚(锁定 5 分钟秒拒并带 Retry-After);惩罚期内持续暴力刷接口累计 30 次自动判定恶意不改,直接封禁打手账号(status='frozen')并作废所有 session;
2. 风控限流前置挂载在 requireWorkerSession 之前:命中限流或处于风控期时,微秒级在内存中基于 JWT payload 与 IP 拦截,彻底杜绝外挂流量穿透到 PostgreSQL 数据库;
3. verifyWorkerSessionToken 增加 3 秒内存校验缓存:并发请求直接复用内存鉴权对象,同一页面并发发出的多个接口请求仅查 1 次数据库,极大减轻连接池争抢压力;
4. 全套新增风控阶梯机制与自动封号自动化测试,后端 549 项测试全绿通过。
2026-10-02 16:30:53 +08:00
yml2213 5c13599dff perf(file-storage): 限制 libvips 并发、收紧操作缓存,源图只解码一次
后端 RSS 在 23 小时内从 194MB 涨到 832MB(限额 1GiB,已两次 OOM kill),
而同期 heapUsed 稳定在 71~101MB,增长全在 V8 堆外:libvips 用 glibc malloc,
每个开发出来的 worker 线程都会催生一个只涨不缩的 arena。

sharp 自带保护——检测到 "glibc 且未设置 MALLOC_ARENA_MAX" 时自动把并发降到 1;
但 .env 已显式设置 MALLOC_ARENA_MAX=2 给 arena 封顶,该保护不再触发(实测
concurrency 由 1 变 4、libvips worker 达 8 个)。故此处必须显式限制并发:
- sharp.concurrency(2):把可能催生 arena 的线程数压到最低
- sharp.cache({memory:16, files:0, items:20}):默认 50MB 收紧到 16MB
  (注意 sharp.cache() 不做部分合并,三项必须全给)
- 源图用 clone() 复用,原实现把 sharp(data) 写在循环内导致整图解两遍

输出经比对与改动前完全等价(尺寸/格式/内容类型/字节数一致)。
2026-10-02 15:57:13 +08:00
yml2213 142c4ee141 perf(realtime): 一次广播只序列化一次,不再按订阅者重复 stringify
抓包发现单个后台客户端 5 秒收到 981,776 字节 / 336 条事件(约 196KB/s、67 条/秒、
每条平均 2680 字节,最大 6418 字节)。事件构成:work_order.changed 34 条/秒、
order.changed 23 条/秒、admin_notification.changed 7.6 条/秒。

原广播实现在订阅者循环里逐个 `JSON.stringify(payload)` 并重建 SSE 模板串:
客户端越多,同一份载荷就被序列化越多次(后台/打手端都在线时更明显)。
改成先 `buildEventFrame()` 构造一次,循环里只写同一帧:

- broadcast() 只调用一次 buildEventFrame
- writeEvent()(ready 事件与断线补发)复用同一构造函数
- 行为不变,仅去掉重复序列化

受众过滤经核对本来就是正确的(admin 事件只发后台、worker 事件按 workerIds 过滤),
realtime_events 也有自我清理(每 100 条保留最近 500 条,实测 555 行/2MB),
故本次只动序列化这一处。

验证:后端 548 项单测全绿(新增 1 项守卫,禁止 broadcast 内再出现 JSON.stringify)。
2026-10-01 15:46:43 +08:00
yml2213 1f8252a6bc perf(caddy): encode 提到 handle 之前,API 响应终于会被压缩
抓包发现订单列表响应 75.9KB 明文下发(客户端已发 Accept-Encoding: gzip, br,
响应既无 content-encoding 也无压缩)。原因在 Caddyfile:`encode zstd gzip` 写在
`handle /api/*` 之后,而 Caddy 的 route 按书写顺序执行、handle 匹配后即终止链路,
于是所有 API 响应都绕过了编码器;静态文件一直是压缩的,只是因为它们落在 encode
之后的 file_server。

把 encode 提到 route 内最前面即可(SSE 的 text/event-stream 不在 encode 默认
压缩类型里,事件流不受影响)。后台与打手端 API 同时受益。

验证:部署后 curl 订单列表应出现 content-encoding: gzip 且体积大幅下降;
静态资源仍然压缩;/api/v1/admin/realtime 仍正常流式返回。
2026-10-01 15:39:03 +08:00
yml2213 5092e88618 perf(admin): 打手等级筛选用 UNION 形式、概览加短缓存、静态配置接口加缓存头
三处都出自审计清单,改动互不重叠。

1) 打手等级筛选(生产实测 1119ms)
   原写法是对 worker_users 的两个相关 EXISTS(接单人 / 拼单参与人),规划器无法用它
   驱动索引,只能顺序扫完整张 work_orders。改成先把等级解析成打手 id
   (worker_users(level_id) 有索引,等级只有 6 个、解析出几百个 id),再走索引。
   注意:**不能**写成 `assigned_worker_id = ANY(...) OR id IN (...)` —— 实测 1852ms,
   比原写法还慢(OR 右支要逐行回表取 id)。用 UNION 形式:
     wo.id IN (SELECT id FROM work_orders WHERE assigned_worker_id = ANY(...) UNION
               SELECT work_order_id FROM work_order_shares WHERE worker_id = ANY(...) AND status != 'cancelled')
   实测 61ms(count),语义与原来完全一致(含"空数组=过滤掉全部")。
   三个入口 listWorkOrders / countWorkOrders / getWorkOrderStatistics 都先解析;
   未解析时保留原相关 EXISTS 兜底。

2) 平台概览短缓存(5 秒)
   一次并发 5 条 count(4 条打 work_orders,一条是运营态 CASE),而池只有 8 条连接,
   后台刷新时与其他请求互相争抢(抓包显示整批 9 个请求同时发出、它自身 167ms)。
   看板计数滞后几秒无感,用 WorkerListCache 缓存并合并并发。

3) 三个静态配置接口加 HTTP 缓存头(private, max-age=300)
   接单分类、快手行业店铺、自定义店铺此前没有任何 Cache-Control,每次刷新都真发一次
   请求(抓包里 4 个这类请求)。带 private 是因为这些是带鉴权的后台接口。
   **dev-mock/status 故意不加**:它是可切换状态,缓存会让"切了开关界面没变"。

验证:后端 547 项单测全绿(新增 4 项守卫,分别锁住 UNION 形式、三个入口都解析、
概览缓存、以及 dev-mock 不得加缓存)。
2026-10-01 15:36:46 +08:00
yml2213 21cd9135a2 perf(worker): 「我的订单」列表改成先取 id 再取详情,排序阶段不再物化宽列
打手端我的订单的排序是表达式:
  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
它依赖打手参数与拼单表,**没有任何索引能支撑**,规划器只能把该打手全部可见单
连同宽列(每行约 1.2KB material_json)物化后排序。生产实测(真实 service 路径):
名下 6609 单的打手首访 1112ms、5198 单的 808ms、3140 单的 772ms,每次读约 60MB
(列表缓存 2 秒一失效,所以这个代价每天要重复付很多次)。

改法:该排序走"延迟分页"——第一阶段只 SELECT wo.id(窄投影 + 同一 ORDER BY +
同一 LIMIT/OFFSET),第二阶段用 `WHERE wo.id = ANY($1::bigint[])` 取这一页的详情,
再按 id 顺序还原(ANY 不保证顺序)。行集、顺序、总数语义完全不变。

验证:后端 542 项单测全绿(新增 1 项守卫);生产上先用部署前的 id 顺序留底,
部署后用同一批打手比对首 20 条 id 与总数,并对比首访耗时。
2026-10-01 15:29:49 +08:00
yml2213 987553909d perf(db): 统计的「状态+日期范围」组合补 created_at 键覆盖索引
087 以 id 为键覆盖了统计要读的窄列,但日期条件无法用它驱动扫描
(created_at 不是前导列),规划器因此对 work_orders 做整表 Hash Join:
生产实测该变体每次读 400~700MB —— 缓存热时 283~694ms,缓存有压力时 926ms+
并触发慢查询告警。而"状态 + 近 7/30 天"正是运营最常用的筛选组合。

迁移 088 补一条 created_at 键、覆盖同一批窄列的索引,让规划器改为
Bitmap Index Scan 只取日期范围内的行。生产实测(真实 service 路径):
  取 13,974 行 / 执行 75.7ms / 端到端 283ms(含列表查询),慢查询告警消失。
用"单次调用前后块差值"验证:51,521 块,与我 EXPLAIN 的 shared hit 完全一致。

与 087 的分工:无日期筛选走 id 键(index-only),有日期范围走本索引(bitmap)。
Bitmap Index Scan 无法 index-only,INCLUDE 的作用是让 heap 访问只落在日期范围内。
刻意不含 acceptance_json(勾 VIP 待补图时本就有意回表)。

验证:后端 541 项单测全绿(新增 1 项守卫)。
2026-10-01 15:23:57 +08:00
yml2213andCursor b6c0e06f45 style: 补齐筛选统计守卫测试的格式
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 15:05:56 +08:00
yml2213andCursor 1239393fc2 perf: 筛选统计去掉 JSON 兜底,并补上 orders 覆盖索引
去掉 work_orders 堆访问后,JOIN 会把 orders 整表扫进 Hash Right Join。
领取页在快照新鲜时不再打上游;快手出网统一 10 秒超时。倒计时改为自适应间隔。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 15:05:38 +08:00
yml2213 f4c405d51a fix(worker): 打手端实时补丁不再误删"按工单号搜到的行",并同步分页总数
这是后台那个"搜工单号搜不到、必须刷新整页"在打手端的同源副本:
WorkerLayout 的 operation = event.operation === 'remove' || !matches ? 'remove' : 'upsert'
只要前端判"不匹配"就删行,而前端关键词匹配器**没有比对工单号**——
后端搜索表达式的第一项恰恰就是 work_order_no(还有 material_json 里更多前端不知道的字段)。
于是按工单号搜索时,该单一旦有实时变更,行就被从缓存删掉、列表变空。

修法与后台侧对齐:
1. 抽出 utils/worker-realtime-work-orders.ts(可单测、无运行时依赖),关键词匹配补上
   workOrderNo,并改成三态判定——状态/分类等可证伪维度不符才删行;关键词能找到=match
   (允许插入),找不到=unknown(不删不插,只就地更新)。
2. patchWorkerWorkOrderListEnvelope 增删行时同步 data.pagination.total,
   修掉"列表空了、分页器还显示有 N 条"。
3. 大厅无限下拉只在第 1 页允许插入,其余页只就地更新。

验证:前端 86 项单测全绿(新增 6 项,覆盖"按工单号搜索+实时变更不得删行"、
unknown 只就地更新、删/插同步总数、无限下拉页边界)。
2026-10-01 13:48:05 +08:00
yml2213 2d75be702d perf(db): 补齐三处"只差索引"的热查询缺口
审计方式:pg_stat_statements 按"每次调用读取块数"排序 + EXPLAIN (ANALYZE, BUFFERS) 复现,
每条都先在线上并发建索引验证有效再固化(生产上索引已存在,IF NOT EXISTS 跳过)。

① 后台订单列表:COALESCE(paid_at, created_at) 同时出现在 WHERE 与 ORDER BY,
   函数包裹列非 sargable —— 默认"近 7 天"视角本身就会全表扫 orders 再排序。
   实测 26 万行/443MB:Parallel Seq Scan 读 256MB、577ms
   → Index Scan 读 0.3MB、2.05ms(约 280 倍)。
② 重跑快手补单:NOT EXISTS 打在 work_orders.order_id 上,而该列此前**没有索引**,
   反连接只能 Hash Anti Join —— 为几十个候选 id 顺序扫完 500MB 宽表,LIMIT 100 拦不住。
   实测 188MB、120ms → Index Only Scan 1.2ms(约 100 倍)。同时补 orders(order_status)。
③ 快手凭证"同步源"列表的 (oid, updated_at DESC, id DESC):29.6 万行/678MB,实测 568MB、5.5 秒。
   仅建索引不足以改变计划(查询写法要求先物化全部 oid,见审计报告),但它是后续
   改写查询的前提,先建好避免再等一次大表建索引。

验证:后端 534 项单测全绿(新增 1 项守卫)。
2026-10-01 13:48:05 +08:00
yml2213 92d7887108 perf(db): 运营态计数/统计加覆盖索引,消除 248MB/次的全表扫
pg_stat_statements 重置后 25 分钟窗口显示,work_orders 相关查询里每次调用读取量最大的
全是"按运营态筛选"的形状:
  SELECT COUNT(*) FROM work_orders wo LEFT JOIN (<拼单汇总>) WHERE <运营态 CASE>
    87 次调用、每次约 248MB、均 347ms
  筛选统计的同形查询多次,248~545MB/次
根因:运营态不是存储列而是算出来的 CASE(依赖 status/sharing_enabled/sharing_total_quantity
+ 拼单汇总),规划器只能逐行求值,EXPLAIN 显示 "Rows Removed by Filter: 95923" ——
把 9.6 万行、约 533MB 的宽表整张读出来再丢掉。

迁移 085 给 CASE 用到的三列建覆盖索引(约 4MB,堆表的 1/130):
同一查询实测 248MB → 22MB、184ms → 75ms(IO 降 11 倍)。
status 本来就有索引、状态流转本来也走不了 HOT 更新,故不额外增加写放大。

验证:后端 533 项单测全绿(新增 1 项守卫);生产真机 EXPLAIN (ANALYZE, BUFFERS) 前后对比。
2026-10-01 13:30:06 +08:00
yml2213 3ab897963d perf(db): 打手端「我的订单」概览加覆盖索引与短缓存
这是数据库累计耗时第一的热路径:32 天 130 万次调用(0.5 次/秒),平均 99.8ms,
但名下订单多的打手会退化到 4.5~9 秒。EXPLAIN (ANALYZE) 定位到根因:
查询只用到 8126 个工单 id,规划器却选了「work_orders 全表顺序扫 + Hash Join」——
为几千个 id 扫 9.6 万行宽表(500MB),单这一步 4.1 秒。

1. 迁移 084:给查询真正需要的列建覆盖索引(id + INCLUDE status/assigned_worker_id/
   reward_amount/acceptance_json)。把那次全表扫变成 index-only scan,代价从 31015 降到约 5k,
   规划器自然改选索引;索引 34MB,是堆表的十五分之一。生产实测同一查询 14.5 秒 → 1.5 秒(冷缓存)。
   试过用 LATERAL 强制回表,规划器会把它拉平成同样的 Hash Join,所以只能从索引侧解决。
   迁移在事务内执行,故用普通 CREATE INDEX(不用 CONCURRENTLY)。
2. 概览读取加 5 秒短缓存:把"计算次数"与"页面轮询次数"解耦。
   只缓存读取——会话校验与 settleOverdueWorkOrders(写副作用)每次照常执行,否则会漏结算。

验证:后端 532 项单测全绿(新增 1 项守卫,覆盖索引列与缓存位置都纳入断言)。
2026-10-01 13:12:42 +08:00
yml2213 2f2d44f601 perf(admin-worker): 筛选统计加 10 秒短缓存,把成本增长与访问量解耦
金额落到派生列后,统计查询从生产实测 3.5~4.4 秒降到 ~400ms(约 9 倍),
但仍要扫全部命中工单(9.6 万行宽表 + 关联 orders + 拼单汇总),成本随工单量线性增长,
数据再翻几倍会重新撞上 500ms 慢查询线。

因此按"筛选指纹"缓存 10 秒并合并并发请求(WorkerListCache.getOrSet):
单位时间内的计算次数被封顶在 TTL 上,不再跟着刷新次数走;多人同时刷列表只会算一次。
代价是统计与分页总数最多滞后 10 秒,期间前端已由实时事件快照就地修正 statistics,
刷新后自动对齐。

指纹覆盖 statuses/status/workOrderId/keyword/workerSharingId/workerId/workerLevelIds/
categoryId/acceptanceMode/giftPhase/vipEvidencePending/useOperationalStatus/创建日期,
并加静态守卫防止后续漏字段导致不同筛选互相污染。

验证:后端 531 项单测全绿(新增 1 项守卫)。
2026-10-01 13:02:09 +08:00
yml2213 1ca15e9025 perf(db): 统计金额落到列上、会话刷新节流、补齐保留与内存治理
背景:生产 32 天内 >500ms 慢查询从 2.4k/天涨到 1.3 万/天(09-26 尖峰 14.3 万),
pg_stat_statements 显示后台工单列表族占 57%、打手端 visible_orders 占 27%;
同时 backend 容器十天从 194MB 涨到 754MB(768MB 限额的 98%),贴顶后整体变慢。

1. 新增迁移 083:work_orders 增加派生列 source_order_amount_fen,并从
   material_json.source.orderAmountFen 回填(只认 15 位以内整数,与 JS/SQL 口径一致)。
   统计查询原来每行要解析 1.2KB JSON(全表约 132MB)且同一表达式求值两次,
   生产实测 3.5~4.4 秒;改走派生列后 COALESCE 短路,未命中列的行才回退 JSON/orders.total_amount,
   口径与改造前完全一致。写入点:createWorkOrder / updateWorkOrder 各一处,并加静态守卫。
2. worker_sessions.last_seen_at 从"每个请求都写"改为"5 分钟才写一次":
   32 天被写了 3300 万次(占 DB 总时间 2.8%,并堆出 14% 死元组)。仍用一条语句返回鉴权有效性
   (EXISTS target AS valid),不能因为不写就让上层 401。
3. 保留策略补上 work_order_events(71.7 万行 / 269MB,此前完全没纳入清理,只增不减),
   新增配置 retention.workOrderEventDays(默认 180 天)与 WORK_ORDER_EVENT_RETENTION_DAYS 出口。
4. 内存治理:compose 后端限额 768m→1g 并显式设置 V8 老生代上限(--max-old-space-size=768),
   避免贴 cgroup 上限后 GC 抖动;新增每 10 分钟的进程内存快照日志(rss/heapUsed/heapLimit),
   用于区分"正常 GC 曲线"与"真泄漏"。
5. 限流桶加 5 万条硬上限:key 里含请求体字段(collect 的 orderNo)时可被构造成高基数,
   只按过期清理挡不住内存增长。

验证:后端 530 项单测全绿(新增 11 项,含派生列/会话/保留的静态守卫与解析函数单测)。
2026-10-01 12:55:09 +08:00
yml2213 d262b22a41 fix(admin-worker): 实时补丁同步筛选统计,并锁住"按工单号搜索结果被删行"的回归
线上现象:按工单号搜到的那一单,状态一变(实时 SSE),列表就变成「暂无数据」,而上方
筛选统计仍显示「共 1 单」;整页刷新后才恢复。

成因在前端实时快照补丁:旧逻辑里的 matchesWorkOrderKeyword 只比对「商品名 + 平台单号」,
不比对「工单号」,所以按 WO… 搜出来的行一旦有实时变更就被判成不匹配,直接从缓存里删掉;
而 statistics 是首次响应里带出来的、没被同步,于是出现"统计说有、列表没有"的自相矛盾。
(删行本身已由上一条提交改为:关键词筛选归入「不可证真」→ 只原地更新。)

本条补齐剩下的一致性:
1. 实时补丁增删/原地更新行时,同步维护 data.statistics 的 totalCount 与 statusCounts
   (statusCounts 按运营态口径记账,与后端 getWorkOrderStatistics 一致),列表与统计不再打架;
2. 补两个回归测试:按工单号搜到的行在状态变化后必须原地保留;插入/移除/原地改状态三种
   情况下统计都要跟得上。

验证:前端 80 项单测全绿(新增 2 项),lint / typecheck / build 通过。
2026-10-01 12:35:59 +08:00
yml2213 9573acc59d fix(consignment): 问题单「退回大厅」也要解绑寄卖现货
问题单处置的「退回大厅」只把订单置回 open,没有解绑被本工单占用的寄卖现货,也没摘 material 上的
寄售标记。后果是卖家端「我的寄卖」把这张单显示成「履约中 + 关联订单状态已变化,请联系客服核对」
(状态机只认 in_progress / problem / pending_acceptance,open 落到兜底分支),而现货永久停在
consumed 占库不还、既不再参与匹配也没有释放入口。

其余同义路径(后台退回大厅、超时重开、打手退单通过)本来都会解绑,只有问题单这条漏了,属口径不一致。
现在把解绑条件从「仅取消」扩到「取消或退回大厅」;「退回打手重做」保持占用(货还在这一单上履约)。
调用位置仍在状态 UPDATE 之前——该 UPDATE 在 open/cancelled 时会清空 friend_added_at,
清完再解绑会把已确认加好友的货误判成可回池(二次出货)。

验证:真实库跑通两条路径——退回打手重做(现货仍 consumed+绑定的、标记保留、打手端仍「已匹配-待加好友」)
与退回大厅(现货回 in_stock、解绑、标记摘除、打手端变「已通过(在库待匹配)」);后端 520 项单测全绿。
2026-10-01 12:22:56 +08:00
yml2213 40ee36d72a fix(consignment): 修复寄卖取消解绑、SUB 单号写空、拼单门禁与后台工单列表实时乱跳
1. 问题单处置「取消」后打手信息不消失:resolveProblemWorkOrder 的取消分支从未解绑寄卖现货,现货永久停在 consumed 占库不还,工单 material 上的寄售标记残留又让列表继续显示「供货打手」。取消时补上解绑;调用必须排在状态 UPDATE 之前——那条 UPDATE 会清空 friend_added_at,清完再解绑会把已确认加好友的货误判成可回池(二次出货);
2. 解绑函数在没有现货可解绑时直接 return,跳过了摘标记,于是「库存行早已不存在」的老帖永远摘不掉寄售标记(列表一直显示供货打手)。现在无论是否解绑到现货都摘标记,痕迹保留在 material.consignmentHistory 的 detachReason 里;
3. 内部现货履约写入的 sourceSubmissionNo 恒为空:取的是占库 `UPDATE ... RETURNING *` 的行,只带回本表列,没有 join 出来的 submission_no,导致工单列表把所有寄卖单都显示成「老单无提报单号」,SUB 单号这个唯一锚点丢失。改取带 join 的预读库存(内部现货资料、指派工单资料、指派事件留痕三处)。存量老单按业务口径不回填;
4. 后台接单工单列表实时乱跳:SSE 快照补丁按数组下标读 queryKey,面板 key 加了字段后下标全错(page 被读成 targetWorkOrderId → 任何页都被当成第 1 页),且完全没判「内部寄售匹配」等筛选,于是任意一条不在当前页的老单只要发生变更就被插到列表顶部。改为结构化 queryKey + 三态判定(match 才允许插顶、mismatch 才允许移除、其余只原地更新),插入还要求 page===1 且工单 id 大于当前首行;状态比对改用 rawStatus(快照的 status 是运营态,拼单母单会被显示成 in_progress),分类筛选因快照不带 parentCategoryId 归入不可证伪;
5. 新增「寄卖只做整单」硬门禁:已有人参与的拼单工单不参与现货匹配与库存提醒,履约入口一律返回 work_order_sharing_not_consignable,前端「手动匹配寄卖」入口按同一口径收敛;开了拼单但无人参与的整单不受影响,避免过度拦截。

验证:后端 520 项单测(517 pass / 3 skip)、前端 78 项全绿;并对真实开发库跑通问题单取消三类场景(老帖无库存行 / 回库 / 已加好友作废)、退回打手与退回大厅的反向用例(不得解绑)、履约写入真实 SUB 单号、拼单门禁四入口 + 未过度拦截反向用例。
2026-10-01 12:17:00 +08:00
yml2213 610c65bb34 docs: 新增发单系统与寄售仓库技术分析报告
1. docs/项目分析报告-发单系统与寄售仓库.md:项目全景(实测文件/行数、测试结果、迁移规模)、后台发单链路与工单状态机、寄售仓库数据模型与匹配规则、按优先级排序的风险总表与改进路线;
2. docs/寄卖子系统技术分析报告.md:寄卖子系统逐项分析,含端到端流程、077-082 数据模型、核心服务清单、库存与分配状态机、API 清单、风险与测试缺口;
3. 两份报告中的资金与安全类结论均已对照源码复核,标注「待确认」的属需要业务口径拍板、而非代码缺陷的项。
2026-10-01 11:07:36 +08:00
yml2213 1636616ec1 feat(worker-platform): 寄卖价格改由接单模板只读定价,我的寄卖支持服务端搜索与分页
1. 寄售价不再由卖家填写:打手端改为只读展示模板价(与履约押金同款只读框),服务端在发布时按同一套规则复算金额,保证展示口径与落库口径一致,直连接口也无法随意定价;
2. 模板缺档按「最接近的已配置档位」报价、距离相同取更低档:后台只配了 1/3/7 钻时,号主出 2 钻报 1 钻价、出 6 钻报 7 钻价,不再因为中间档没配价而拿不到价格;
3. 我的寄卖列表把分组、关键字、翻页全部下沉到 SQL:新增 listWorkerConsignmentPage 用 UNION ALL 合成「提报单行 + 无提报单库存兜底行」,CASE 推导 待审核/履约中/已完成 分组,返回当前分组 total 与三个分组的角标计数;
4. 关键字覆盖寄卖单号、规格名、系统/区服/系列/钻级、卖家角色名与数字 ID、联系方式、买家昵称与游戏 ID(读取 work_orders.material_json.collect.fields 的多种历史键名);
5. 前端搜索框 300ms 防抖后才请求服务端、换分组或换关键字自动回到第 1 页、列表底部增加分页器,并移除原来固定 pageSize 200 的隐形截断;
6. 新增仓库层与匹配层单测,其中包含 SQL 分组 CASE 与 domain/consignment-item-stage.ts 的 group 映射交叉断言,防止两处状态机漂移;后端 516 项、前端 66 项测试全部通过。
2026-10-01 11:07:28 +08:00
yml2213andCursor ef491660e7 fix(admin-worker): 发单金额高于用户付款时强制确认,避免只靠红色字提醒
发布、指派和改价都会弹出亏损确认,且不能用「不再提示」跳过;列表发单金额旁增加「高于付款」标签。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 15:48:23 +08:00
yml2213andCursor 9298b31b9b feat(worker-platform): 增加寄卖白名单总开关,关闭后全员可发布
资金配置与打手列表可一键切换白名单模式;发布校验、打手端入口按总开关放行。同时收紧未开通判定、关权限确认和开通弹窗文案。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 14:23:00 +08:00
yml2213 937e51db6b fix(admin-worker): 修复打手管理切换寄卖权限后表格状态未自动刷新的问题
1. 修复 WorkersPanel 中 queryKey 匹配偏差,改用统一的 admin-worker-platform-workers 查询键与 refreshWorkers();
2. 引入 setQueriesData 乐观更新机制,点击 Switch 切换后毫秒级原地更新行状态,无需手动刷新页面。
2026-09-21 13:37:54 +08:00
yml2213 cb8b249e57 feat(worker-platform): 实现打手寄卖白名单权限控制与存量发布打手平滑过渡
1. 新增数据库迁移 082_worker_consignment_whitelist.sql,worker_users 表增加 can_consign 与 consignment_activated_at 字段,存量在 consignment_submissions 提报过的打手默认自动开启;
2. 打手端提报接口 publishWorkerConsignment 增加白名单权限服务端强校验拦截(403 consignment_permission_required);
3. 管理后台打手管理表格增加「寄卖权限」实时 Switch 开关列与打手详情抽屉权限展示,支持客服即时开通或关闭打手寄卖权限;
4. 打手端我要寄卖页面在未开通权限时展示专属白名单引导卡片,一键呼起联系客服开通寄卖弹窗;
5. 补充寄卖权限拦截与路由单测,前端59项单测与后端507项单测全部通过。
2026-09-21 13:30:33 +08:00
yml2213 0df5c9f34e feat(worker-platform): 抢单大厅首页新增联系客服胶囊入口并抽象通用客服弹窗组件
1. 抽离打手端通用联系客服弹窗组件 WorkerContactModal,统一维护微信二维码、打手账号/状态/余额回显、一键复制及沟通提示;
2. 抢单大厅移动端顶部状态栏(余额上限右侧首屏黄金位)新增精致微动效【联系客服】胶囊按钮,大幅缩短找客服沟通链路;
3. 抢单大厅桌面端头部操作栏同步补齐【联系客服】按钮,并在个人中心账号弹窗中统一复用 WorkerContactModal;
4. 适配移动端与超窄屏自适应响应式样式,完成前端59项测试与全量打包构建验证。
2026-09-21 12:37:38 +08:00
yml2213 a4f6f5ed9b style(worker-portal): 优化打手端我要寄卖页面顶部卡片布局,轻量化重构首屏体验
1. 移除第 4 个占位无意义的「发布寄卖 苏尔南」卡片,核心指标精准收敛为待审核、履约中、已完成 3 大状态;
2. 新增 worker-consignment-kpi-grid 紧凑统计栏,移动端改为横向 3 列等宽平铺,高度由 320px+ 骤降至 52px,竖向空间节省超 80%;
3. 剔除卡片内无实际价值的长句副说明文案,改为上行轻量状态标签 + 下行醒目数字的微排版,支持点击秒切记录过滤;
4. 彻底解决移动端首屏被巨幅卡片占满导致表单下沉的问题,首屏直接呈现履约押金提示与规格选择,提升打手发布效率;
5. 前端 59 个自动化测试全绿,全套 check 与 build 100% 通过。
2026-09-21 12:31:03 +08:00