Browse Source

fix(履约): 发货同步URL补/wxa前缀 + 多渠道履约

E2E Test Bot 3 weeks ago
parent
commit
9d6c9727cd

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3246,6 +3246,8 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移401: 存量规划师补登认证(D6:active guide 按已持有基础类别 planner certified 补登,避免误封存量)
         backfillExistingPlannerCertifications();
+        // 迁移402: delivery_method COMMENT 增加 6=同城配送(履约多渠道,规格 2026-10-22-fulfillment-multi-mode-design)
+        addDeliveryMethod6Comments();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -12985,4 +12987,27 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移401: 存量规划师认证补登失败: {}", e.getMessage());
         }
     }
+
+    /**
+     * 迁移402: delivery_method COMMENT 增加 6=同城配送(履约多渠道,规格 2026-10-22-fulfillment-multi-mode-design)。
+     * 仅 COMMENT 变更,无数据变更,幂等可重复执行(MODIFY COLUMN 天然幂等 + 双层 try-catch)。
+     */
+    private void addDeliveryMethod6Comments() {
+        try {
+            jdbcTemplate.execute("ALTER TABLE products MODIFY COLUMN delivery_method TINYINT DEFAULT 1 "
+                    + "COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人 6=同城配送'");
+            log.info("迁移402: products.delivery_method COMMENT 已增加 6=同城配送");
+        } catch (Exception e) {
+            // COMMENT 变更失败(如列不存在)不影响启动
+            log.warn("迁移402: products.delivery_method COMMENT 更新失败: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("ALTER TABLE product_orders MODIFY COLUMN delivery_method INT DEFAULT NULL "
+                    + "COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人 6=同城配送'");
+            log.info("迁移402: product_orders.delivery_method COMMENT 已增加 6=同城配送");
+        } catch (Exception e) {
+            // COMMENT 变更失败(如列不存在)不影响启动
+            log.warn("迁移402: product_orders.delivery_method COMMENT 更新失败: {}", e.getMessage());
+        }
+    }
 }

+ 3 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductOrderController.java

@@ -59,11 +59,13 @@ public class AdminProductOrderController {
     public Result<String> ship(@RequestBody Map<String, Object> params,
                                @RequestAttribute("userId") Long adminId) {
         String orderNo = (String) params.get("orderNo");
+        String shipType = (String) params.get("shipType");
         String trackingNumber = (String) params.get("trackingNumber");
         String expressCompany = (String) params.get("expressCompany");
+        String receiverContact = (String) params.get("receiverContact");
         Integer shippingCost = params.get("shippingCost") != null
                 ? Integer.valueOf(params.get("shippingCost").toString()) : null;
-        return orderService.ship(orderNo, trackingNumber, expressCompany, shippingCost);
+        return orderService.ship(orderNo, shipType, trackingNumber, expressCompany, shippingCost, receiverContact);
     }
 
     @Operation(summary = "更新订单快递信息")

+ 3 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductOrderController.java

@@ -110,9 +110,11 @@ private QrCodeService qrCodeService;
     @PostMapping("/ship")
     public Result<String> ship(@RequestBody Map<String, Object> params) {
         String orderNo = (String) params.get("orderNo");
+        String shipType = (String) params.get("shipType");
         String trackingNumber = (String) params.get("trackingNumber");
         String expressCompany = (String) params.get("expressCompany");
-        return orderService.ship(orderNo, trackingNumber, expressCompany, null);
+        String receiverContact = (String) params.get("receiverContact");
+        return orderService.ship(orderNo, shipType, trackingNumber, expressCompany, null, receiverContact);
     }
 
     // ========== 退款流程 ==========

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java

@@ -91,7 +91,7 @@ public class Product implements Serializable {
     private Long categoryId;               // 商品类目ID
     private Long distributionSystemId;     // 销售体系ID
 
-    private Integer deliveryMethod;        // 配送方式: 1=快递 2=自提 3=两者皆可
+    private Integer deliveryMethod;        // 配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人 6=同城配送
     @TableField("purchase_count_threshold")
     private Integer minQuantity;           // 最小购买数量限制
 

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java

@@ -37,7 +37,7 @@ public class ProductOrder implements Serializable {
     private String logisticsCompany;  // 物流公司
     private String trackingNumber;    // 快递单号
     private String expressCompany;    // 快递公司
-    private Integer deliveryMethod;   // 配送方式:1快递 2自提
+    private Integer deliveryMethod;   // 配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人 6=同城配送
     private String pickupCode;         // 自提码
     private Date confirmTime;          // 确认收货时间
     private Date paidAt;

+ 195 - 19
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -1194,6 +1194,15 @@ public class ProductOrderService {
         } catch (Exception e) {
             log.error("刷新通关解锁进度失败: orderNo={}", orderNo, e);
         }
+        // 虚拟商品自动发货(规格 §3.3):支付成功即置 completed + 同步微信 logistics_type=3(fail-open 幂等)
+        try {
+            if (isVirtualProduct(order)) {
+                Result<String> r = autoShipVirtualOrder(order);
+                log.info("虚拟商品订单支付成功自动发货: orderNo={}, result={}", orderNo, r != null ? r.getMessage() : "null");
+            }
+        } catch (Exception e) {
+            log.error("虚拟商品自动发货失败(不阻塞支付): orderNo={}, error={}", orderNo, e.getMessage(), e);
+        }
         return Result.success("支付成功");
     }
 
@@ -1268,29 +1277,95 @@ public class ProductOrderService {
     }
 
     public Result<String> ship(String orderNo, String trackingNumber, String expressCompany, Integer shippingCost) {
+        return ship(orderNo, null, trackingNumber, expressCompany, shippingCost, null);
+    }
+
+    /**
+     * 多渠道履约发货(自提 / 快递 / 同城 / 虚拟商品),行为矩阵见
+     * docs/superpowers/specs/2026-10-22-fulfillment-multi-mode-design.md §3.2。
+     *
+     * @param orderNo         订单号
+     * @param shipType        发货方式:express/local/selfPickup/virtual;null 时按订单推导(见 resolveShipType)
+     * @param trackingNumber  物流单号(express/local 必填;selfPickup/virtual 拒收)
+     * @param expressCompany  物流公司/同城运力商(express/local 必填;selfPickup/virtual 拒收)
+     * @param shippingCost    快递费(分)
+     * @param receiverContact 收货人电话(快递/同城同步微信时透传,自提/虚拟不传)
+     */
+    public Result<String> ship(String orderNo, String shipType,
+                               String trackingNumber, String expressCompany,
+                               Integer shippingCost, String receiverContact) {
         ProductOrder order = orderMapper.selectOne(
             new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
         if (order == null) {
             return Result.error("订单不存在");
         }
+        boolean isVirtual = isVirtualProduct(order);
+        // 虚拟商品:已终态(completed)或无实体配送 → 补发货走幂等自动发货;其余分支校验
+        if ("completed".equals(order.getStatus()) && isVirtual) {
+            return autoShipVirtualOrder(order);
+        }
         if (!"paid".equals(order.getStatus())) {
             return Result.error("仅已支付订单可发货");
         }
-        // For self-pickup orders, skip tracking info
-        if (order.getDeliveryMethod() != null && order.getDeliveryMethod() == 2) {
-            order.setStatus("pending_receipt");
-        } else {
-            order.setLogisticsNo(trackingNumber);
-            order.setLogisticsCompany(expressCompany);
-            order.setStatus("pending_receipt");
+        // 虚拟商品支付成功即自动发货(规范 §3.3),商家后台补发货复用幂等方法;
+        // 与微信协议一致:无实体配送无单号,传入单号/公司 → 拒绝
+        if (isVirtual) {
+            if ((trackingNumber != null && !trackingNumber.trim().isEmpty())
+                    || (expressCompany != null && !expressCompany.trim().isEmpty())) {
+                return Result.error("虚拟商品无需填写物流信息");
+            }
+            return autoShipVirtualOrder(order);
+        }
+        // 非虚拟商品:推导/校验发货类型
+        ShipType st = resolveShipType(order, shipType);
+        if (st == null) {
+            return Result.error("无法判断发货方式,请选择:快递发货或自提发货");
+        }
+        if (st == ShipType.VIRTUAL) {
+            // 显式船传 shipType=virtual 但订单不是虚拟商品 → 拒绝
+            return Result.error("该订单不是虚拟商品,无法按虚拟商品发货");
+        }
+        switch (st) {
+            case EXPRESS:
+                if (trackingNumber == null || trackingNumber.trim().isEmpty()) {
+                    return Result.error("请填写物流单号");
+                }
+                if (expressCompany == null || expressCompany.trim().isEmpty()) {
+                    return Result.error("请填写物流公司");
+                }
+                order.setLogisticsNo(trackingNumber);
+                order.setLogisticsCompany(expressCompany);
+                break;
+            case LOCAL:
+                if (trackingNumber == null || trackingNumber.trim().isEmpty()) {
+                    return Result.error("请填写同城配送单号");
+                }
+                if (expressCompany == null || expressCompany.trim().isEmpty()) {
+                    return Result.error("请填写同城配送公司");
+                }
+                order.setLogisticsNo(trackingNumber);
+                order.setLogisticsCompany(expressCompany);
+                break;
+            case SELF_PICKUP:
+            default:
+                if ((trackingNumber != null && !trackingNumber.trim().isEmpty())
+                        || (expressCompany != null && !expressCompany.trim().isEmpty())) {
+                    return Result.error("自提订单无需填写物流信息");
+                }
+                break;
         }
         if (shippingCost != null) {
             order.setShippingCost(shippingCost);
         }
+        order.setStatus("pending_receipt");
+        if (st == ShipType.SELF_PICKUP) {
+            // 履约扩展:自取/线上订单必须供应商先确认(confirmReceive 有校验),此处统一置为已确认
+            order.setFulfillStatus("confirmed_by_vendor");
+        }
         order.setUpdatedAt(new Date());
         orderMapper.updateById(order);
         // 发货成功后自动同步物流信息到微信小程序交易管理服务(fail-open:失败只记日志不阻塞)
-        syncShippingInfoToWechat(order, trackingNumber, expressCompany);
+        syncShippingInfoToWechat(order, st, trackingNumber, expressCompany, receiverContact);
         return Result.success("已发货");
     }
 
@@ -1308,29 +1383,130 @@ public class ProductOrderService {
         if (shippingCost != null) order.setShippingCost(shippingCost);
         order.setUpdatedAt(new Date());
         orderMapper.updateById(order);
-        // 快递信息更新后自动同步微信(部分更新时回退订单已存值)
-        syncShippingInfoToWechat(order,
+        // 快递信息更新后自动同步微信(部分更新时回退订单已存值);updateExpress 仅用于快递/同城
+        ShipType st = resolveShipTypeFromOrder(order);
+        syncShippingInfoToWechat(order, st,
                 trackingNumber != null ? trackingNumber : order.getLogisticsNo(),
-                expressCompany != null ? expressCompany : order.getLogisticsCompany());
+                expressCompany != null ? expressCompany : order.getLogisticsCompany(),
+                null);
         return Result.success("快递信息已更新");
     }
 
+    /**
+     * 发货方式类型:express=快递(微信logistics_type 1)、local=同城配送(2)、
+     * virtual=虚拟商品(3)、selfPickup=用户自提(4)。
+     */
+    private enum ShipType {
+        EXPRESS, LOCAL, SELF_PICKUP, VIRTUAL
+    }
+
+    /** 虚拟商品:productType == "virtual" */
+    private boolean isVirtualProduct(ProductOrder order) {
+        return order != null && "virtual".equals(order.getProductType());
+    }
+
+    /**
+     * 由订单推导发货类型(规格 §3.1)。
+     * 优先显式 shipTypeParam(express/local/selfPickup/virtual),其次按 productType/deliveryMethod 推导;
+     * deliveryMethod=3/4/5 或 NULL 订单层面无法判定 → 返回 null(调用方报业务错误,要求前端显式传入 shipType)。
+     */
+    private ShipType resolveShipType(ProductOrder order, String shipTypeParam) {
+        if (shipTypeParam != null && !shipTypeParam.trim().isEmpty()) {
+            String s = shipTypeParam.trim().toLowerCase();
+            if ("express".equals(s)) return ShipType.EXPRESS;
+            if ("local".equals(s)) return ShipType.LOCAL;
+            if ("selfpickup".equals(s)) return ShipType.SELF_PICKUP;
+            if ("virtual".equals(s)) return ShipType.VIRTUAL;
+            return null; // 未知类型
+        }
+        if (isVirtualProduct(order)) {
+            return ShipType.VIRTUAL;
+        }
+        Integer dm = order.getDeliveryMethod();
+        if (dm == null) {
+            // NULL:订单层面无法判定,要求前端显式传入 shipType
+            return null;
+        }
+        switch (dm) {
+            case 1: return ShipType.EXPRESS;
+            case 2: return ShipType.SELF_PICKUP;
+            case 6: return ShipType.LOCAL;
+            default: return null; // 3 两者皆可(应已被 validateFulfill 转成 1/2,出现属老数据);4/5 线上服务履约
+        }
+    }
+
+    /** 按订单 deliveryMethod 推导(updateExpress 改快递/同城场景复用):1快递/2自提/6同城;其余按 EXPRESS 兜底 */
+    private ShipType resolveShipTypeFromOrder(ProductOrder order) {
+        Integer dm = order.getDeliveryMethod();
+        if (dm == null) {
+            return ShipType.EXPRESS;
+        }
+        switch (dm) {
+            case 1: return ShipType.EXPRESS;
+            case 2: return ShipType.SELF_PICKUP;
+            case 6: return ShipType.LOCAL;
+            default: return ShipType.EXPRESS;
+        }
+    }
+
+    /**
+     * 虚拟商品自动发货(规格 §3.3):支付成功即置 completed + 调微信 upload_shipping_info(logistics_type=3)。
+     * fail-open + 幂等:状态已 completed 时直接返回提示不再重复同步。
+     */
+    private Result<String> autoShipVirtualOrder(ProductOrder order) {
+        if (isVirtualProduct(order) && "completed".equals(order.getStatus())) {
+            log.info("虚拟商品订单已交付(补发货幂等跳过): orderNo={}", order.getOrderNo());
+            return Result.success("该订单已自动交付");
+        }
+        order.setStatus("completed");
+        order.setUpdatedAt(new Date());
+        orderMapper.updateById(order);
+        // 同步微信(fail-open),买家无 openid 时微信侧跳过
+        try {
+            User buyer = userMapper.selectById(order.getBuyerId());
+            if (buyer == null || buyer.getOpenid() == null || buyer.getOpenid().isEmpty()) {
+                log.warn("虚拟商品买家无 openid,跳过微信发货同步: orderNo={}", order.getOrderNo());
+            } else {
+                String itemDesc = (order.getProductName() == null ? "" : order.getProductName())
+                        + " x" + (order.getQuantity() == null ? 1 : order.getQuantity());
+                if (itemDesc.length() > 120) {
+                    itemDesc = itemDesc.substring(0, 120);
+                }
+                String err = wechatService.uploadShippingInfo(order.getOrderNo(), buyer.getOpenid(),
+                        null, null, itemDesc, 3, null);
+                if (err != null) {
+                    log.warn("虚拟商品微信同步未成功(不阻塞): orderNo={}, reason={}", order.getOrderNo(), err);
+                }
+            }
+        } catch (Exception e) {
+            log.error("虚拟商品微信发货同步异常(不阻塞): orderNo={}, error={}", order.getOrderNo(), e.getMessage(), e);
+        }
+        log.info("虚拟商品自动发货完成: orderNo={}", order.getOrderNo());
+        return Result.success("自动发货成功");
+    }
+
     /**
      * 管理端发货/改快递成功后,自动把物流信息同步到微信「小程序交易管理服务」
      * (upload_shipping_info)。fail-open:失败只记日志,不阻塞本地发货。
-     * 自提订单(deliveryMethod=2)或无物流单号时跳过。
+     * 自提(4)/虚拟(3)订单:无单号不带;买家无 openid / 无单号时跳过。
      */
-    private void syncShippingInfoToWechat(ProductOrder order, String trackingNo, String expressCompany) {
+    private void syncShippingInfoToWechat(ProductOrder order, ShipType shipType,
+                                          String trackingNo, String expressCompany, String receiverContact) {
         try {
             if (order == null) {
                 return;
             }
-            // 仅实体物流同步:自提订单跳过
-            if (order.getDeliveryMethod() != null && order.getDeliveryMethod() == 2) {
-                log.info("自提订单跳过微信发货同步: orderNo={}", order.getOrderNo());
-                return;
+            int logisticsType;
+            switch (shipType) {
+                case LOCAL: logisticsType = 2; break;
+                case SELF_PICKUP: logisticsType = 4; break;
+                case VIRTUAL: logisticsType = 3; break;
+                case EXPRESS:
+                default: logisticsType = 1; break;
             }
-            if (trackingNo == null || trackingNo.trim().isEmpty()) {
+            // 无单号跳过(自提/虚拟本身无单号,属正常,不警告)
+            if ((logisticsType == 1 || logisticsType == 2)
+                    && (trackingNo == null || trackingNo.trim().isEmpty())) {
                 log.info("无物流单号,跳过微信发货同步: orderNo={}", order.getOrderNo());
                 return;
             }
@@ -1346,7 +1522,7 @@ public class ProductOrderService {
                 itemDesc = itemDesc.substring(0, 120);
             }
             String err = wechatService.uploadShippingInfo(order.getOrderNo(), buyer.getOpenid(),
-                    trackingNo, expressCompany, itemDesc);
+                    trackingNo, expressCompany, itemDesc, logisticsType, receiverContact);
             if (err != null) {
                 log.warn("微信发货同步未成功(不阻塞发货): orderNo={}, reason={}", order.getOrderNo(), err);
             }

+ 42 - 14
cfc-backend/src/main/java/com/etotem/cfc/service/WechatService.java

@@ -674,6 +674,11 @@ public class WechatService implements WechatServiceInterface {
         if (c.contains("申通")) return "STO";
         if (c.contains("京东")) return "JD";
         if (c.contains("EMS") || c.contains("邮政")) return "EMS";
+        // 同城配送运力(编码以微信 get_delivery_list 接口返回为准,本实现先用常见占位码,未命中透传)
+        if (c.contains("顺丰同城")) return "SF_TOWN";
+        if (c.contains("闪送")) return "FLASH";
+        if (c.contains("达达")) return "DADA";
+        if (c.contains("美团")) return "MEITUAN";
         return c;
     }
 
@@ -685,28 +690,36 @@ public class WechatService implements WechatServiceInterface {
      *
      * @param outTradeNo     商户订单号(下单时 out_trade_no = 订单号,order_number_type=1 用 mchid+out_trade_no 匹配微信订单)
      * @param buyerOpenid    买家 openid(payer.openid)
-     * @param trackingNo     物流单号
-     * @param expressCompany 快递公司(中文名,内部映射为微信编码;未命中透传)
+     * @param trackingNo     物流单号(express/local 必填;selfPickup/virtual 传 null)
+     * @param expressCompany 快递公司或同城运力商名(express/local 必填;selfPickup/virtual 传 null)
      * @param itemDesc       商品描述(≤120字,如 "商品名 x1")
-     * @return 成功返回 null;失败返回错误信息(不抛异常)
+     * @param logisticsType  微信物流类型:1=实体物流(快递) 2=同城配送 3=虚拟商品 4=用户自提
      */
     public String uploadShippingInfo(String outTradeNo, String buyerOpenid,
-                                     String trackingNo, String expressCompany, String itemDesc) {
+                                     String trackingNo, String expressCompany, String itemDesc,
+                                      int logisticsType, String receiverContact) {
         if (!isFulfillmentEnabled()) {
             log.info("发货同步微信已关闭(fulfillment-enabled=false),跳过 upload_shipping_info: orderNo={}", outTradeNo);
             return null;
         }
         if (testMode) {
-            log.info("测试模式:跳过 upload_shipping_info 同步微信: orderNo={}", outTradeNo);
+            log.info("测试模式:跳过 upload_shipping_info 同步微信: orderNo={}, logisticsType={}", outTradeNo, logisticsType);
             return null;
         }
         if (mchId == null || mchId.trim().isEmpty()) {
             log.warn("wechat.mch-id 未配置,跳过 upload_shipping_info: orderNo={}", outTradeNo);
             return null;
         }
+        // logisticsType=1/2 要求单号与公司;3/4 不带
+        if ((logisticsType == 1 || logisticsType == 2) && (trackingNo == null || trackingNo.trim().isEmpty())) {
+            return "微信发货物流单号不能为空(logisticsType=" + logisticsType + ")";
+        }
+        if ((logisticsType == 1 || logisticsType == 2) && (expressCompany == null || expressCompany.trim().isEmpty())) {
+            return "微信发货物流公司不能为空(logisticsType=" + logisticsType + ")";
+        }
         try {
             String accessToken = getAccessToken();
-            String url = "https://api.weixin.qq.com/sec/order/upload_shipping_info?access_token=" + accessToken;
+            String url = "https://api.weixin.qq.com/wxa/sec/order/upload_shipping_info?access_token=" + accessToken;
 
             JSONObject orderKey = new JSONObject();
             orderKey.put("order_number_type", 1); // 1=商户号+商户单号
@@ -717,16 +730,21 @@ public class WechatService implements WechatServiceInterface {
             payer.put("openid", buyerOpenid);
 
             JSONObject shipping = new JSONObject();
-            shipping.put("tracking_no", trackingNo);
-            shipping.put("express_company", mapExpressCompanyCode(expressCompany));
-            shipping.put("item_desc", itemDesc == null ? "" : itemDesc);
+            if (trackingNo != null) shipping.put("tracking_no", trackingNo);
+            if (expressCompany != null) shipping.put("express_company", mapExpressCompanyCode(expressCompany));
+            if (itemDesc != null) shipping.put("item_desc", itemDesc.length() > 120 ? itemDesc.substring(0, 120) : itemDesc);
+            if (receiverContact != null && !receiverContact.isEmpty()) {
+                JSONObject contact = new JSONObject();
+                contact.put("receiver_contact", receiverContact);
+                shipping.put("contact", contact);
+            }
             JSONArray shippingList = new JSONArray();
             shippingList.add(shipping);
 
             JSONObject requestBody = new JSONObject();
             requestBody.put("order_key", orderKey);
-            requestBody.put("logistics_type", 1); // 1=实体物流(自提/虚拟商品不调用)
-            requestBody.put("delivery_mode", 1);  // 1=统一发货(2=分拆发货)
+            requestBody.put("logistics_type", logisticsType);
+            requestBody.put("delivery_mode", 1);  // 1=统一发货
             requestBody.put("upload_time", new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX").format(new Date()));
             requestBody.put("payer", payer);
             requestBody.put("shipping_list", shippingList);
@@ -738,7 +756,9 @@ public class WechatService implements WechatServiceInterface {
 
             ResponseEntity<String> response = restTemplate.exchange(url,
                     org.springframework.http.HttpMethod.POST, entity, String.class);
-            JSONObject json = JSON.parseObject(response.getBody());
+            int httpStatus = response.getStatusCode().value();
+            String responseBody = response.getBody();
+            JSONObject json = JSON.parseObject(responseBody);
             int errcode = json.getIntValue("errcode");
             if (errcode == 0) {
                 // 顶层成功,但条目级可能部分失败(微信部分失败时顶层 errcode=0)
@@ -756,8 +776,8 @@ public class WechatService implements WechatServiceInterface {
                 log.info("微信发货信息录入成功: orderNo={}, trackingNo={}", outTradeNo, trackingNo);
                 return null;
             }
-            log.error("微信发货信息录入失败: orderNo={}, errcode={}, errmsg={}",
-                    outTradeNo, errcode, json.getString("errmsg"));
+            log.error("微信发货信息录入失败: orderNo={}, httpStatus={}, errcode={}, errmsg={}, respBody={}",
+                    outTradeNo, httpStatus, errcode, json.getString("errmsg"), responseBody);
             return "微信发货信息录入失败: " + json.getString("errmsg");
         } catch (Exception e) {
             // fail-open:失败只记日志,不阻塞本地发货
@@ -765,4 +785,12 @@ public class WechatService implements WechatServiceInterface {
             return "微信发货信息录入异常: " + e.getMessage();
         }
     }
+
+    /**
+     * 旧 5 参重载,委托新 7 参并默认 logisticsType=1(实体物流/快递)
+     */
+    public String uploadShippingInfo(String outTradeNo, String buyerOpenid,
+                                      String trackingNo, String expressCompany, String itemDesc) {
+        return uploadShippingInfo(outTradeNo, buyerOpenid, trackingNo, expressCompany, itemDesc, 1, null);
+    }
 }

+ 2 - 2
cfc-backend/src/main/resources/schema.sql

@@ -1179,7 +1179,7 @@ CREATE TABLE IF NOT EXISTS products (
     profit_rate INT DEFAULT 0 COMMENT '平台利润率(千分比)',
     sort_order INT DEFAULT 0 COMMENT '排序(值越小越靠前)',
     points_multiplier DECIMAL(10,2) NOT NULL DEFAULT 1.00 COMMENT '平台积分倍数,1元=1积分×倍数',
-    delivery_method TINYINT DEFAULT 1 COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人',
+    delivery_method TINYINT DEFAULT 1 COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人 6=同城配送',
     purchase_count_threshold INT DEFAULT 0 COMMENT '最小购买数量限制',
     category_id BIGINT COMMENT '商品类目ID',
     distribution_system_id BIGINT COMMENT '销售体系ID',
@@ -1340,7 +1340,7 @@ audit_reason TEXT COMMENT '审核拒绝原因',
     logistics_company VARCHAR(100) DEFAULT NULL COMMENT '物流公司',
     tracking_number VARCHAR(100) DEFAULT NULL COMMENT '快递单号(历史列,发货/改快递已统一写 logistics_no/logistics_company)',
     express_company VARCHAR(100) DEFAULT NULL COMMENT '快递公司(历史列,发货/改快递已统一写 logistics_no/logistics_company)',
-    delivery_method INT DEFAULT NULL COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人',
+    delivery_method INT DEFAULT NULL COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人 6=同城配送',
     pickup_code VARCHAR(64) DEFAULT NULL COMMENT '自提码',
     confirm_time DATETIME DEFAULT NULL COMMENT '确认收货时间',
     refund_status INT DEFAULT NULL COMMENT '退款状态',

+ 253 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ProductOrderServiceFulfillmentMultiModeTest.java

@@ -0,0 +1,253 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 多渠道履约发货测试(规格:docs/superpowers/specs/2026-10-22-fulfillment-multi-mode-design.md §6)
+ */
+public class ProductOrderServiceFulfillmentMultiModeTest {
+
+    private static final String ORDER_NO = "PO20261022001";
+
+    private ProductOrderService orderService;
+    private ProductOrderMapper orderMapper;
+    private UserMapper userMapper;
+    private WechatService wechatService;
+
+    @BeforeEach
+    public void setUp() {
+        orderService = new ProductOrderService();
+        orderMapper = mock(ProductOrderMapper.class);
+        userMapper = mock(UserMapper.class);
+        wechatService = mock(WechatService.class);
+        ReflectionTestUtils.setField(orderService, "orderMapper", orderMapper);
+        ReflectionTestUtils.setField(orderService, "userMapper", userMapper);
+        ReflectionTestUtils.setField(orderService, "wechatService", wechatService);
+    }
+
+    private ProductOrder paidOrder(Integer deliveryMethod, String productType) {
+        ProductOrder order = new ProductOrder();
+        order.setOrderNo(ORDER_NO);
+        order.setBuyerId(100L);
+        order.setProductName("测试商品");
+        order.setQuantity(1);
+        order.setStatus("paid");
+        order.setDeliveryMethod(deliveryMethod);
+        order.setProductType(productType);
+        return order;
+    }
+
+    private ProductOrder virtualPaidOrder() {
+        return paidOrder(1, "virtual");
+    }
+
+    private ProductOrder virtualCompletedOrder() {
+        ProductOrder order = virtualPaidOrder();
+        order.setStatus("completed");
+        return order;
+    }
+
+    private void stubBuyerWithOpenid() {
+        User buyer = new User();
+        buyer.setOpenid("oABC123");
+        when(userMapper.selectById(100L)).thenReturn(buyer);
+        when(wechatService.uploadShippingInfo(any(), any(), any(), any(), any(), any(), any())).thenReturn(null);
+    }
+
+    private void stubBuyerNoOpenid() {
+        User buyer = new User();
+        buyer.setOpenid("");
+        when(userMapper.selectById(100L)).thenReturn(buyer);
+    }
+
+    /** 1. 快递发货:缺单号/缺公司 → Result.error,不落库、不调微信 */
+    @Test
+    public void ship_express_requiresTrackingAndCompany() {
+        ProductOrder order = paidOrder(1, "physical");
+        when(orderMapper.selectOne(any())).thenReturn(order);
+        stubBuyerWithOpenid();
+
+        // 缺单号
+        Result<String> r1 = orderService.ship(ORDER_NO, "express", null, "顺丰速运", null, null);
+        assertEquals("请填写物流单号", r1.getMessage());
+        // 缺公司
+        Result<String> r2 = orderService.ship(ORDER_NO, "express", "SF123", null, null, null);
+        assertEquals("请填写物流公司", r2.getMessage());
+
+        // 验证:无 updateById、无微信同步
+        verify(orderMapper, never()).updateById(any());
+        verify(wechatService, never()).uploadShippingInfo(any(), any(), any(), any(), any(), any(), any());
+    }
+
+    /** 2. 同城发货 → 落库 pending_receipt + 微信同步 logisticsType=2 + 单号/公司透传 */
+    @Test
+    public void ship_local_syncsWechatWithLogisticsType2() {
+        ProductOrder order = paidOrder(6, "physical"); // deliveryMethod=6 同城配送
+        when(orderMapper.selectOne(any())).thenReturn(order);
+        stubBuyerWithOpenid();
+
+        Result<String> r = orderService.ship(ORDER_NO, "local", "FLASH123", "闪送", null, "13800138000");
+        assertEquals("已发货", r.getMessage());
+
+        // 落库:pending_receipt
+        ArgumentCaptor<ProductOrder> persisted = ArgumentCaptor.forClass(ProductOrder.class);
+        verify(orderMapper).updateById(persisted.capture());
+        ProductOrder saved = persisted.getValue();
+        assertEquals("FLASH123", saved.getLogisticsNo());
+        assertEquals("闪送", saved.getLogisticsCompany());
+        assertEquals("pending_receipt", saved.getStatus());
+
+        // 微信同步:logisticsType=2,单号/公司透传,receiverContact 透传
+        ArgumentCaptor<Integer> logisticsType = ArgumentCaptor.forClass(Integer.class);
+        ArgumentCaptor<String> trackingNo = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<String> expressCompany = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<String> receiverContact = ArgumentCaptor.forClass(String.class);
+        verify(wechatService).uploadShippingInfo(eq(ORDER_NO), eq("oABC123"),
+                trackingNo.capture(), expressCompany.capture(), any(),
+                logisticsType.capture(), receiverContact.capture());
+        assertEquals(2, logisticsType.getValue());
+        assertEquals("FLASH123", trackingNo.getValue());
+        assertEquals("闪送", expressCompany.getValue());
+        assertEquals("13800138000", receiverContact.getValue());
+    }
+
+    /** 3. 自提发货 → 无单号 + 微信 logisticsType=4;传入单号 → 拒绝 */
+    @Test
+    public void ship_selfPickup_syncsWechatWithLogisticsType4() {
+        ProductOrder order = paidOrder(2, "physical"); // deliveryMethod=2 自提
+        when(orderMapper.selectOne(any())).thenReturn(order);
+        stubBuyerWithOpenid();
+
+        // 正常自提:无单号、无公司
+        Result<String> r1 = orderService.ship(ORDER_NO, "selfPickup", null, null, null, null);
+        assertEquals("已发货", r1.getMessage());
+
+        // 微信同步:logisticsType=4,trackingNo/expressCompany 为 null
+        ArgumentCaptor<Integer> logisticsType = ArgumentCaptor.forClass(Integer.class);
+        ArgumentCaptor<String> trackingNo = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<String> expressCompany = ArgumentCaptor.forClass(String.class);
+        verify(wechatService).uploadShippingInfo(eq(ORDER_NO), eq("oABC123"),
+                trackingNo.capture(), expressCompany.capture(), any(),
+                logisticsType.capture(), any());
+        assertEquals(4, logisticsType.getValue());
+        assertEquals(null, trackingNo.getValue());
+        assertEquals(null, expressCompany.getValue());
+
+        // 传入单号 → 拒绝
+        Result<String> r2 = orderService.ship(ORDER_NO, "selfPickup", "ILLEGAL123", null, null, null);
+        assertEquals("自提订单无需填写物流信息", r2.getMessage());
+
+        // 传入公司 → 拒绝
+        Result<String> r3 = orderService.ship(ORDER_NO, "selfPickup", null, "非法公司", null, null);
+        assertEquals("自提订单无需填写物流信息", r3.getMessage());
+    }
+
+    /** 4. 虚拟商品:传入单号/公司拒绝;补发货幂等(completed 后重复调用返回成功提示、不重复同步微信) */
+    @Test
+    public void ship_virtual_rejectsManualShipAndIdempotent() {
+        // 4a. 虚拟商品 paid 状态:显式传单号/公司 → 拒绝
+        ProductOrder virtualPaid = virtualPaidOrder();
+        when(orderMapper.selectOne(any())).thenReturn(virtualPaid);
+        stubBuyerWithOpenid();
+
+        Result<String> r1 = orderService.ship(ORDER_NO, "virtual", "SF123", "顺丰", null, null);
+        assertEquals("虚拟商品无需填写物流信息", r1.getMessage());
+
+        // 4b. 虚拟商品 paid 状态:正常补发货(走 autoShipVirtualOrder)
+        Result<String> r2 = orderService.ship(ORDER_NO, "virtual", null, null, null, null);
+        assertEquals("自动发货成功", r2.getMessage());
+        // 状态变为 completed
+        ArgumentCaptor<ProductOrder> persisted = ArgumentCaptor.forClass(ProductOrder.class);
+        verify(orderMapper, org.mockito.Mockito.atLeastOnce()).updateById(persisted.capture());
+        ProductOrder saved = persisted.getValue();
+        assertEquals("completed", saved.getStatus());
+
+        // 微信同步:logisticsType=3(虚拟商品),无单号/公司
+        ArgumentCaptor<Integer> logisticsType = ArgumentCaptor.forClass(Integer.class);
+        ArgumentCaptor<String> trackingNo = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<String> expressCompany = ArgumentCaptor.forClass(String.class);
+        verify(wechatService).uploadShippingInfo(eq(ORDER_NO), eq("oABC123"),
+                trackingNo.capture(), expressCompany.capture(), any(),
+                logisticsType.capture(), any());
+        assertEquals(3, logisticsType.getValue());
+        assertEquals(null, trackingNo.getValue());
+        assertEquals(null, expressCompany.getValue());
+
+        // 4c. 虚拟商品已 completed:再次调用 → 幂等返回提示、不再同步微信
+        org.mockito.Mockito.clearInvocations(wechatService);
+        ProductOrder virtualCompleted = virtualCompletedOrder();
+        when(orderMapper.selectOne(any())).thenReturn(virtualCompleted);
+        Result<String> r3 = orderService.ship(ORDER_NO, "virtual", null, null, null, null);
+        assertEquals("该订单已自动交付", r3.getMessage());
+        // 清空后本次不应再调用微信同步(幂等)
+        verify(wechatService, never())
+                .uploadShippingInfo(any(), any(), any(), any(), any(), any(), any());
+    }
+
+    /** 5. 虚拟商品自动发货:支付回调后 → status=completed + 微信 logisticsType=3 + 无单号;
+     *  买家无 openid 时 fail-open 不抛异常 */
+    @Test
+    public void autoShipVirtualOrder_onPaySuccess() {
+        // 5a. 有 openid 正常同步微信
+        ProductOrder virtualPaid = virtualPaidOrder();
+        when(orderMapper.selectOne(any())).thenReturn(virtualPaid);
+        stubBuyerWithOpenid();
+
+        // 直接调用私有 autoShipVirtualOrder(通过反射)
+        Result<?> r = ReflectionTestUtils.invokeMethod(orderService, "autoShipVirtualOrder", virtualPaid);
+        assertNotNull(r);
+        assertEquals("自动发货成功", r.getMessage());
+
+        // 状态变为 completed
+        ArgumentCaptor<ProductOrder> persisted = ArgumentCaptor.forClass(ProductOrder.class);
+        verify(orderMapper).updateById(persisted.capture());
+        assertEquals("completed", persisted.getValue().getStatus());
+
+        // 微信同步:logisticsType=3
+        ArgumentCaptor<Integer> logisticsType = ArgumentCaptor.forClass(Integer.class);
+        verify(wechatService).uploadShippingInfo(eq(ORDER_NO), eq("oABC123"),
+                any(), any(), any(), logisticsType.capture(), any());
+        assertEquals(3, logisticsType.getValue());
+
+        // 5b. 买家无 openid:fail-open,不抛异常,不阻塞
+        org.mockito.Mockito.clearInvocations(wechatService);
+        ProductOrder virtualPaid2 = virtualPaidOrder();
+        when(orderMapper.selectOne(any())).thenReturn(virtualPaid2);
+        stubBuyerNoOpenid();
+        Result<?> r2 = ReflectionTestUtils.invokeMethod(orderService, "autoShipVirtualOrder", virtualPaid2);
+        assertNotNull(r2);
+        assertEquals("自动发货成功", r2.getMessage());
+        // 买家无 openid 时不应调用微信
+        verify(wechatService, never())
+                .uploadShippingInfo(any(), any(), any(), any(), any(), any(), any());
+    }
+
+    /** 6. deliveryMethod=3(两者皆可)不显式传 shipType → 明确错误 */
+    @Test
+    public void resolveShipType_deliveryMethod3_requiresExplicitShipType() {
+        ProductOrder order = paidOrder(3, "physical"); // 两者皆可
+        when(orderMapper.selectOne(any())).thenReturn(order);
+        stubBuyerWithOpenid();
+
+        // 不传 shipType(null) → 应返回明确错误
+        Result<String> r = orderService.ship(ORDER_NO, null, "SF123", "顺丰", null, null);
+        assertEquals("无法判断发货方式,请选择:快递发货或自提发货", r.getMessage());
+    }
+}