Преглед изворни кода

feat:微信入驻接口成功+定时查询任务

刘云鑫 пре 1 недеља
родитељ
комит
cd3f25581c

+ 4 - 0
alien-entity/src/main/java/shop/alien/entity/store/StoreInfo.java

@@ -362,4 +362,8 @@ public class StoreInfo {
     @ApiModelProperty(value = "预约服务 0不提供 1提供")
     @TableField("booking_service")
     private Integer bookingService;
+
+    @ApiModelProperty(value = "微信支付特约商户号(服务商进件审核通过后回写 sub_mchid)")
+    @TableField("wechat_sub_mchid")
+    private String wechatSubMchid;
 }

+ 8 - 0
alien-entity/src/main/java/shop/alien/entity/store/WechatPartnerApplyment.java

@@ -34,6 +34,14 @@ public class WechatPartnerApplyment {
     @TableField("idempotency_key")
     private String idempotencyKey;
 
+    /**
+     * 关联本系统门店 {@link StoreInfo#getId()},审核通过后用于回写 {@code store_info.wechat_sub_mchid};
+     * 提交进件时通过 query 参数 {@code storeId} 传入并落库(非微信请求字段)。
+     */
+    @ApiModelProperty("关联门店ID store_info.id")
+    @TableField("store_id")
+    private Integer storeId;
+
     @ApiModelProperty("微信支付申请单号applyment_id")
     @TableField("applyment_id")
     private Long applymentId;

+ 193 - 0
alien-job/src/main/java/shop/alien/job/jobhandler/WechatPartnerApplymentAuditSyncJobHandler.java

@@ -0,0 +1,193 @@
+package shop.alien.job.jobhandler;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.xxl.job.core.biz.model.ReturnT;
+import com.xxl.job.core.context.XxlJobHelper;
+import com.xxl.job.core.handler.annotation.XxlJob;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.WechatPartnerApplyment;
+import shop.alien.job.feign.AlienStoreFeign;
+import shop.alien.mapper.WechatPartnerApplymentMapper;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * XXL-JOB:定时查询微信进件审核信息,并更新本地表 {@code wechat_partner_applyment}。
+ * <p>
+ * 查询方式:通过 {@link AlienStoreFeign}(OpenFeign)调用 alien-store 转发查询接口,由 alien-store 负责对微信签名请求+验签;\n
+ * 本 Handler 解析返回数据,并按通过/驳回回写本地表。\n
+ * </p>
+ * <p>
+ * 待处理记录<strong>仅从数据库查询</strong>,不通过 XXL-Job 任务参数传入申请单号;\n
+ * 条数上限由配置 {@code job.wechatPartnerApplyment.batchSize} 控制。\n
+ * </p>
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class WechatPartnerApplymentAuditSyncJobHandler {
+
+    private final AlienStoreFeign alienStoreFeign;
+    private final WechatPartnerApplymentMapper wechatPartnerApplymentMapper;
+
+    /**
+     * 【需配置】alien-store 根地址,与 {@link AlienStoreFeign} 的 {@code feign.alienStore.url} 一致;\n
+     * 仅用于 XXL 日志展示,实际请求走 Feign。
+     */
+    @Value("${feign.alienStore.url:}")
+    private String alienStoreFeignUrl;
+
+    /** 默认批量大小 */
+    @Value("${job.wechatPartnerApplyment.batchSize:1}")
+    private int batchSize;
+
+    /**
+     * XXL-JOB 任务:同步进件审核状态(通过/驳回均更新)。
+     * JobHandler 名称:{@code wechatPartnerApplymentAuditSync}
+     */
+    @XxlJob("wechatPartnerApplymentAuditSync")
+    public ReturnT<String> wechatPartnerApplymentAuditSync(String param) {
+        int shardIndex = XxlJobHelper.getShardIndex();
+        int shardTotal = XxlJobHelper.getShardTotal();
+
+        int limit = Math.max(1, Math.min(batchSize, 200));
+
+        XxlJobHelper.log("[进件XXL] start shardIndex={}, shardTotal={}, limit={}, feign.alienStore.url={}",
+                shardIndex, shardTotal, limit, alienStoreFeignUrl);
+
+        // 仅从库查询待同步:未终态(0) 且已有 applyment_id;分片广播:按 applyment_id % shardTotal 分片
+        List<WechatPartnerApplyment> list = wechatPartnerApplymentMapper.selectList(
+                new LambdaQueryWrapper<WechatPartnerApplyment>()
+                        .eq(WechatPartnerApplyment::getIsApproved, 0)
+                        .isNotNull(WechatPartnerApplyment::getApplymentId)
+                        .apply("MOD(applyment_id, {0}) = {1}", shardTotal, shardIndex)
+                        .orderByAsc(WechatPartnerApplyment::getUpdatedAt)
+                        .last("limit " + limit)
+        );
+
+        int ok = 0;
+        int fail = 0;
+        for (WechatPartnerApplyment r : list) {
+            Long id = r.getApplymentId();
+            if (id == null || id <= 0) {
+                continue;
+            }
+            try {
+                R<Map<String, Object>> result = alienStoreFeign.queryWechatPartnerApplymentState(id);
+                if (!R.isSuccess(result)) {
+                    fail++;
+                    String msg = result != null ? result.getMsg() : "响应为空";
+                    XxlJobHelper.log("[进件XXL] queryFail applyment_id={}, msg={}", id, msg);
+                    continue;
+                }
+                Map<String, Object> dataMap = result != null ? result.getData() : null;
+                if (dataMap == null || dataMap.isEmpty()) {
+                    fail++;
+                    XxlJobHelper.log("[进件XXL] queryFail applyment_id={}, data为空", id);
+                    continue;
+                }
+                JSONObject data = JSON.parseObject(JSON.toJSONString(dataMap));
+                String rawResp = JSON.toJSONString(result);
+                // 回写本地表(通过/驳回/进行中都更新状态字段)
+                upsertFromWechatQueryResult(r, data, rawResp);
+                ok++;
+                XxlJobHelper.log("[进件XXL] ok applyment_id={}, state={}", id, data.getString("applyment_state"));
+            } catch (Exception e) {
+                fail++;
+                log.error("[进件XXL] Feign 查询异常 applyment_id={}", id, e);
+                XxlJobHelper.log("[进件XXL] exception applyment_id={}, err={}", id, e.getMessage());
+            }
+        }
+
+        XxlJobHelper.log("[进件XXL] done candidate={}, ok={}, fail={}", list.size(), ok, fail);
+        return ReturnT.SUCCESS;
+    }
+
+    private void upsertFromWechatQueryResult(WechatPartnerApplyment existed, JSONObject data, String rawResp) {
+        Date now = new Date();
+        WechatPartnerApplyment record = existed != null ? existed : new WechatPartnerApplyment();
+        record.setLastQueryTime(now);
+        record.setLastQueryRespJson(rawResp);
+        record.setUpdatedAt(now);
+
+        record.setBusinessCode(blankToNull(data.getString("business_code")));
+        record.setSubMchid(blankToNull(data.getString("sub_mchid")));
+        record.setSignUrl(blankToNull(data.getString("sign_url")));
+        record.setApplymentState(blankToNull(data.getString("applyment_state")));
+        record.setApplymentStateMsg(blankToNull(data.getString("applyment_state_msg")));
+
+        Integer approved = calcIsApproved(record.getApplymentState());
+        record.setIsApproved(approved);
+        if (approved != null && approved == 2) {
+            record.setRejectReason(extractRejectReason(data));
+        } else if (approved != null && approved == 1) {
+            // 通过则清空驳回原因(避免旧值残留)
+            record.setRejectReason(null);
+        }
+
+        // 仅更新当前行
+        if (record.getId() != null) {
+            wechatPartnerApplymentMapper.updateById(record);
+        } else {
+            // 兜底:按 applyment_id 找不到 id 的情况,插入一条(不建议频繁发生)
+            record.setApplymentId(data.getLong("applyment_id"));
+            if (record.getCreatedAt() == null) {
+                record.setCreatedAt(now);
+            }
+            wechatPartnerApplymentMapper.insert(record);
+        }
+    }
+
+    private static Integer calcIsApproved(String applymentState) {
+        if (StringUtils.isBlank(applymentState)) {
+            return 0;
+        }
+        if ("APPLYMENT_STATE_FINISHED".equalsIgnoreCase(applymentState)) {
+            return 1;
+        }
+        if ("APPLYMENT_STATE_REJECTED".equalsIgnoreCase(applymentState)) {
+            return 2;
+        }
+        return 0;
+    }
+
+    private static String extractRejectReason(JSONObject data) {
+        try {
+            if (data == null || data.getJSONArray("audit_detail") == null) {
+                return null;
+            }
+            StringBuilder sb = new StringBuilder();
+            for (int i = 0; i < data.getJSONArray("audit_detail").size(); i++) {
+                JSONObject it = data.getJSONArray("audit_detail").getJSONObject(i);
+                if (it == null) continue;
+                String fieldName = it.getString("field_name");
+                String field = it.getString("field");
+                String reason = it.getString("reject_reason");
+                if (StringUtils.isBlank(reason)) continue;
+                if (sb.length() > 0) sb.append(";");
+                String left = StringUtils.isNotBlank(fieldName) ? fieldName : (StringUtils.isNotBlank(field) ? field : "");
+                if (StringUtils.isNotBlank(left)) sb.append(left).append(":");
+                sb.append(reason);
+            }
+            String s = sb.toString();
+            return s.length() <= 2048 ? s : s.substring(0, 2048);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private static String blankToNull(String s) {
+        String t = s != null ? s.trim() : null;
+        return StringUtils.isNotBlank(t) ? t : null;
+    }
+}
+

+ 26 - 6
alien-store/src/main/java/shop/alien/store/controller/WeChatPartnerApplymentController.java

@@ -10,9 +10,11 @@ import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 import shop.alien.entity.result.R;
+import shop.alien.entity.store.WechatPartnerApplyment;
 import shop.alien.store.service.WeChatPartnerApplymentService;
 
 import java.io.IOException;
+import java.util.List;
 import java.util.Map;
 
 /**
@@ -41,18 +43,21 @@ public class WeChatPartnerApplymentController {
      *
      * @param requestBody    与微信文档一致的 JSON 字符串
      * @param idempotencyKey 可选,传入 {@code Idempotency-Key},同一业务单重试时请保持不变
+     * @param storeId        可选,本系统门店 {@code store_info.id};审核通过后将微信 {@code sub_mchid} 写入该门店
      */
     @ApiOperation("特约商户进件-提交申请单(转发微信 /v3/applyment4sub/applyment/)")
     @ApiImplicitParams({
-            @ApiImplicitParam(name = "Idempotency-Key", value = "幂等键,可选;重试同一进件时请与首次一致", paramType = "header", dataType = "String")
+            @ApiImplicitParam(name = "Idempotency-Key", value = "幂等键,可选;重试同一进件时请与首次一致", paramType = "header", dataType = "String"),
+            @ApiImplicitParam(name = "storeId", value = "门店ID store_info.id,可选;用于审核通过后回写 wechat_sub_mchid", paramType = "query", dataType = "Integer")
     })
-    @PostMapping(value = "/v3/applyment4sub/applyment/", consumes = MediaType.APPLICATION_JSON_VALUE)
+    @PostMapping(value = "/v3/applyment4sub/applyment", consumes = MediaType.APPLICATION_JSON_VALUE)
     public R<Map<String, Object>> submitApplyment(
             @RequestBody String requestBody,
-            @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey) {
-        log.info("WeChatPartnerApplymentController.submitApplyment bodyLen={}, idempotencyKeyPresent={}",
-                requestBody != null ? requestBody.length() : 0, idempotencyKey != null && !idempotencyKey.isEmpty());
-        return weChatPartnerApplymentService.submitApplyment(requestBody, idempotencyKey);
+            @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
+            @RequestParam(value = "storeId", required = false) Integer storeId) {
+        log.info("WeChatPartnerApplymentController.submitApplyment bodyLen={}, idempotencyKeyPresent={}, storeId={}",
+                requestBody != null ? requestBody.length() : 0, idempotencyKey != null && !idempotencyKey.isEmpty(), storeId);
+        return weChatPartnerApplymentService.submitApplyment(requestBody, idempotencyKey, storeId);
     }
 
     /**
@@ -94,4 +99,19 @@ public class WeChatPartnerApplymentController {
         log.info("WeChatPartnerApplymentController.queryApplymentStateByApplymentId applyment_id={}", applymentId);
         return weChatPartnerApplymentService.queryApplymentStateByApplymentId(applymentId);
     }
+
+    /**
+     * 按门店查询特约商户进件状态:读本地 {@code wechat_partner_applyment} 该店最新一条记录(不调微信)。
+     *
+     * @param storeId 门店主键 {@code store_info.id}(进件提交时需已传同一 storeId)
+     */
+    @ApiOperation("特约商户进件-按门店查询进件状态(读本地库)")
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "storeId", value = "门店ID store_info.id", required = true, paramType = "path", dataType = "int")
+    })
+    @GetMapping("/applyment/store/{storeId}")
+    public R<List<WechatPartnerApplyment>> queryApplymentStateByStoreId(@PathVariable("storeId") Integer storeId) {
+        log.info("WeChatPartnerApplymentController.queryApplymentStateByStoreId storeId={}", storeId);
+        return weChatPartnerApplymentService.queryApplymentStateByStoreId(storeId);
+    }
 }

+ 162 - 8
alien-store/src/main/java/shop/alien/store/service/WeChatPartnerApplymentService.java

@@ -6,10 +6,13 @@ import lombok.extern.slf4j.Slf4j;
 import okhttp3.*;
 import org.apache.commons.lang3.StringUtils;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
+import shop.alien.entity.store.StoreInfo;
 import shop.alien.entity.store.WechatPartnerApplyment;
 import shop.alien.entity.result.R;
+import shop.alien.mapper.StoreInfoMapper;
 import shop.alien.mapper.WechatPartnerApplymentMapper;
 import shop.alien.store.util.WXPayUtility;
 import shop.alien.util.system.OSUtil;
@@ -17,6 +20,7 @@ import shop.alien.util.system.OSUtil;
 import javax.annotation.PostConstruct;
 import javax.annotation.Resource;
 import java.io.IOException;
+import java.math.BigDecimal;
 import java.lang.reflect.Type;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
@@ -144,6 +148,9 @@ public class WeChatPartnerApplymentService {
     @Resource
     private WechatPartnerApplymentMapper wechatPartnerApplymentMapper;
 
+    @Resource
+    private StoreInfoMapper storeInfoMapper;
+
     @PostConstruct
     public void loadCertificates() {
         String privateKeyPath;
@@ -392,6 +399,7 @@ public class WeChatPartnerApplymentService {
      *       则迁入 {@code id_card_info}。</li>
      *   <li>将 {@code id_card_valid_time_begin} / {@code id_card_valid_time} 重命名为 {@code card_period_begin} / {@code card_period_end}。</li>
      *   <li>将各 {@code *_period_end}、营业执照等 {@code period_end} 中误写的英文 {@code forever} 规范为文档用语「长期」。</li>
+     *   <li>{@code settlement_info.settlement_id} 微信要求 string;若前端传为 JSON 数字(如 {@code 716}、{@code 716.0}),转为字符串避免 {@code PARAM_ERROR}。</li>
      * </ul>
      *
      * @param json 前端或历史模板传入的 JSON 字符串
@@ -414,6 +422,7 @@ public class WeChatPartnerApplymentService {
         try {
             normalizeIdentityInfoIdCardNested(root);
             normalizeForeverToLongPeriodRecursive(root);
+            coerceSettlementInfoSettlementIdToString(root);
         } catch (Exception e) {
             log.error("[进件] normalizeApplymentJsonForV3 处理异常,原样提交", e);
             return json;
@@ -424,6 +433,27 @@ public class WeChatPartnerApplymentService {
     }
 
     /**
+     * 微信「入驻结算规则 ID」字段类型为 string;Gson 解析 JSON 数字会得到 {@link Number},序列化回 JSON 仍为数字,导致接口报错。
+     * 前置将 {@code settlement_info.settlement_id} 若为数字则转为十进制字符串(如 {@code 716.0} → {@code "716"})。
+     */
+    @SuppressWarnings("unchecked")
+    private void coerceSettlementInfoSettlementIdToString(Map<String, Object> root) {
+        Object si = root.get("settlement_info");
+        if (!(si instanceof Map)) {
+            return;
+        }
+        Map<String, Object> settlementInfo = (Map<String, Object>) si;
+        Object sid = settlementInfo.get("settlement_id");
+        if (!(sid instanceof Number)) {
+            return;
+        }
+        Number n = (Number) sid;
+        String asStr = new BigDecimal(n.toString()).stripTrailingZeros().toPlainString();
+        settlementInfo.put("settlement_id", asStr);
+        log.info("[进件] settlement_info.settlement_id 已由数字 {} 转为字符串 \"{}\"", n, asStr);
+    }
+
+    /**
      * 将 {@code subject_info.identity_info} 下扁平身份证字段迁入 {@code id_card_info},并重命名有效期字段。
      */
     @SuppressWarnings("unchecked")
@@ -533,27 +563,37 @@ public class WeChatPartnerApplymentService {
     /**
      * 提交特约商户进件申请单,请求体为微信要求的 JSON(含 business_code、subject_info、bank_account_info 等)。
      *
-     * @param requestJson  与微信文档一致的 JSON 字符串(可先经 {@link #normalizeApplymentJsonForV3(String)} 规范结构)
+     * @param requestJson  与微信文档一致的 JSON 字符串(可先经 {@link #normalizeApplymentJsonForV3(String)} 规范结构)。
+     *                     若传入 {@code storeId},服务端会写入必填字段 {@code business_code}(规则见 {@link #mergeBusinessCodeIntoApplymentJson(String, Integer)});
+     *                     未传 {@code storeId} 时须在 JSON 中自行填写非空的 {@code business_code}。
      * @param idempotencyKey 可选;传入则写入 {@code Idempotency-Key} 请求头,用于幂等重试(同一进件业务请固定同一值)
+     * @param storeId        推荐传入;本系统门店 {@code store_info.id}。传入时后端生成 {@code business_code = 服务商商户号(sp_mchid) + "_" + storeId};审核通过后将 {@code sub_mchid} 写入该门店
      * @return 成功时 data 为微信返回的 JSON 解析后的 Map;失败为 R.fail
      */
-    public R<Map<String, Object>> submitApplyment(String requestJson, String idempotencyKey) {
+    public R<Map<String, Object>> submitApplyment(String requestJson, String idempotencyKey, Integer storeId) {
         if (StringUtils.isBlank(requestJson)) {
             log.warn("[进件] 请求体为空");
             return R.fail("请求体不能为空");
         }
         final String normalizedJson = normalizeApplymentJsonForV3(requestJson);
+        final String jsonWithBusinessCode;
+        try {
+            jsonWithBusinessCode = mergeBusinessCodeIntoApplymentJson(normalizedJson, storeId);
+        } catch (IllegalArgumentException e) {
+            log.warn("[进件] business_code 处理失败: {}", e.getMessage());
+            return R.fail(e.getMessage());
+        }
         final String payloadJson;
         try {
-            payloadJson = encryptSensitiveFieldsInApplymentJson(normalizedJson);
+            payloadJson = encryptSensitiveFieldsInApplymentJson(jsonWithBusinessCode);
         } catch (Exception e) {
             log.error("[进件] 敏感字段加密失败", e);
             return R.fail("进件请求敏感信息加密失败: " + e.getMessage());
         }
 
         // 落库:提交前先写入/更新一条记录(以 sp_mchid + business_code 唯一)
-        String businessCode = extractBusinessCodeSafe(normalizedJson);
-        upsertApplymentOnSubmitStart(businessCode, idempotencyKey, payloadJson);
+        String businessCode = extractBusinessCodeSafe(jsonWithBusinessCode);
+        upsertApplymentOnSubmitStart(businessCode, idempotencyKey, payloadJson, storeId);
 
         String uri = applyment4subPath;
         String url = wechatPayApiHost + uri;
@@ -618,7 +658,7 @@ public class WeChatPartnerApplymentService {
         String template = StringUtils.isNotBlank(applymentQueryByIdPath)
                 ? applymentQueryByIdPath
                 : "/v3/applyment4sub/applyment/applyment_id/{applyment_id}";
-        String uri = template.replace("{applyment_id}", WXPayUtility.urlEncode(String.valueOf(applymentId)));
+        String uri = template.replace("{applyment_id}", String.valueOf(applymentId));
         String url = wechatPayApiHost + uri;
         log.info("[进件查询] 调用微信查询申请单状态 url={}, uri={}", url, uri);
 
@@ -657,6 +697,86 @@ public class WeChatPartnerApplymentService {
     }
 
     /**
+     * 按门店查询最近一次特约商户进件状态(仅读本地库,不调微信接口)。
+     * <p>
+     * 从 {@code wechat_partner_applyment} 按 {@code store_id} 取最新一条(按更新时间、主键倒序)。
+     * 进件提交、定时任务或查询接口已把微信侧状态写入本表,此处直接返回落库数据即可。
+     * </p>
+     *
+     * @param storeId 门店主键 {@link StoreInfo#getId()},须与进件提交时 query 参数 {@code storeId} 一致
+     * @return 成功时 data 为列表:有记录时含一条 Map,无进件记录时为空列表(不返回业务错误)
+     */
+    public R<List<WechatPartnerApplyment>> queryApplymentStateByStoreId(Integer storeId) {
+        if (storeId == null || storeId <= 0) {
+            log.warn("[进件查询] storeId 非法 storeId={}", storeId);
+            return R.fail("门店 storeId 不能为空且必须大于 0");
+        }
+        List<WechatPartnerApplyment> record = findLatestApplymentByStoreId(storeId);
+        if (record == null) {
+            log.info("[进件查询] 按门店无进件记录,返回空列表 storeId={}", storeId);
+            return R.data(Collections.emptyList());
+        }
+        log.info("[进件查询] 按门店返回本地库状态 storeId={}", storeId);
+        return R.data(record);
+    }
+
+    /**
+     * 同一门店可能存在多次进件记录时,取最新一条用于展示与查询。
+     */
+    private List<WechatPartnerApplyment> findLatestApplymentByStoreId(int storeId) {
+        LambdaQueryWrapper<WechatPartnerApplyment> qw = new LambdaQueryWrapper<WechatPartnerApplyment>()
+                .eq(WechatPartnerApplyment::getStoreId, storeId)
+                .orderByDesc(WechatPartnerApplyment::getUpdatedAt)
+                .orderByDesc(WechatPartnerApplyment::getId);
+        return wechatPartnerApplymentMapper.selectList(qw);
+    }
+
+
+    /**
+     * 合并 {@code business_code} 到进件根节点。
+     * <p>
+     * 微信要求 {@code business_code}(业务申请编号)必填且唯一。当请求带 {@code storeId} 时,由后端统一生成为:
+     * {@code 服务商商户号(sp_mchid) + "_" + storeId}(下划线分隔,与配置项 {@code payment.wechatPartnerPay.business.spMchId} 一致),
+     * 避免前端漏传或传空串导致 {@code PARAM_ERROR}。
+     * </p>
+     * <p>
+     * 未传 {@code storeId} 时,若 JSON 中 {@code business_code} 已非空则保留;否则抛出 {@link IllegalArgumentException}。
+     * </p>
+     *
+     * @param normalizedJson 已规范化的进件 JSON 字符串
+     * @param storeId        可选,有值则覆盖/写入 {@code business_code}
+     * @return 合并后的 JSON 字符串
+     */
+    private String mergeBusinessCodeIntoApplymentJson(String normalizedJson, Integer storeId) {
+        Map<String, Object> root;
+        try {
+            root = GSON.fromJson(normalizedJson, MAP_TYPE);
+        } catch (Exception e) {
+            throw new IllegalArgumentException("进件 JSON 解析失败,无法写入 business_code: " + e.getMessage());
+        }
+        if (root == null) {
+            root = new LinkedHashMap<>();
+        }
+        if (storeId != null) {
+            String sp = StringUtils.trimToEmpty(spMchId);
+            if (StringUtils.isBlank(sp)) {
+                throw new IllegalArgumentException("未配置服务商商户号 payment.wechatPartnerPay.business.spMchId,无法生成 business_code");
+            }
+            String code = sp + "_" + storeId;
+            root.put("business_code", code);
+            log.info("[进件] 已生成并写入 business_code={}(sp_mchid_storeId 格式,storeId={})", code, storeId);
+            return GSON.toJson(root);
+        }
+        Object v = root.get("business_code");
+        String existing = v == null ? "" : String.valueOf(v).trim();
+        if (StringUtils.isBlank(existing)) {
+            throw new IllegalArgumentException(
+                    "请传入查询参数 storeId(后端将生成 business_code=服务商商户号_storeId),或在 JSON 根节点填写非空的 business_code");
+        }
+        return GSON.toJson(root);
+    }
+
+    /**
      * 从进件 JSON 中提取 business_code(用于落库幂等主键);解析失败则返回 null。
      */
     private static String extractBusinessCodeSafe(String requestJson) {
@@ -676,7 +796,7 @@ public class WeChatPartnerApplymentService {
         }
     }
 
-    private void upsertApplymentOnSubmitStart(String businessCode, String idempotencyKey, String payloadJson) {
+    private void upsertApplymentOnSubmitStart(String businessCode, String idempotencyKey, String payloadJson, Integer storeId) {
         if (StringUtils.isBlank(businessCode)) {
             log.warn("[进件落库] business_code 为空,跳过落库(请在请求体中填写 business_code)");
             return;
@@ -696,6 +816,9 @@ public class WeChatPartnerApplymentService {
         if (StringUtils.isBlank(record.getSpMchid()) || "UNKNOWN".equalsIgnoreCase(record.getSpMchid())) {
             record.setSpMchid(dbSpMchid);
         }
+        if (storeId != null) {
+            record.setStoreId(storeId);
+        }
         record.setIdempotencyKey(idempotencyKey);
         record.setLastSubmitTime(now);
         record.setRequestJson(payloadJson);
@@ -705,7 +828,7 @@ public class WeChatPartnerApplymentService {
         } else {
             wechatPartnerApplymentMapper.updateById(record);
         }
-        log.info("[进件落库] submitStart sp_mchid={}, business_code={}, id={}", record.getSpMchid(), businessCode, record.getId());
+        log.info("[进件落库] submitStart sp_mchid={}, business_code={}, storeId={}, id={}", record.getSpMchid(), businessCode, record.getStoreId(), record.getId());
     }
 
     private void upsertApplymentOnSubmitResult(String businessCode, Map<String, Object> respMap, String respBody) {
@@ -742,6 +865,7 @@ public class WeChatPartnerApplymentService {
         }
         log.info("[进件落库] submitResult business_code={}, applyment_id={}, state={}",
                 businessCode, record.getApplymentId(), record.getApplymentState());
+        syncStoreInfoWechatSubMchidIfApproved(record);
     }
 
     private void upsertApplymentOnSubmitError(String businessCode, String errMsg, String respBody) {
@@ -806,6 +930,7 @@ public class WeChatPartnerApplymentService {
         } else {
             wechatPartnerApplymentMapper.updateById(record);
         }
+        syncStoreInfoWechatSubMchidIfApproved(record);
     }
 
     private void upsertApplymentOnQueryError(long applymentId, String errMsg, String respBody) {
@@ -823,6 +948,35 @@ public class WeChatPartnerApplymentService {
         wechatPartnerApplymentMapper.updateById(record);
     }
 
+    /**
+     * 进件状态为「已完成」且微信返回 {@code sub_mchid} 时,将特约商户号写入关联门店 {@code store_info.wechat_sub_mchid}。
+     */
+    private void syncStoreInfoWechatSubMchidIfApproved(WechatPartnerApplyment record) {
+        if (record == null || record.getStoreId() == null) {
+            return;
+        }
+        if (record.getIsApproved() == null || record.getIsApproved() != 1) {
+            return;
+        }
+        if (StringUtils.isBlank(record.getSubMchid())) {
+            log.warn("[进件] 状态已完成但 sub_mchid 为空,跳过写入门店 storeId={}", record.getStoreId());
+            return;
+        }
+        try {
+            StoreInfo si = storeInfoMapper.selectById(record.getStoreId());
+            if (si == null) {
+                log.warn("[进件] 门店不存在,跳过写入 wechat_sub_mchid storeId={}", record.getStoreId());
+                return;
+            }
+            storeInfoMapper.update(null, new LambdaUpdateWrapper<StoreInfo>()
+                    .eq(StoreInfo::getId, record.getStoreId())
+                    .set(StoreInfo::getWechatSubMchid, record.getSubMchid()));
+            log.info("[进件] 已写入门店 wechat_sub_mchid storeId={}, sub_mchid={}", record.getStoreId(), record.getSubMchid());
+        } catch (Exception e) {
+            log.error("[进件] 写入 store_info.wechat_sub_mchid 失败 storeId={}", record.getStoreId(), e);
+        }
+    }
+
     private WechatPartnerApplyment findByBusinessCode(String businessCode) {
         LambdaQueryWrapper<WechatPartnerApplyment> qw = new LambdaQueryWrapper<WechatPartnerApplyment>()
                 .eq(WechatPartnerApplyment::getBusinessCode, businessCode)

+ 16 - 0
alien-store/src/main/java/shop/alien/store/strategy/payment/PaymentStrategy.java

@@ -22,6 +22,15 @@ public interface PaymentStrategy {
      */
     R createPrePayOrder(String price, String subject) throws Exception;
 
+    /**
+     * 创建预支付订单(可选门店维度:微信服务商等策略需传 storeId 以解析子商户号)
+     *
+     * @param storeId 门店主键,非服务商策略可忽略
+     */
+    default R createPrePayOrder(String price, String subject, Integer storeId) throws Exception {
+        return createPrePayOrder(price, subject);
+    }
+
 
     /**
      * 处理支付通知
@@ -41,6 +50,13 @@ public interface PaymentStrategy {
      */
     R searchOrderByOutTradeNoPath(String transactionId) throws Exception;
 
+    /**
+     * 按商户订单号查单(可选门店维度:微信服务商需传 storeId 以解析子商户号)
+     */
+    default R searchOrderByOutTradeNoPath(String transactionId, Integer storeId) throws Exception {
+        return searchOrderByOutTradeNoPath(transactionId);
+    }
+
      /**
      * 处理退款请求
      *

+ 76 - 12
alien-store/src/main/java/shop/alien/store/strategy/payment/impl/WeChatPartnerPaymentStrategyImpl.java

@@ -8,7 +8,9 @@ import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Component;
 import shop.alien.entity.result.R;
+import shop.alien.entity.store.StoreInfo;
 import shop.alien.entity.store.RefundRecord;
+import shop.alien.mapper.StoreInfoMapper;
 import shop.alien.store.service.RefundRecordService;
 import shop.alien.store.strategy.payment.PaymentStrategy;
 import shop.alien.store.util.WXPayUtility;
@@ -51,6 +53,8 @@ import java.util.Map;
 public class WeChatPartnerPaymentStrategyImpl implements PaymentStrategy {
 
     private final RefundRecordService refundRecordService;
+    /** 按门店读取 store_info.wechat_sub_mchid(特约商户号),与前端传入的 storeId 对应 */
+    private final StoreInfoMapper storeInfoMapper;
     /** 复用直连实现中的退款单模型、HTTP 工具与 RefundRecord 构建逻辑(构建后覆盖 payType 为服务商类型) */
     private final WeChatPaymentStrategyImpl weChatPaymentStrategy;
 
@@ -80,11 +84,8 @@ public class WeChatPartnerPaymentStrategyImpl implements PaymentStrategy {
     private String spMchId;
 
     /**
-     * 【需您配置】子商户/特约商户号(sub_mchid)。
-     * 若业务为「单个子商户」可写死;多子商户时需改为从订单/门店动态传入(当前策略为配置项)。
+     * 子商户号 sub_mchid 从 {@link StoreInfo#getWechatSubMchid()}(表 store_info.wechat_sub_mchid)按前端传入的 storeId 查询,不再使用全局配置。
      */
-    @Value("${payment.wechatPartnerPay.business.subMchId}")
-    private String subMchId;
 
     /**
      * 【可选配置】子商户 AppID(sub_appid)。
@@ -144,7 +145,17 @@ public class WeChatPartnerPaymentStrategyImpl implements PaymentStrategy {
 
     @Override
     public R createPrePayOrder(String price, String subject) throws Exception {
-        log.info("[WeChatPartner] 创建预支付订单,price={}, subject={}, spMchId={}, subMchId={}", price, subject, spMchId, subMchId);
+        return createPrePayOrder(price, subject, null);
+    }
+
+    @Override
+    public R createPrePayOrder(String price, String subject, Integer storeId) throws Exception {
+        String subMchid = resolveSubMchidFromStore(storeId);
+        log.info("[WeChatPartner] 创建预支付订单,price={}, subject={}, spMchId={}, storeId={}, subMchid={}",
+                price, subject, spMchId, storeId, subMchid != null ? subMchid : "(未解析)");
+        if (subMchid == null) {
+            return R.fail("请传入门店 storeId,且门店需在 store_info 中维护微信特约商户号 wechat_sub_mchid");
+        }
         if (price == null || price.trim().isEmpty()) {
             return R.fail("价格不能为空");
         }
@@ -163,7 +174,7 @@ public class WeChatPartnerPaymentStrategyImpl implements PaymentStrategy {
         PartnerAppPrepayRequest request = new PartnerAppPrepayRequest();
         request.spAppid = spAppId;
         request.spMchid = spMchId;
-        request.subMchid = subMchId;
+        request.subMchid = subMchid;
         if (StringUtils.isNotBlank(subAppId)) {
             request.subAppid = subAppId;
         }
@@ -185,7 +196,7 @@ public class WeChatPartnerPaymentStrategyImpl implements PaymentStrategy {
             result.put("spAppId", spAppId);
             result.put("subAppId", subAppId);
             result.put("spMchId", spMchId);
-            result.put("subMchId", subMchId);
+            result.put("subMchId", subMchid);
             result.put("orderNo", request.outTradeNo);
 
             long timestamp = System.currentTimeMillis() / 1000;
@@ -212,9 +223,19 @@ public class WeChatPartnerPaymentStrategyImpl implements PaymentStrategy {
 
     @Override
     public R searchOrderByOutTradeNoPath(String outTradeNo) throws Exception {
-        log.info("[WeChatPartner] 查单 outTradeNo={}", outTradeNo);
+        return searchOrderByOutTradeNoPath(outTradeNo, null);
+    }
+
+    @Override
+    public R searchOrderByOutTradeNoPath(String outTradeNo, Integer storeId) throws Exception {
+        String subMchid = resolveSubMchidFromStore(storeId);
+        log.info("[WeChatPartner] 查单 outTradeNo={}, storeId={}, subMchid={}", outTradeNo, storeId,
+                subMchid != null ? subMchid : "(未解析)");
+        if (subMchid == null) {
+            return R.fail("请传入门店 storeId,且门店需在 store_info 中维护微信特约商户号 wechat_sub_mchid");
+        }
         try {
-            WeChatPaymentStrategyImpl.DirectAPIv3QueryResponse response = partnerSearchOrderRun(outTradeNo);
+            WeChatPaymentStrategyImpl.DirectAPIv3QueryResponse response = partnerSearchOrderRun(outTradeNo, subMchid);
             return R.data(response);
         } catch (WXPayUtility.ApiException e) {
             log.error("[WeChatPartner] 查单失败 code={}, msg={}", e.getErrorCode(), e.getMessage());
@@ -224,8 +245,15 @@ public class WeChatPartnerPaymentStrategyImpl implements PaymentStrategy {
 
     @Override
     public String handleRefund(Map<String, String> params) throws Exception {
+        Integer refundStoreId = parseStoreIdFromParams(params);
+        String subMchid = resolveSubMchidFromStore(refundStoreId);
+        log.info("[WeChatPartner] 退款请求 storeId={}, subMchid={}, outTradeNo={}",
+                refundStoreId, subMchid != null ? subMchid : "(未解析)", params.get("outTradeNo"));
+        if (subMchid == null) {
+            return "退款失败:请在参数中传入 storeId,且门店需在 store_info 中维护微信特约商户号 wechat_sub_mchid";
+        }
         PartnerRefundCreateRequest request = new PartnerRefundCreateRequest();
-        request.subMchid = subMchId;
+        request.subMchid = subMchid;
         request.outTradeNo = params.get("outTradeNo");
         request.outRefundNo = UniqueRandomNumGenerator.generateUniqueCode(19);
         request.reason = params.get("reason");
@@ -291,6 +319,42 @@ public class WeChatPartnerPaymentStrategyImpl implements PaymentStrategy {
         return PaymentEnum.WECHAT_PAY_PARTNER.getType();
     }
 
+    /**
+     * 根据门店主键查询特约商户号;缺失或空则返回 null 并打日志,便于排查配置问题。
+     */
+    private String resolveSubMchidFromStore(Integer storeId) {
+        if (storeId == null) {
+            log.warn("[WeChatPartner] storeId 为空,无法解析 wechat_sub_mchid");
+            return null;
+        }
+        StoreInfo info = storeInfoMapper.selectById(storeId);
+        if (info == null) {
+            log.warn("[WeChatPartner] 门店不存在 storeId={}", storeId);
+            return null;
+        }
+        if (StringUtils.isBlank(info.getWechatSubMchid())) {
+            log.warn("[WeChatPartner] 门店未配置 wechat_sub_mchid storeId={}", storeId);
+            return null;
+        }
+        return info.getWechatSubMchid().trim();
+    }
+
+    private static Integer parseStoreIdFromParams(Map<String, String> params) {
+        if (params == null) {
+            return null;
+        }
+        String raw = params.get("storeId");
+        if (raw == null || raw.trim().isEmpty()) {
+            return null;
+        }
+        try {
+            return Integer.valueOf(raw.trim());
+        } catch (NumberFormatException e) {
+            log.warn("[WeChatPartner] 退款参数 storeId 非整数: {}", raw);
+            return null;
+        }
+    }
+
     private WeChatPaymentStrategyImpl.DirectAPIv3AppPrepayResponse partnerPrePayOrderRun(PartnerAppPrepayRequest request) {
         String uri = prePayPath;
         String reqBody = WXPayUtility.toJson(request);
@@ -318,11 +382,11 @@ public class WeChatPartnerPaymentStrategyImpl implements PaymentStrategy {
         }
     }
 
-    private WeChatPaymentStrategyImpl.DirectAPIv3QueryResponse partnerSearchOrderRun(String outTradeNo) {
+    private WeChatPaymentStrategyImpl.DirectAPIv3QueryResponse partnerSearchOrderRun(String outTradeNo, String subMchid) {
         String uri = searchOrderByOutTradeNoPath.replace("{out_trade_no}", WXPayUtility.urlEncode(outTradeNo));
         Map<String, Object> args = new HashMap<>();
         args.put("sp_mchid", spMchId);
-        args.put("sub_mchid", subMchId);
+        args.put("sub_mchid", subMchid);
         String queryString = WXPayUtility.urlEncode(args);
         if (!queryString.isEmpty()) {
             uri = uri + "?" + queryString;

+ 2 - 0
alien-util/src/main/java/shop/alien/util/common/constant/PaymentEnum.java

@@ -13,6 +13,8 @@ public enum PaymentEnum {
     WECHAT_PAY("wechatPay"),
     /** 微信支付小程序 */
     WECHAT_PAY_MININ_PROGRAM("wechatPayMininProgram"),
+    /** 微信支付(服务商模式 / 特约商户) */
+    WECHAT_PAY_PARTNER("wechatPayPartner"),
     /** 银联支付 */
     UNION_PAY("unionPay");