Просмотр исходного кода

feat(order): 管理端发货后自动同步物流到微信小程序交易管理服务

- WechatService 新增 uploadShippingInfo()(微信 sec/order/upload_shipping_info 发货信息录入)
  - order_key 用 mchid+out_trade_no(order_number_type=1),delivery_mode=1、logistics_type=1
  - 快递中文名映射微信编码(顺丰→SF/中通→ZTO/圆通→YTO/韵达→YD/申通→STO/京东→JD/EMS)
  - 响应解析优先检查 shipping_list 条目级失败(微信部分失败时顶层 errcode=0)
  - fail-open:失败只记日志不阻塞本地发货;wechat.test-mode=true 自动跳过
- ProductOrderService.ship()/updateExpress() 发货成功后调 syncShippingInfoToWechat()
  - 查买家 openid,组装「商品名 x数量」描述(≤120字),部分更新时回退订单已存快递值
- application.yml 新增 wechat.fulfillment-enabled 开关(默认 true,WECHAT_FULFILLMENT_ENABLED 可覆盖)
- 文档:API_REFERENCE 4.8 补充发货同步微信说明;PROJECT-OVERVIEW 追加变更记录
Sisyphus 1 неделя назад
Родитель
Сommit
08c4c0662a

+ 50 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -163,6 +163,9 @@ public class ProductOrderService {
     @Resource
     private MembershipService membershipService;
 
+    @Resource
+    private WechatService wechatService;
+
     @Resource
     private ProductDeliveryConfigService productDeliveryConfigService;
 
@@ -1043,6 +1046,8 @@ public class ProductOrderService {
         }
         order.setUpdatedAt(new Date());
         orderMapper.updateById(order);
+        // 发货成功后自动同步物流信息到微信小程序交易管理服务(fail-open:失败只记日志不阻塞)
+        syncShippingInfoToWechat(order, trackingNumber, expressCompany);
         return Result.success("已发货");
     }
 
@@ -1057,9 +1062,54 @@ public class ProductOrderService {
         if (shippingCost != null) order.setShippingCost(shippingCost);
         order.setUpdatedAt(new Date());
         orderMapper.updateById(order);
+        // 快递信息更新后自动同步微信(部分更新时回退订单已存值)
+        syncShippingInfoToWechat(order,
+                trackingNumber != null ? trackingNumber : order.getTrackingNumber(),
+                expressCompany != null ? expressCompany : order.getExpressCompany());
         return Result.success("快递信息已更新");
     }
 
+    /**
+     * 管理端发货/改快递成功后,自动把物流信息同步到微信「小程序交易管理服务」
+     * (upload_shipping_info)。fail-open:失败只记日志,不阻塞本地发货。
+     * 自提订单(deliveryMethod=2)或无物流单号时跳过。
+     */
+    private void syncShippingInfoToWechat(ProductOrder order, String trackingNo, String expressCompany) {
+        try {
+            if (order == null) {
+                return;
+            }
+            // 仅实体物流同步:自提订单跳过
+            if (order.getDeliveryMethod() != null && order.getDeliveryMethod() == 2) {
+                log.info("自提订单跳过微信发货同步: orderNo={}", order.getOrderNo());
+                return;
+            }
+            if (trackingNo == null || trackingNo.trim().isEmpty()) {
+                log.info("无物流单号,跳过微信发货同步: orderNo={}", order.getOrderNo());
+                return;
+            }
+            // 买家 openid
+            User buyer = userMapper.selectById(order.getBuyerId());
+            if (buyer == null || buyer.getOpenid() == null || buyer.getOpenid().isEmpty()) {
+                log.warn("买家无 openid,跳过微信发货同步: orderNo={}, buyerId={}", order.getOrderNo(), order.getBuyerId());
+                return;
+            }
+            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(),
+                    trackingNo, expressCompany, itemDesc);
+            if (err != null) {
+                log.warn("微信发货同步未成功(不阻塞发货): orderNo={}, reason={}", order.getOrderNo(), err);
+            }
+        } catch (Exception e) {
+            // fail-open:任何异常不阻塞本地发货流程
+            log.error("微信发货同步异常(不阻塞发货): orderNo={}, error={}", order.getOrderNo(), e.getMessage(), e);
+        }
+    }
+
     public Result<String> confirmReceive(String orderNo, Long userId) {
         ProductOrder order = orderMapper.selectOne(
             new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));

+ 124 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/WechatService.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -31,10 +32,12 @@ import java.net.HttpURLConnection;
 import java.net.URL;
 import java.nio.charset.StandardCharsets;
 import java.util.Base64;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.UUID;
 import java.time.Duration;
+import java.text.SimpleDateFormat;
 
 @Service
 public class WechatService implements WechatServiceInterface {
@@ -69,6 +72,13 @@ public class WechatService implements WechatServiceInterface {
     @Value("${wechat.env-version:release}")
     private String envVersion;
 
+    @Value("${wechat.mch-id:}")
+    private String mchId;
+
+    // 管理端发货后自动同步物流信息到微信「小程序交易管理服务」的开关(fail-open,默认开)
+    @Value("${wechat.fulfillment-enabled:true}")
+    private boolean fulfillmentEnabled;
+
     private final RestTemplate restTemplate = createRestTemplate();
 
     /**
@@ -641,4 +651,118 @@ public class WechatService implements WechatServiceInterface {
         byte[] decrypted = cipher.doFinal(encryptedBytes);
         return new String(decrypted, StandardCharsets.UTF_8);
     }
+
+    /**
+     * 是否启用「管理端发货 → 自动同步微信小程序交易管理服务」开关
+     */
+    public boolean isFulfillmentEnabled() {
+        return fulfillmentEnabled;
+    }
+
+    /**
+     * 快递公司中文名 → 微信物流公司编码映射(未命中透传原名)
+     */
+    public String mapExpressCompanyCode(String company) {
+        if (company == null || company.trim().isEmpty()) {
+            return "";
+        }
+        String c = company.trim();
+        if (c.contains("顺丰")) return "SF";
+        if (c.contains("中通")) return "ZTO";
+        if (c.contains("圆通")) return "YTO";
+        if (c.contains("韵达")) return "YD";
+        if (c.contains("申通")) return "STO";
+        if (c.contains("京东")) return "JD";
+        if (c.contains("EMS") || c.contains("邮政")) return "EMS";
+        return c;
+    }
+
+    /**
+     * 微信「小程序交易管理服务」发货信息录入 upload_shipping_info
+     * 管理端发货成功后调用,把物流信息同步到微信后台订单管理(用户微信侧收发货通知/查物流/确认收货)。
+     *
+     * fail-open:失败只记日志返回错误信息,不阻塞本地发货流程;测试模式与未配置 mch-id 时跳过。
+     *
+     * @param outTradeNo     商户订单号(下单时 out_trade_no = 订单号,order_number_type=1 用 mchid+out_trade_no 匹配微信订单)
+     * @param buyerOpenid    买家 openid(payer.openid)
+     * @param trackingNo     物流单号
+     * @param expressCompany 快递公司(中文名,内部映射为微信编码;未命中透传)
+     * @param itemDesc       商品描述(≤120字,如 "商品名 x1")
+     * @return 成功返回 null;失败返回错误信息(不抛异常)
+     */
+    public String uploadShippingInfo(String outTradeNo, String buyerOpenid,
+                                     String trackingNo, String expressCompany, String itemDesc) {
+        if (!isFulfillmentEnabled()) {
+            log.info("发货同步微信已关闭(fulfillment-enabled=false),跳过 upload_shipping_info: orderNo={}", outTradeNo);
+            return null;
+        }
+        if (testMode) {
+            log.info("测试模式:跳过 upload_shipping_info 同步微信: orderNo={}", outTradeNo);
+            return null;
+        }
+        if (mchId == null || mchId.trim().isEmpty()) {
+            log.warn("wechat.mch-id 未配置,跳过 upload_shipping_info: orderNo={}", outTradeNo);
+            return null;
+        }
+        try {
+            String accessToken = getAccessToken();
+            String url = "https://api.weixin.qq.com/sec/order/upload_shipping_info?access_token=" + accessToken;
+
+            JSONObject orderKey = new JSONObject();
+            orderKey.put("order_number_type", 1); // 1=商户号+商户单号
+            orderKey.put("mchid", mchId);
+            orderKey.put("out_trade_no", outTradeNo);
+
+            JSONObject payer = new JSONObject();
+            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);
+            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("upload_time", new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX").format(new Date()));
+            requestBody.put("payer", payer);
+            requestBody.put("shipping_list", shippingList);
+
+            org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
+            headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON);
+            org.springframework.http.HttpEntity<String> entity = new org.springframework.http.HttpEntity<>(
+                    requestBody.toJSONString(), headers);
+
+            ResponseEntity<String> response = restTemplate.exchange(url,
+                    org.springframework.http.HttpMethod.POST, entity, String.class);
+            JSONObject json = JSON.parseObject(response.getBody());
+            int errcode = json.getIntValue("errcode");
+            if (errcode == 0) {
+                // 顶层成功,但条目级可能部分失败(微信部分失败时顶层 errcode=0)
+                JSONArray list = json.getJSONArray("shipping_list");
+                if (list != null && !list.isEmpty()) {
+                    for (int i = 0; i < list.size(); i++) {
+                        JSONObject item = list.getJSONObject(i);
+                        if (item != null && item.getIntValue("errcode") != 0) {
+                            log.error("微信发货信息录入条目失败: orderNo={}, errcode={}, errmsg={}",
+                                    outTradeNo, item.getIntValue("errcode"), item.getString("errmsg"));
+                            return "微信发货信息录入条目失败: " + item.getString("errmsg");
+                        }
+                    }
+                }
+                log.info("微信发货信息录入成功: orderNo={}, trackingNo={}", outTradeNo, trackingNo);
+                return null;
+            }
+            log.error("微信发货信息录入失败: orderNo={}, errcode={}, errmsg={}",
+                    outTradeNo, errcode, json.getString("errmsg"));
+            return "微信发货信息录入失败: " + json.getString("errmsg");
+        } catch (Exception e) {
+            // fail-open:失败只记日志,不阻塞本地发货
+            log.error("微信发货信息录入异常: orderNo={}, error={}", outTradeNo, e.getMessage(), e);
+            return "微信发货信息录入异常: " + e.getMessage();
+        }
+    }
 }

+ 1 - 0
cfc-backend/src/main/resources/application.yml

@@ -93,6 +93,7 @@ wechat:
   login-url: https://api.weixin.qq.com/sns/jscode2session
   phone-url: https://api.weixin.qq.com/wxa/business/getuserphonenumber
   test-mode: true  # 测试模式:跳过微信 API 调用,使用模拟数据
+  fulfillment-enabled: ${WECHAT_FULFILLMENT_ENABLED:true}  # 管理端发货后自动同步物流到微信小程序交易管理服务(fail-open)
   appkey: ${WECHAT_APPKEY:cXqYJseX2MUaRM5kVHLQRskw5l5ZrDNo}          # 虚拟支付 AppKey 现网(环境变量可覆盖)
   sandbox-appkey: ${WECHAT_SANDBOX_APPKEY:sKcsYR0e9T603wvD5yFcvISBcFLh3TXH}  # 虚拟支付 AppKey 沙箱(环境变量可覆盖)
   virtual-pay-env: ${WECHAT_VIRTUAL_PAY_ENV:0}   # 0=现网 1=沙箱

+ 1 - 1
docs/superpowers/PROJECT-OVERVIEW.md

@@ -378,7 +378,7 @@
 | `2026-08-31-self-check-ai-integration-design.md` | 🟡 设计稿(P0-1 纯AI替换静态建议 + P0-2 用户点击生成健康计划 + P1-1 历史趋势AI解读 + P1-2 Chat上下文注入,P2延后) | 五维家庭自检 AI 结合设计 |
 | `2026-09-09-team-hierarchy-design.md` | 🟡 设计稿(团队层级树从下往上生长 + 团队消费额分级统计 + 直推成员消费/CF明细 + 我的推荐人展示 + 去掉富页面推荐码) | 团队层级与推荐关系可视化 |
 | `2026-09-10-report-quick-analyze-design.md` | 🟡 设计稿(报告详情页快速分析:免费版每日 1 次 Redis 计数 + 付费版多轮;复用 chat_graph + AiGateway,LangGraph 零改动) | 报告快速分析设计 |
-| `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程;2026-09-06 补充家庭成员切换/回收箱接口:`/leave`、`/kick` 回收箱分流、`/recycle`;2026-09-10 补充关系三图接口:`/api/octopus`、`/api/pearl`、`/api/ability`;2026-09-12 补充供应商审核接口:`/api/product/order/review/update`;2026-10-04 补充统一服务角色申请 6 接口 `/api/service-role/*`(4.44,含 status 语义、角色落地规则及 admin/operator 权限口径)、规划师解绑家庭 `/api/guide/families/unbind`(4.45)、规划师方案审核 `/api/health/plan/pending-review/*`(4.46,含未分配池 `teacher_id IS NULL` 规则);2026-10-05 补充商品批量上下架 `POST /api/admin/product/batch-shelve`(body: `{productIds, shelve}`,批量校验仅审核通过商品可操作);2026-10-09 补充指标统一层 9 接口(4.48:视图 4 个 `/api/indicator/*` + admin 5 个 `/api/admin/indicator/*`,含家庭归属校验、关联校准口径、映射导入/review)) | 后台接口参考文档 |
+| `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程;2026-09-06 补充家庭成员切换/回收箱接口:`/leave`、`/kick` 回收箱分流、`/recycle`;2026-09-10 补充关系三图接口:`/api/octopus`、`/api/pearl`、`/api/ability`;2026-09-12 补充供应商审核接口:`/api/product/order/review/update`;2026-10-04 补充统一服务角色申请 6 接口 `/api/service-role/*`(4.44,含 status 语义、角色落地规则及 admin/operator 权限口径)、规划师解绑家庭 `/api/guide/families/unbind`(4.45)、规划师方案审核 `/api/health/plan/pending-review/*`(4.46,含未分配池 `teacher_id IS NULL` 规则);2026-10-05 补充商品批量上下架 `POST /api/admin/product/batch-shelve`(body: `{productIds, shelve}`,批量校验仅审核通过商品可操作);2026-10-09 补充指标统一层 9 接口(4.48:视图 4 个 `/api/indicator/*` + admin 5 个 `/api/admin/indicator/*`,含家庭归属校验、关联校准口径、映射导入/review);2026-10-18 补充发货同步微信说明(4.8:`/api/product/order/ship` 发货成功后自动调微信 `upload_shipping_info` 同步物流,`wechat.fulfillment-enabled` 开关,fail-open 不阻塞)) | 后台接口参考文档 |
 | `2026-09-10-key-person-expansion-octopus-design.md` | 🟡 设计规格已确认(成效对象=会员客户 + 类型bitmask + 关键人画像 + 四步法 + 高频词≤8方向;3表/10接口/三层画布;替换旧章鱼图) | 关键人拓展章鱼图替换设计(关系三图重构) |
 | `2026-09-19-article-reading-quiz-design.md` | 🟢 已实施(补全现有阅读答题:article_quiz_records 表 + LangGraph article_quiz_graph 出题 + AiGateway 接入 + 提交答题落库 + CF值/五维能量双奖励 + profile_snapshot.quiz_summary 认知雷达) | 文章阅读后答题奖励 + 认知雷达记录设计 |
 | `2026-09-22-guide-popup-config-design.md` | ✅ 已实施(4表/guide_popup+step+trigger+log,9接口,ConfigurableGuide.vue组件+管理端2页;旧 sys_config 方案保留并行) | 弹窗引导页配置化管理设计 |

+ 1 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -651,6 +651,7 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/product/order/refund/apply` | 申请退款 |
 | `POST /api/product/order/vendor` | 服务商查询收到的订单 |
 | `POST /api/product/order/vendor-confirm` | 服务商确认发出/服务完成 |
+| `POST /api/product/order/ship` | 服务商/管理端发货(填物流单号+快递公司)。发货成功后自动调用微信「小程序交易管理服务」`upload_shipping_info` 同步物流到微信订单管理(用户在微信侧收发货通知/查物流/确认收货);自提订单或无单号跳过;失败只记日志不阻塞(fail-open);`wechat.test-mode=true` 或 `wechat.fulfillment-enabled=false`(环境变量 `WECHAT_FULFILLMENT_ENABLED`)时跳过 |
 | `POST /api/product/order/review/update` | 供应商审核修改待支付订单(修改价格/配送方式/添加优惠券/赠品) |
 | `POST /api/cart/add` | 加入购物车 |
 | `POST /api/cart/list` | 购物车列表 |