From c8a4b7e94561c0a078691ec946dba384997945d2 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 3 Oct 2026 14:27:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(worker):=20=E5=88=A0=E9=99=A4=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E6=AE=8B=E7=95=99=E5=AD=97=E6=AE=B5=20task=5Fid=20?= =?UTF-8?q?=E7=9A=84=E6=AD=BB=20JOIN=EF=BC=8C=E5=BA=97=E9=93=BA=E5=90=8D?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E6=94=B9=E8=AF=BB=20material=5Fjson?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核查结论: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)。 --- ..._annotate_vestigial_work_order_task_id.sql | 30 ++++++++++++ .../worker-platform/after-sales-repo.ts | 8 ++-- .../work-order-management-repo.ts | 2 + .../worker-cancel-request-repo.ts | 12 +++-- .../worker-order-feedback-repo.ts | 8 ++-- .../worker-platform-service.test.ts | 46 +++++++++++++++++++ 6 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 apps/backend/src/db/migrations/091_annotate_vestigial_work_order_task_id.sql diff --git a/apps/backend/src/db/migrations/091_annotate_vestigial_work_order_task_id.sql b/apps/backend/src/db/migrations/091_annotate_vestigial_work_order_task_id.sql new file mode 100644 index 00000000..683ffe46 --- /dev/null +++ b/apps/backend/src/db/migrations/091_annotate_vestigial_work_order_task_id.sql @@ -0,0 +1,30 @@ +-- 091_annotate_vestigial_work_order_task_id.sql —— 标注残留字段 work_orders.task_id。 +-- +-- 该列自迁移 004 引入,设计意图是把接单工单关联到履约任务(fulfillment_tasks)。 +-- 但生产核查(2026-10-03,库 order_site @47.114.114.225)确认它从未被写入过, +-- 且结构上也无从关联: +-- +-- 1) 唯一写入点是 createWorkOrder 的 `input.taskId || null`,而两个调用方 +-- 一个是硬编码 `taskId: null`(sync-worker-orders-service),另一个根本不传; +-- updateWorkOrder 的 patch 白名单里也没有这一列; +-- git 全历史检索 `taskId:` 只出现过 null 一种取值。 +-- +-- 2) 即便强行写值也无从关联:工单关联 100,231 个订单、履约任务关联 87,603 个订单, +-- 交集为 0;按 order_item_id 比对 100,229 条同样 0 命中。而两者的时间范围 +-- (2026-07 ~ 10)与 orders.id 区间(1 ~ 280k)却都是重叠的 —— 说明两套系统 +-- 是按规则表分流的两条并行履约通道(命中 work_product_rules 建工单、 +-- 命中 fulfillment_profiles 建履约任务),一单只走一条,本就不存在对应关系。 +-- +-- 3) 依赖该列的三处 `LEFT JOIN fulfillment_tasks ft ON ft.id = wo.task_id` +-- (取消申请 / 售后案 / 工单反馈)永远匹配不上;其中用作店铺名兜底的那部分 +-- 已改为读 wo.material_json->'source'->>'shopName'(生产实测覆盖 2478 条 +-- 中 2476 条),死 JOIN 已删除。 +-- +-- 结论:保留列与索引(drop 不可逆,且类型定义与类型行多处引用),仅作标注, +-- 避免后续再被当成"数据没同步上来"重复排查。 + +COMMENT ON COLUMN work_orders.task_id IS + '【残留字段,勿用】设计上用于关联履约任务,但无任何代码写入,且工单与履约任务的订单集合零交集(order_id / order_item_id 比对均为 0)。需要店铺名等信息请读 material_json->''source'',不要 JOIN fulfillment_tasks。'; + +COMMENT ON INDEX idx_work_orders_task_unique IS + '【残留索引】服务于永不写入的 work_orders.task_id,当前 0 行;保留以免重建时结构漂移。'; diff --git a/apps/backend/src/repositories/worker-platform/after-sales-repo.ts b/apps/backend/src/repositories/worker-platform/after-sales-repo.ts index c103cbdc..cbb9b63c 100644 --- a/apps/backend/src/repositories/worker-platform/after-sales-repo.ts +++ b/apps/backend/src/repositories/worker-platform/after-sales-repo.ts @@ -23,8 +23,11 @@ const AFTER_SALES_CASE_SELECT = ` COALESCE( NULLIF(BTRIM(o.shop_name), ''), NULLIF(BTRIM(o.shop_id), ''), - NULLIF(BTRIM(ft.shop_name), ''), - NULLIF(BTRIM(ft.shop_id), ''), + -- 原先兜底用 fulfillment_tasks,但 wo.task_id 是残留字段(无代码写入、 + -- 两套系统订单集合零交集),那个 LEFT JOIN 永远匹配不上,兜底形同虚设。 + -- 改用工单自己的 material_json 快照(生产实测覆盖 2,478 条中的 2,476 条)。 + NULLIF(BTRIM(wo.material_json->'source'->>'shopName'), ''), + NULLIF(BTRIM(wo.material_json->'source'->>'shopId'), ''), '' ) AS shop_name, wu.username AS worker_username, @@ -33,7 +36,6 @@ const AFTER_SALES_CASE_SELECT = ` FROM worker_after_sales_cases ac INNER JOIN work_orders wo ON wo.id = ac.work_order_id LEFT JOIN orders o ON o.id = wo.order_id - LEFT JOIN fulfillment_tasks ft ON ft.id = wo.task_id INNER JOIN worker_users wu ON wu.id = ac.worker_id LEFT JOIN work_order_shares wos ON wos.id = ac.work_order_share_id ` diff --git a/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts index dd33e00d..6d29eaf8 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-management-repo.ts @@ -36,6 +36,8 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise'source'->>'shopName' 实测覆盖其中 2,476 条。 COALESCE( NULLIF(BTRIM(o.shop_name), ''), NULLIF(BTRIM(o.shop_id), ''), - NULLIF(BTRIM(ft.shop_name), ''), - NULLIF(BTRIM(ft.shop_id), ''), + NULLIF(BTRIM(wo.material_json->'source'->>'shopName'), ''), + NULLIF(BTRIM(wo.material_json->'source'->>'shopId'), ''), '' ) AS shop_name, wo.product_name, @@ -34,7 +39,6 @@ const WORKER_CANCEL_REQUEST_SELECT = ` FROM worker_order_cancel_requests cr INNER JOIN work_orders wo ON wo.id = cr.work_order_id LEFT JOIN orders o ON o.id = wo.order_id - LEFT JOIN fulfillment_tasks ft ON ft.id = wo.task_id INNER JOIN worker_users wu ON wu.id = cr.worker_id LEFT JOIN work_order_shares wos ON wos.id = cr.work_order_share_id ` diff --git a/apps/backend/src/repositories/worker-platform/worker-order-feedback-repo.ts b/apps/backend/src/repositories/worker-platform/worker-order-feedback-repo.ts index 46d6d1ff..f1686f67 100644 --- a/apps/backend/src/repositories/worker-platform/worker-order-feedback-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-order-feedback-repo.ts @@ -13,8 +13,11 @@ const WORKER_ORDER_FEEDBACK_SELECT = ` COALESCE( NULLIF(BTRIM(o.shop_name), ''), NULLIF(BTRIM(o.shop_id), ''), - NULLIF(BTRIM(ft.shop_name), ''), - NULLIF(BTRIM(ft.shop_id), ''), + -- 原先兜底用 fulfillment_tasks,但 wo.task_id 是残留字段(无代码写入、 + -- 两套系统订单集合零交集),那个 LEFT JOIN 永远匹配不上,兜底形同虚设。 + -- 改用工单自己的 material_json 快照(生产实测覆盖 2,478 条中的 2,476 条)。 + NULLIF(BTRIM(wo.material_json->'source'->>'shopName'), ''), + NULLIF(BTRIM(wo.material_json->'source'->>'shopId'), ''), '' ) AS shop_name, wu.username AS worker_username, @@ -23,7 +26,6 @@ const WORKER_ORDER_FEEDBACK_SELECT = ` FROM worker_order_feedbacks wf INNER JOIN work_orders wo ON wo.id = wf.work_order_id LEFT JOIN orders o ON o.id = wo.order_id - LEFT JOIN fulfillment_tasks ft ON ft.id = wo.task_id INNER JOIN worker_users wu ON wu.id = wf.worker_id LEFT JOIN work_order_shares wos ON wos.id = wf.work_order_share_id ` diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index c5ed1bf8..01015891 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -2620,6 +2620,52 @@ test('筛选统计必须有短缓存 + 并发合并,避免成本随工单量 } }) +test('残留字段 work_orders.task_id 不得再被当成关联键 JOIN(迁移 091)', async () => { + const { readFile } = await import('node:fs/promises') + + // 背景:task_id 无任何代码写入(git 全历史只出现过 null),且工单与履约任务的订单 + // 集合零交集(order_id / order_item_id 比对均为 0,2026-10-03 生产核查)。 + // 三处 LEFT JOIN fulfillment_tasks 永远匹配不上,店铺名兜底已改为读 material_json。 + for (const name of [ + 'worker-cancel-request-repo.ts', + 'after-sales-repo.ts', + 'worker-order-feedback-repo.ts', + ]) { + const source = await readFile( + new URL(`../../repositories/worker-platform/${name}`, import.meta.url), + 'utf8', + ) + // 只断言不存在 JOIN(注释里可以解释这段历史),并确认兜底已切到 material_json + assert.ok( + !/\bJOIN\s+fulfillment_tasks\b/i.test(source), + `${name} 不应再 JOIN fulfillment_tasks:wo.task_id 是永不写入的残留字段,该连接恒为空`, + ) + assert.ok( + source.includes("material_json->'source'->>'shopName'"), + `${name} 的店铺名兜底必须读工单自己的 material_json 快照`, + ) + } + + // 写入点必须保留说明,避免下一个人又把 task_id 当成"数据没同步上来" + const managementRepo = await readFile( + new URL('../../repositories/worker-platform/work-order-management-repo.ts', import.meta.url), + 'utf8', + ) + assert.ok( + managementRepo.includes('残留字段'), + 'createWorkOrder 的 task_id 绑定处必须保留残留字段说明', + ) + + const migration = await readFile( + new URL('../../db/migrations/091_annotate_vestigial_work_order_task_id.sql', import.meta.url), + 'utf8', + ) + assert.ok( + migration.includes('COMMENT ON COLUMN work_orders.task_id'), + '必须给 work_orders.task_id 留下数据库注释', + ) +}) + test('打手端「我的订单」概览必须有覆盖索引 + 短缓存(数据库累计耗时第一的热路径)', async () => { const { readFile } = await import('node:fs/promises') // 覆盖索引:不改 SQL,靠 index-only scan 让规划器放弃全表顺序扫