Explorar o código

chore: merge origin/cfclub (集成远端最新构建)

iwt hai 3 semanas
pai
achega
1fdaa82427

+ 248 - 0
docs/superpowers/specs/2026-10-22-fulfillment-multi-mode-design.md

@@ -0,0 +1,248 @@
+# 后台多渠道发货设计(自提 / 快递 / 虚拟 / 同城配送)
+
+日期:2026-10-22
+状态:待用户审查
+作者:Sisyphus(AI)
+
+## 1. 背景与目标
+
+管理端商品订单的发货目前只支持两条路径:
+
+- `deliveryMethod=1`(快递):必填物流单号 + 快递公司 → 状态 `paid → pending_receipt`,成功后调微信 `upload_shipping_info`(`logistics_type` 硬编码 1)
+- `deliveryMethod=2`(自提):不填单号 → 状态 `paid → pending_receipt`,**跳过微信同步**
+
+订单表 `delivery_method` 枚举:1=快递、2=自提、3=两者皆可、4=线上-可选人、5=线上-单人。微信侧 `logistics_type` 支持 1 实体物流 / 2 同城配送 / 3 虚拟商品 / 4 用户自提。
+
+**目标**:管理端发货对齐微信小程序后台(mp.weixin.qq.com 交易管理)的四种发货方式,打通全部四种场景的本地履约 + 微信同步链路:
+
+| 发货方式 | 触发 | 单号 | 本地状态 | 微信 `logistics_type` |
+|---|---|---|---|---|
+| 快递 | 商家后台发货 | 必填 | `paid → pending_receipt` | 1 |
+| 同城配送 | 商家后台发货 | 必填 | `paid → pending_receipt` | 2 |
+| 用户自提 | 商家后台发货 | 不需要 | `paid → pending_receipt` | 4(新增,原为跳过) |
+| 虚拟商品 | **支付成功自动发货** | 不需要 | `paid → completed` | 3 |
+
+**范围说明(YAGNI)**:本规格只覆盖商品订单域(`product_orders`,订单号 PO 前缀)。`deliveryMethod=4/5`(线上服务者/时段)的履约链路已由服务者确认 + 用户确认收货承担,不纳入本次改造。虚拟支付域(A/ORD/SUB/HQ 前缀的测评/会员/订阅/健康计划)已有 `xpay_goods_deliver_notify` 链路,不在本次改造内。
+
+## 2. 现状分析(代码事实)
+
+### 2.1 后端
+
+- `WechatService.uploadShippingInfo(outTradeNo, buyerOpenid, trackingNo, expressCompany, itemDesc)`
+  - `logistics_type` 硬编码 1;`order_number_type=1`、`mchid + out_trade_no`
+  - 快递映射:顺丰→SF、中通→ZTO、圆通→YTO、韵达→YD、申通→STO、京东→JD、EMS
+  - fail-open:test-mode / `wechat.fulfillment-enabled=false` / 未配 mch-id 时跳过;失败只返回错误串,调用方不抛异常
+  - **发现的问题**:URL 为 `https://api.weixin.qq.com/sec/order/upload_shipping_info`,微信官方路径为 `https://api.weixin.qq.com/wxa/sec/order/upload_shipping_info`,**缺 `/wxa` 前缀**(提交 `08c4c0662` 引入)。fail-open 保护下不影响本地发货,但微信同步实际可能一直失败。本次一并修正为官方路径。
+- `ProductOrderService.ship(orderNo, trackingNumber, expressCompany, shippingCost)`
+  - `deliveryMethod==2` 走自提分支(不落单号),其余走快递分支
+  - 统一写 `logisticsNo/logisticsCompany` 列(提交 `2d58fff2b` 已修复字段双轨)
+  - 同步走 `syncShippingInfoToWechat(order, trackingNo, expressCompany)`:自提或单号为空则跳过
+- `ProductOrderService.updateExpress(...)`:改快递,已统一 `logisticsNo/logisticsCompany`
+- 支付回调 `processPaymentCallback(...)`(普通微信支付,PO 前缀)成功后无按 `productType` 的差异化处理(只有 physical/bundle 扣库存)
+- Controller:`AdminProductOrderController.ship/updateExpress`;`ProductOrderController.ship`(小程序端服务商)
+- 虚拟商品下单既不会扣库存(`ProductOrderService:1003` 附近仅 physical/bundle 处理)
+
+### 2.2 前端(cfc-web)
+
+- `OrderManage.vue`:列表页行内「发货」→ 发货对话框(物流公司 + 单号 + 快递费)
+- `OrderDetail.vue`:详情页「发货」按钮(status=paid)→ 发货对话框(物流公司 + 单号);配送方式文案 `order.deliveryMethod == 2 ? '自提' : '快递'`
+- `deliveryMethodLabel`:1 快递配送 / 2 到店自提 / 3 快递自提 / 4 线上服务 / 5 线上预约
+
+### 2.3 微信侧能力(官方文档确认)
+
+`POST /wxa/sec/order/upload_shipping_info`:
+
+- `logistics_type`:1 实体物流;2 同城配送;3 虚拟商品(无实体配送);4 用户自提
+- `delivery_mode`:1 统一发货(本项目用 1)
+- `shipping_list[]`:`tracking_no`、`express_company`(同城配送公司名或物流公司编码)、`item_desc`、`contact{consignor_contact, receiver_contact}`(掩码)
+- 微信侧对「同城配送」的 `express_company` 要求取「运力 id」(`get_delivery_list`),非普通快递编码表
+- 错误码:268485226 物流单号不能为空;268485227 物流公司编码不能为空
+
+## 3. 功能需求
+
+### 3.1 发货方式推导与显式指定
+
+- 推导规则(后端统一):
+  1. `productType == "virtual"` → 虚拟商品(**不进入手动发货流程,见 3.3**)
+  2. `deliveryMethod == 1` 或 `deliveryMethod == 3`(用户选了快递)→ 快递
+  3. `deliveryMethod == 2` → 自提
+  4. `deliveryMethod == 6`(同城配送,新增)→ 同城配送
+  5. `deliveryMethod == 3`(两者皆可且后台无法判定)/4/5 → 返回明确业务错误,要求前端显式传 `shipType`
+- Controller 层新增可选参数 `shipType`(express/local/virtual/selfPickup/null),非空时显式覆盖推导。虚拟商品也允许商家后台「补发货」(兜底幂等)。
+
+### 3.2 四种方式的行为矩阵
+
+| shipType | trackingNo | expressCompany | 本地状态 | 微信 logistics_type | 微信同步跳过条件 |
+|---|---|---|---|---|---|
+| express | 必填 | 必填 | → pending_receipt | 1 | fail-open 同现况 |
+| local | 必填 | 必填(同城运力商) | → pending_receipt | 2 | 同上 |
+| selfPickup | 拒收 | 拒收 | → pending_receipt | 4 | 买家无 openid |
+| virtual | 拒收 | 拒收 | → completed | 3 | 买家无 openid |
+
+- 快递/同城的单号/公司为空 → `Result.error(...)` 明确拒绝(不落库、不同步)
+- 自提/虚拟传入单号 → `Result.error(...)` 拒绝(与微信协议一致:无实体配送无单号)
+- 虚拟发货后订单终态 `completed`(无用户确认收货环节),仍允许「补发货」重复触发(幂等:状态已 completed 时直接返回提示)
+
+### 3.3 虚拟商品自动发货(核心新增)
+
+触发点:普通微信支付回调成功后(`ProductOrderService.processPaymentCallback` 尾部,与库存扣减/优惠券/CF 分佣等 try-catch 并列)。
+
+条件:`productType == "virtual"` 且订单状态 == `paid`(刚刚置为 paid 的当次回调)。
+
+动作(同一 try 块内 fail-open):
+1. `status = completed`、`updatedAt`,落库
+2. 调微信 `upload_shipping_info`(`logistics_type=3`,不带单号)
+3. 日志记录自动发货结果
+
+幂等:微信支付回调可能重复推送(`processPaymentCallback` 有 pending 状态守卫,理论上只入一次);补一层 `if ("completed".equals(order.getStatus()))` 前置判断,商家后台补发货同样走 `autoShipVirtualOrder` 同一方法。
+
+商家后台表现:虚拟商品订单支付后直接 `completed`,管理端订单列表/详情不显示「发货」按钮(已终态);如出现异常回到 `paid`,补发货走同一幂等方法。
+
+### 3.4 微信同步 URL 修正
+
+`WechatService.uploadShippingInfo` 的 URL 由 `https://api.weixin.qq.com/sec/order/upload_shipping_info` 改为 `https://api.weixin.qq.com/wxa/sec/order/upload_shipping_info`(官方文档路径)。fail-open 不变:失败仅记日志、返回错误串,不阻塞任何本地流程。
+
+## 4. 后端设计
+
+### 4.1 `WechatService.uploadShippingInfo` 扩展
+
+```
+public String uploadShippingInfo(String outTradeNo, String buyerOpenid,
+                                 String trackingNo, String expressCompany,
+                                 String itemDesc, int logisticsType)
+```
+
+- 保留旧 5 参签名(委托 `logisticsType=1`),兼容存量调用方(避免一次性改所有调用点)
+- `logisticsType` 取值 1/2/3/4:1/2 要求 trackingNo+company(空则直接返回错误串,不发请求);3/4 不带 `tracking_no`/`express_company`
+- `shipping_list[].item_desc` 维持 ≤120 字截断
+- `contact.receiver_contact`:新增可选参数 `receiverContact`(从订单 `addressSnapshot.phone` 取,掩码规则由微信侧处理),同城配送(2)与快递(1)时传,自提/虚拟不传
+- `logistics_type` 显式写入 requestBody,替换硬编码 1
+- URL 修正(3.4)
+
+### 4.2 `mapExpressCompanyCode` 补充同城配送映射
+
+新增常量/映射(微信运力 id 需以 `get_delivery_list` 实查为准,先内置常见项,未命中透传):
+
+| 中文名 | 编码 |
+|---|---|
+| 顺丰同城 | SF_TOWN(待实查运力 id 后校准) |
+| 闪送 | FLASH(待校准) |
+| 达达 | DADA(待校准) |
+| 美团配送 | MEITUAN(待校准) |
+
+> 说明:同城运力 id 必须来自微信「获取运力 id 列表」接口。规格定稿时先按上表占位实现(透传兜底),上线前用 `get_delivery_list` 校准常量值。若贵司未接入同城运力,同城订单的 `express_company` 也可填合作方自有编码。
+
+### 4.3 `ProductOrderService.ship` 四分支路由
+
+新增私有方法推导发货类型:
+
+```
+private ShipType resolveShipType(ProductOrder order, String shipTypeParam)  // 3.1 规则
+private boolean isVirtualProduct(ProductOrder order)   // productType == "virtual"
+```
+
+`ship(...)` 新签名(旧签名保留委托):
+
+```
+public Result<String> ship(String orderNo, String shipType,
+                           String trackingNumber, String expressCompany,
+                           Integer shippingCost, String receiverContact)
+```
+
+行为:
+1. 订单不存在 / 非 `paid`(虚拟补发货直接按 3.3 幂等放行:非 paid 且非 virtual 拒绝)→ 明确错误
+2. `resolveShipType` 推导/校验
+3. 校验入参(3.2 表)
+4. 按类型落库 + 状态流转(excp 更新 `fulfill_status`:虚拟直接 completed;其余 `confirmed_by_vendor`)
+5. `syncShippingInfoToWechat(order, shipType, trackingNo, company, receiverContact)` — 按 shipType 取 logisticsType
+6. 返回 `Result.success(...)`
+
+`syncShippingInfoToWechat` 改造:参数加入 shipType;不再「无条件跳过自提」;仅保留买家 openid 缺失、test-mode/开关/mch-id 缺失等 fail-open 条件。
+
+### 4.4 虚拟自动发货
+
+```
+// ProductOrderService
+private void autoShipVirtualOrder(ProductOrder order)   // 3.3 的完整实现(fail-open + 幂等)
+```
+
+在 `processPaymentCallback` 成功后调用(仓库现有 try-catch 区块旁)。商家后台补发货(shipType=virtual)复用它。
+
+### 4.5 Controller
+
+`AdminProductOrderController.ship(params)` 解析新增可选字段:
+
+```
+shipType(string: express|local|virtual|selfPickup)、receiverContact(string)
+```
+
+`ProductOrderController.ship`(小程序服务商端)同步接收 `shipType`/`receiverContact`,行为与管理端一致(服务商自己发货的场景)。
+
+`updateExpress` 维持只改快递(`shipType=express` 语义),不同步扩展;同城改单号复用 `update-express` 即可(逻辑同为 `syncShippingInfoToWechat`,按 `deliveryMethod` 推导)。
+
+### 4.6 schema / 实体 / 迁移
+
+| 变更 | 位置 | 说明 |
+|---|---|---|
+| `product_orders.delivery_method` COMMENT 增加 6=同城配送 | schema.sql + DatabaseInitializer 迁移 | MODIFY COLUMN,try-catch 幂等 |
+| `products.delivery_method` COMMENT 增加 6=同城配送 | schema.sql + DatabaseInitializer 迁移 | 同上 |
+| `ProductOrder.deliveryMethod` 注释同步 | 实体 | 6=同城配送 |
+
+注释级迁移(新增「迁移N」编号,搜索 `// 迁移` 取最新编号递增,`ALTER TABLE ... MODIFY COLUMN ... COMMENT '...'` 双层 try-catch)。无新列、无数据变更。
+
+## 5. 前端设计(cfc-web)
+
+### 5.1 `OrderManage.vue` + `OrderDetail.vue` 发货对话框
+
+按 `shipTarget.productType`/`deliveryMethod` 推导展示:
+
+- 虚拟商品:**不渲染发货按钮**(支付后已 completed);异常回到 paid 时按钮文案「自动发货/补发货」,点击直接确认(无输入项),调 ship 带 `shipType=virtual`
+- 自提(2):确认弹窗(无输入项),`shipType=selfPickup`
+- 快递(1)/两者皆可(3):现有表单(物流公司 + 单号 + 快递费),`shipType=express`
+- 同城(6):配送公司 + 单号(+收货人电话选填),`shipType=local`
+- 4/5(线上服务):隐藏发货按钮(履约走服务者确认链路)
+
+`deliveryMethodLabel` 增加 `6: '同城配送'`;`OrderDetail.vue` 的 `order.deliveryMethod == 2 ? '自提' : '快递'` 替换为统一 label 方法。
+
+### 5.2 小程序管理端(cfc-frontend)
+
+不在本次范围(本规格只改 cfc-web 后台 + 后端;小程序买家端只读展示,无需改动;`pages/vendor` 服务商发货入口见 4.5 Controller 同步扩展,前端调用参数可后续跟进)。
+
+## 6. 测试策略(TDD)
+
+新增 `ProductOrderServiceFulfillmentMultiModeTest`(JUnit5 + Mockito + ReflectionTestUtils,参照 `ProductOrderServiceUpdateExpressFieldTest` 模式):
+
+1. `ship_express_requiresTrackingAndCompany`:缺单号/缺公司 → `Result.error`,不落库、不调微信
+2. `ship_local_syncsWechatWithLogisticsType2`:同城发货 → 落库 `pending_receipt` + 微信同步 `logisticsType=2` + 单号/公司透传
+3. `ship_selfPickup_syncsWechatWithLogisticsType4`:自提发货 → 无单号 + 微信 `logisticsType=4`;传入单号 → 拒绝
+4. `ship_virtual_rejectsRemoteManualShip`/`ship_virtual_fillShipIdempotent`:虚拟传入单号拒绝;补发货幂等(completed 后重复调用返回成功提示、不重复同步微信)
+5. `autoShipVirtualOrder_onPaySuccess`:支付回调后虚拟订单 → status=completed + 微信 `logisticsType=3` + 无单号;买家无 openid 时 fail-open 不抛异常
+6. `resolveShipType_deliveryMethod3_requiresExplicitShipType`:两者皆可订单不显式传 shipType → 明确错误
+7. `uploadShippingInfo_urlContainsWxaPrefix`:微信 URL 断言 `/wxa/sec/order/upload_shipping_info`
+
+`WechatService` 层单测(可选,若现无 http mock 基建则跳过,由 Service 层 verify 覆盖)。
+
+无存量测试被破坏的验证方式:`git stash` 干净 HEAD 复跑对照组(上次修复同款流程)。
+
+## 7. 风险与回滚
+
+| 风险 | 缓解 |
+|---|---|
+| 微信 URL `/wxa` 修正后同步才真正生效,历史「已发货」订单实际未同步 | 失败只记日志(fail-open);如需补偿,可按 `paid→pending_receipt` 时段订单重推(后续可做管理端「手动补同步」,本次不做) |
+| 同城运力 id 占位码与微信真实 id 不符 | 透传兜底不阻塞;上线前用 `get_delivery_list` 校准常量(任务 4) |
+| 虚拟商品自动发货时机 | 幂等双守卫(状态 + productType);补发货同方法复用 |
+| `products.delivery_method` 增加 6 后商品编辑页未同步选项 | 前端任务中补充 ProductEdit 配送方式选项(radio-group) |
+| fail-open 掩盖微信侧失败 | 保留 log.warn/error;后续可在管理端加同步状态列(本次不做) |
+
+回滚:全部为增量改动,`git revert` 单 commit 即恢复;无数据删除、无列新增(仅 COMMENT 变更)。
+
+## 8. 任务拆分(粗略,供 writing-plans 细化)
+
+1. 后端:`WechatService.uploadShippingInfo` 增加 `logisticsType`/`receiverContact` + URL 修正 + 同城映射表(含失败测试先行)
+2. 后端:`ProductOrderService` ship 四分支 + resolveShipType + 参数校验(TDD)
+3. 后端:虚拟商品自动发货 `autoShipVirtualOrder` + 支付回调挂接(TDD)
+4. 后端:Controller 入参 `shipType`/`receiverContact`(管理端 + 小程序服务商端)
+5. schema/sql COMMENT 迁移 6=同城配送(DatabaseInitializer + schema.sql)
+6. 前端:OrderManage/OrderDetail 发货对话框按类型切换 + label 修正 + ProductEdit 增加 6 选项
+7. 文档:API_REFERENCE 4.8 补充发货方式矩阵;PROJECT-OVERVIEW 变更记录
+8. 编译验证(`mvn clean compile`)+ 存量测试对照(stash 复跑)+ 小程序审计脚本(如动 frontend)