Explorar o código

feat:微信入驻

刘云鑫 hai 2 semanas
pai
achega
365fb37c54

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

@@ -0,0 +1,93 @@
+package shop.alien.entity.store;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 微信支付服务商 - 特约商户进件申请单(本地落库主表)
+ */
+@Data
+@ApiModel(value = "WechatPartnerApplyment对象", description = "微信支付服务商-特约商户进件申请单(本地落库)")
+@TableName("wechat_partner_applyment")
+public class WechatPartnerApplyment {
+
+    @ApiModelProperty("主键")
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("服务商商户号sp_mchid")
+    @TableField("sp_mchid")
+    private String spMchid;
+
+    @ApiModelProperty("业务申请编号business_code")
+    @TableField("business_code")
+    private String businessCode;
+
+    @ApiModelProperty("幂等键Idempotency-Key")
+    @TableField("idempotency_key")
+    private String idempotencyKey;
+
+    @ApiModelProperty("微信支付申请单号applyment_id")
+    @TableField("applyment_id")
+    private Long applymentId;
+
+    @ApiModelProperty("特约商户号sub_mchid")
+    @TableField("sub_mchid")
+    private String subMchid;
+
+    @ApiModelProperty("超级管理员签约链接sign_url")
+    @TableField("sign_url")
+    private String signUrl;
+
+    @ApiModelProperty("申请单状态applyment_state")
+    @TableField("applyment_state")
+    private String applymentState;
+
+    @ApiModelProperty("申请状态描述applyment_state_msg")
+    @TableField("applyment_state_msg")
+    private String applymentStateMsg;
+
+    @ApiModelProperty("是否通过:0未知/处理中 1通过 2驳回")
+    @TableField("is_approved")
+    private Integer isApproved;
+
+    @ApiModelProperty("不通过理由(聚合audit_detail.reject_reason等)")
+    @TableField("reject_reason")
+    private String rejectReason;
+
+    @ApiModelProperty("最后一次提交时间")
+    @TableField("last_submit_time")
+    private Date lastSubmitTime;
+
+    @ApiModelProperty("最后一次查询时间")
+    @TableField("last_query_time")
+    private Date lastQueryTime;
+
+    @ApiModelProperty("提交申请单请求体JSON(建议存最终提交给微信的那份)")
+    @TableField("request_json")
+    private String requestJson;
+
+    @ApiModelProperty("最后一次提交微信响应原文JSON")
+    @TableField("last_submit_resp_json")
+    private String lastSubmitRespJson;
+
+    @ApiModelProperty("最后一次查询微信响应原文JSON")
+    @TableField("last_query_resp_json")
+    private String lastQueryRespJson;
+
+    @ApiModelProperty("创建时间")
+    @TableField("created_at")
+    private Date createdAt;
+
+    @ApiModelProperty("更新时间")
+    @TableField("updated_at")
+    private Date updatedAt;
+}
+

+ 11 - 0
alien-entity/src/main/java/shop/alien/mapper/WechatPartnerApplymentMapper.java

@@ -0,0 +1,11 @@
+package shop.alien.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import shop.alien.entity.store.WechatPartnerApplyment;
+
+/**
+ * 微信支付服务商-特约商户进件申请单 Mapper
+ */
+public interface WechatPartnerApplymentMapper extends BaseMapper<WechatPartnerApplyment> {
+}
+

+ 97 - 0
alien-store/src/main/java/shop/alien/store/controller/WeChatPartnerApplymentController.java

@@ -0,0 +1,97 @@
+package shop.alien.store.controller;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiImplicitParams;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+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.store.service.WeChatPartnerApplymentService;
+
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * 微信支付服务商 — 特约商户进件
+ * <p>
+ * 对外路径与微信 API 路径段对齐:{@code .../v3/applyment4sub/applyment/},
+ * 实际转发至 {@code https://api.mch.weixin.qq.com/v3/applyment4sub/applyment/}。
+ * </p>
+ */
+@Slf4j
+@Api(tags = {"微信支付服务商-特约进件"})
+@CrossOrigin
+@RestController
+@RequestMapping("/payment/wechatPartner")
+@RequiredArgsConstructor
+public class WeChatPartnerApplymentController {
+
+    private final WeChatPartnerApplymentService weChatPartnerApplymentService;
+
+    /**
+     * 提交特约商户进件申请单(对应微信 {@code POST /v3/applyment4sub/applyment/})
+     * <p>
+     * 请求体 JSON 字段以微信支付官方文档为准(如 business_code、contact_info、subject_info、
+     * bank_account_info 等),本接口不做字段级校验,原样转发。
+     * </p>
+     *
+     * @param requestBody    与微信文档一致的 JSON 字符串
+     * @param idempotencyKey 可选,传入 {@code Idempotency-Key},同一业务单重试时请保持不变
+     */
+    @ApiOperation("特约商户进件-提交申请单(转发微信 /v3/applyment4sub/applyment/)")
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "Idempotency-Key", value = "幂等键,可选;重试同一进件时请与首次一致", paramType = "header", dataType = "String")
+    })
+    @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);
+    }
+
+    /**
+     * 商户图片上传,对应微信 {@code POST /v3/merchant/media/upload},成功返回 {@code media_id}。
+     */
+    @ApiOperation("特约商户进件-图片上传(转发微信 /v3/merchant/media/upload,返回 media_id)")
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "file", value = "图片文件 JPG/PNG/BMP,≤5MB", required = true, dataType = "file", paramType = "form")
+    })
+    @PostMapping(value = "/v3/merchant/media/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    public R<Map<String, Object>> uploadMerchantMedia(@RequestParam("file") MultipartFile file) {
+        if (file == null || file.isEmpty()) {
+            return R.fail("请选择图片文件");
+        }
+        try {
+            return weChatPartnerApplymentService.uploadMerchantMedia(file.getBytes(), file.getOriginalFilename());
+        } catch (IOException e) {
+            return R.fail("读取上传文件失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 申请单号查询申请状态,对应微信
+     * {@code GET /v3/applyment4sub/applyment/applyment_id/{applyment_id}}
+     * (<a href="https://pay.weixin.qq.com/doc/v3/partner/4012697052">文档</a>)。
+     *
+     * @param applymentId 微信支付分配的申请单号
+     */
+    @ApiOperation("特约商户进件-申请单号查询申请状态(转发微信 GET .../applyment_id/{applyment_id})")
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "applyment_id", value = "微信支付申请单号", required = true, paramType = "path", dataType = "Long")
+    })
+    @GetMapping("/v3/applyment4sub/applyment/applyment_id/{applyment_id}")
+    public R<Map<String, Object>> queryApplymentStateByApplymentId(
+            @PathVariable("applyment_id") Long applymentId) {
+        if (applymentId == null) {
+            return R.fail("applyment_id 不能为空");
+        }
+        log.info("WeChatPartnerApplymentController.queryApplymentStateByApplymentId applyment_id={}", applymentId);
+        return weChatPartnerApplymentService.queryApplymentStateByApplymentId(applymentId);
+    }
+}

+ 949 - 0
alien-store/src/main/java/shop/alien/store/service/WeChatPartnerApplymentService.java

@@ -0,0 +1,949 @@
+package shop.alien.store.service;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+import org.apache.commons.lang3.StringUtils;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import shop.alien.entity.store.WechatPartnerApplyment;
+import shop.alien.entity.result.R;
+import shop.alien.mapper.WechatPartnerApplymentMapper;
+import shop.alien.store.util.WXPayUtility;
+import shop.alien.util.system.OSUtil;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.Resource;
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 微信支付服务商 — 特约商户进件(提交申请单)
+ * <p>
+ * 对应微信接口:{@code POST /v3/applyment4sub/applyment/},请求体结构以
+ * <a href="https://pay.weixin.qq.com/doc/v3/partner/4012719997">官方文档(提交申请单)</a> 为准;
+ * 提交前会经 {@link #normalizeApplymentJsonForV3(String)} 将旧版扁平 {@code identity_info} 等写法规范为当前文档结构。
+ * </p>
+ * <p>
+ * 使用与 {@code payment.wechatPartnerPay} 相同的服务商商户证书、{@code sp_mchid} 签名。
+ * </p>
+ * <p>
+ * 前端可传敏感字段明文,由 {@link #encryptSensitiveFieldsInApplymentJson(String)} 使用<strong>微信支付平台公钥</strong>
+ * 按文档做 RSA-OAEP 加密后再提交(与 {@link WXPayUtility#encrypt(PublicKey, String)} 一致)。
+ * </p>
+ * <p>
+ * 若微信返回「请确认待处理的消息是否为加密后的密文」:① 核对商户平台下载的<strong>微信支付平台公钥</strong>文件与
+ * {@code wechatPayPublicKeyId}(请求头 {@code Wechatpay-Serial})为同一组;② 勿对已是密文或 media_id 的字段再加密;
+ * ③ 可临时设 {@code payment.wechatPartnerPay.applymentSkipSensitiveEncrypt=true} 判断是否加密链路问题(仅调试)。
+ * </p>
+ */
+@Slf4j
+@Service
+public class WeChatPartnerApplymentService {
+
+    private static final String POST = "POST";
+    private static final String GET = "GET";
+    /** 微信商户图片上传:单文件最大 5MB */
+    private static final long MERCHANT_MEDIA_MAX_BYTES = 5L * 1024 * 1024;
+    private static final Gson GSON = new Gson();
+    private static final Type MAP_TYPE = new TypeToken<Map<String, Object>>() {}.getType();
+
+    /**
+     * 特约商户进件 JSON 中,凡键名在此集合内且值为非空字符串(或数字),将视为需加密的敏感信息。
+     * 名称与微信「提交申请单」文档中要求加密的字段保持一致;若文档新增字段,可在此补充。
+     */
+    private static final Set<String> APPLYMENT_SENSITIVE_FIELD_NAMES;
+
+    static {
+        Set<String> s = new HashSet<>(Arrays.asList(
+                // contact_info
+                "contact_name",
+                "contact_id_number",
+                "mobile_phone",
+                "contact_email",
+                // subject_info.identity_info.id_card_info / id_doc_info
+                "id_card_name",
+                "id_card_number",
+                "id_card_address",
+                "id_doc_name",
+                "id_doc_number",
+                "id_doc_address",
+                // ubo_info_list[]
+                "ubo_id_doc_name",
+                "ubo_id_doc_number",
+                "ubo_id_doc_address",
+                // bank_account_info(微信字段名为 account_number,非 bank_account)
+                "account_name",
+                "account_number",
+                "bank_account"
+        ));
+        APPLYMENT_SENSITIVE_FIELD_NAMES = Collections.unmodifiableSet(s);
+    }
+
+    @Value("${payment.wechatPartnerPay.host:https://api.mch.weixin.qq.com}")
+    private String wechatPayApiHost;
+
+    /**
+     * 【需配置】进件提交路径,须与微信 API 一致(注意末尾斜杠与签名用 path 一致)
+     */
+    @Value("${payment.wechatPartnerPay.applyment4subPath:/v3/applyment4sub/applyment/}")
+    private String applyment4subPath;
+
+    /**
+     * 商户图片上传(用于进件等材料),与微信 {@code POST /v3/merchant/media/upload} 一致
+     */
+    @Value("${payment.wechatPartnerPay.mediaUploadPath:/v3/merchant/media/upload}")
+    private String mediaUploadPath;
+
+    /**
+     * 【需配置】申请单号查询申请状态路径模板,须含占位符 {@code {applyment_id}},与微信签名用 URI 一致。
+     * 文档:{@code GET /v3/applyment4sub/applyment/applyment_id/{applyment_id}}
+     */
+    @Value("${payment.wechatPartnerPay.applymentQueryByIdPath:/v3/applyment4sub/applyment/applyment_id/{applyment_id}}")
+    private String applymentQueryByIdPath;
+
+    @Value("${payment.wechatPartnerPay.business.spMchId}")
+    private String spMchId;
+
+    @Value("${payment.wechatPartnerPay.business.win.privateKeyPath}")
+    private String privateWinKeyPath;
+
+    @Value("${payment.wechatPartnerPay.business.linux.privateKeyPath}")
+    private String privateLinuxKeyPath;
+
+    @Value("${payment.wechatPartnerPay.business.win.wechatPayPublicKeyFilePath}")
+    private String wechatWinPayPublicKeyFilePath;
+
+    @Value("${payment.wechatPartnerPay.business.linux.wechatPayPublicKeyFilePath}")
+    private String wechatLinuxPayPublicKeyFilePath;
+
+    @Value("${payment.wechatPartnerPay.business.merchantSerialNumber}")
+    private String merchantSerialNumber;
+
+    @Value("${payment.wechatPartnerPay.business.wechatPayPublicKeyId}")
+    private String wechatPayPublicKeyId;
+
+    /**
+     * 为 true 时<strong>不对</strong>进件 JSON 做敏感字段 RSA 加密,直接提交(仅联调排查「密文」类报错时使用)。
+     * 生产环境必须为 false。
+     */
+    @Value("${payment.wechatPartnerPay.applymentSkipSensitiveEncrypt:false}")
+    private boolean applymentSkipSensitiveEncrypt;
+
+    private PrivateKey privateKey;
+    private PublicKey wechatPayPublicKey;
+
+    @Resource
+    private WechatPartnerApplymentMapper wechatPartnerApplymentMapper;
+
+    @PostConstruct
+    public void loadCertificates() {
+        String privateKeyPath;
+        String pubPath;
+        if ("windows".equals(OSUtil.getOsName())) {
+            privateKeyPath = privateWinKeyPath;
+            pubPath = wechatWinPayPublicKeyFilePath;
+        } else {
+            privateKeyPath = privateLinuxKeyPath;
+            pubPath = wechatLinuxPayPublicKeyFilePath;
+        }
+        log.info("[进件] 加载服务商证书 privateKeyPath={}", privateKeyPath);
+        this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyPath);
+        this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(pubPath);
+    }
+
+    /**
+     * 调用微信支付「商户图片上传」接口,返回 {@code media_id} 供进件等场景引用。
+     * <p>
+     * 签名原文为 {@code meta} 的 JSON 字符串(与 multipart 中 meta 段一致),算法见微信文档。
+     * </p>
+     *
+     * @param fileContent      图片二进制(JPG/PNG/BMP,≤5MB)
+     * @param originalFilename 原始文件名,需带合法后缀;仅用于 meta.filename 与 file 段展示名
+     * @return 成功时 data 中含 {@code media_id};失败为 R.fail
+     */
+    public R<Map<String, Object>> uploadMerchantMedia(byte[] fileContent, String originalFilename) {
+        if (fileContent == null || fileContent.length == 0) {
+            log.warn("[图片上传] 文件为空");
+            return R.fail("文件不能为空");
+        }
+        if (fileContent.length > MERCHANT_MEDIA_MAX_BYTES) {
+            log.warn("[图片上传] 超过大小限制 size={}", fileContent.length);
+            return R.fail("图片大小不能超过 5MB");
+        }
+        String safeName = sanitizeUploadFilename(originalFilename);
+        if (!hasAllowedImageExtension(safeName)) {
+            log.warn("[图片上传] 后缀不允许 filename={}", safeName);
+            return R.fail("仅支持 JPG、JPEG、BMP、PNG 格式");
+        }
+        String sha256Hex;
+        try {
+            sha256Hex = sha256HexLower(fileContent);
+        } catch (NoSuchAlgorithmException e) {
+            log.error("[图片上传] SHA256 不可用", e);
+            return R.fail("服务器摘要算法不可用");
+        }
+        // 顺序固定,保证 meta JSON 与 Authorization 签名原文、multipart 中 meta 段字节完全一致
+        Map<String, String> metaMap = new LinkedHashMap<>();
+        metaMap.put("filename", safeName);
+        metaMap.put("sha256", sha256Hex);
+        String metaJson = GSON.toJson(metaMap);
+
+        String uri = mediaUploadPath;
+        if (StringUtils.isBlank(uri)) {
+            uri = "/v3/merchant/media/upload";
+        }
+        String url = wechatPayApiHost + uri;
+        log.info("[图片上传] 调用微信 url={}, filename={}, size={}, sha256={}",
+                url, safeName, fileContent.length, sha256Hex);
+
+        MediaType fileMediaType = guessImageMediaType(safeName);
+        RequestBody filePart = RequestBody.create(fileContent, fileMediaType);
+        MultipartBody multipartBody = new MultipartBody.Builder()
+                .setType(MultipartBody.FORM)
+                .addFormDataPart("meta", metaJson)
+                .addFormDataPart("file", safeName, filePart)
+                .build();
+
+        Request.Builder builder = new Request.Builder().url(url);
+        builder.addHeader("Accept", "application/json");
+        builder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
+        builder.addHeader("Authorization", WXPayUtility.buildAuthorization(
+                spMchId, merchantSerialNumber, privateKey, POST, uri, metaJson));
+        builder.post(multipartBody);
+        Request request = builder.build();
+
+        OkHttpClient client = new OkHttpClient.Builder().build();
+        try (Response response = client.newCall(request).execute()) {
+            String respBody = WXPayUtility.extractBody(response);
+            if (response.code() >= 200 && response.code() < 300) {
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey,
+                        response.headers(), respBody);
+                Map<String, Object> map = parseJsonToMap(respBody);
+                log.info("[图片上传] 成功 media_id={}", map != null ? map.get("media_id") : null);
+                return R.data(map);
+            }
+            log.error("[图片上传] 微信返回失败 httpCode={}, body={}", response.code(), respBody);
+            WXPayUtility.ApiException ex = new WXPayUtility.ApiException(response.code(), respBody, response.headers());
+            String errMsg = StringUtils.isNotBlank(ex.getErrorMessage()) ? ex.getErrorMessage()
+                    : (StringUtils.isNotBlank(respBody) ? respBody : "图片上传失败");
+            return R.fail(errMsg);
+        } catch (IOException e) {
+            log.error("[图片上传] HTTP 异常 url={}", url, e);
+            return R.fail("图片上传网络异常: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 去掉路径、仅保留文件名;若无后缀则默认 {@code upload.png}
+     */
+    private static String sanitizeUploadFilename(String originalFilename) {
+        String name = StringUtils.isNotBlank(originalFilename) ? originalFilename.trim() : "upload.png";
+        int slash = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\'));
+        if (slash >= 0 && slash < name.length() - 1) {
+            name = name.substring(slash + 1);
+        }
+        if (StringUtils.isBlank(name) || !name.contains(".")) {
+            name = "upload.png";
+        }
+        return name;
+    }
+
+    private static boolean hasAllowedImageExtension(String filename) {
+        String lower = filename.toLowerCase(Locale.ROOT);
+        return lower.endsWith(".jpg") || lower.endsWith(".jpeg")
+                || lower.endsWith(".png") || lower.endsWith(".bmp");
+    }
+
+    private static MediaType guessImageMediaType(String filename) {
+        String lower = filename.toLowerCase(Locale.ROOT);
+        if (lower.endsWith(".png")) {
+            return MediaType.parse("image/png");
+        }
+        if (lower.endsWith(".bmp")) {
+            return MediaType.parse("image/bmp");
+        }
+        return MediaType.parse("image/jpeg");
+    }
+
+    private static String sha256HexLower(byte[] data) throws NoSuchAlgorithmException {
+        MessageDigest md = MessageDigest.getInstance("SHA-256");
+        byte[] digest = md.digest(data);
+        StringBuilder sb = new StringBuilder(digest.length * 2);
+        for (byte b : digest) {
+            sb.append(String.format(Locale.ROOT, "%02x", b));
+        }
+        return sb.toString();
+    }
+
+    /**
+     * 对进件请求 JSON 中需要加密的敏感字段(见 {@link #APPLYMENT_SENSITIVE_FIELD_NAMES})逐层遍历:
+     * 若存在对应键且值为非空明文,则使用<strong>当前配置的微信支付平台公钥</strong>加密为 Base64 密文后写回。
+     * <p>
+     * 算法与 {@link WXPayUtility#encrypt(PublicKey, String)} 一致(RSA/ECB/OAEPWithSHA-1AndMGF1Padding)。
+     * 若某字段在 JSON 中不存在或为空字符串,则跳过。
+     * </p>
+     *
+     * @param plainApplymentJson 前端传入的进件 JSON(敏感字段可为明文)
+     * @return 加密后的 JSON 字符串,用于签名与请求体
+     * @throws com.google.gson.JsonSyntaxException JSON 无法解析
+     * @throws IllegalArgumentException           某字段明文过长导致 RSA 加密失败等
+     */
+    public String encryptSensitiveFieldsInApplymentJson(String plainApplymentJson) {
+        if (applymentSkipSensitiveEncrypt) {
+            log.warn("[进件] 已配置 applymentSkipSensitiveEncrypt=true,跳过敏感字段加密(仅调试用)");
+            return plainApplymentJson;
+        }
+        if (StringUtils.isBlank(plainApplymentJson)) {
+            return plainApplymentJson;
+        }
+        Map<String, Object> root = GSON.fromJson(plainApplymentJson, MAP_TYPE);
+        if (root == null || root.isEmpty()) {
+            return plainApplymentJson;
+        }
+        AtomicInteger encryptedCount = new AtomicInteger(0);
+        encryptSensitiveFieldsRecursive(root, wechatPayPublicKey, encryptedCount);
+        log.info("[进件] 敏感字段加密完成,已加密字段数={}", encryptedCount.get());
+        return GSON.toJson(root);
+    }
+
+    /**
+     * 递归遍历 Map / List,对命中键名的字符串(或数字转字符串)做加密替换。
+     */
+    @SuppressWarnings("unchecked")
+    private void encryptSensitiveFieldsRecursive(Object node, PublicKey platformPublicKey, AtomicInteger encryptedCount) {
+        if (node == null) {
+            return;
+        }
+        if (node instanceof Map) {
+            Map<String, Object> map = (Map<String, Object>) node;
+            for (Map.Entry<String, Object> e : map.entrySet()) {
+                String key = e.getKey();
+                Object val = e.getValue();
+                if (APPLYMENT_SENSITIVE_FIELD_NAMES.contains(key)) {
+                    String plain = null;
+                    if (val instanceof String) {
+                        plain = (String) val;
+                    } else if (val instanceof Number) {
+                        plain = String.valueOf(val);
+                    }
+                    if (StringUtils.isNotBlank(plain)) {
+                        if (looksLikeAlreadyEncryptedOrNonPlain(plain)) {
+                            log.info("[进件] 跳过加密(疑似已是密文或非明文) key={}, len={}", key,
+                                    plain != null ? plain.length() : 0);
+                            continue;
+                        }
+                        try {
+                            String cipher = WXPayUtility.encrypt(platformPublicKey, plain);
+                            e.setValue(cipher);
+                            encryptedCount.incrementAndGet();
+                            log.debug("[进件] 已加密字段 key={}", key);
+                        } catch (RuntimeException ex) {
+                            log.error("[进件] 加密字段失败 key={}, plainLen={}", key,
+                                    plain != null ? plain.length() : 0, ex);
+                            throw ex;
+                        }
+                    }
+                } else if (val instanceof Map) {
+                    encryptSensitiveFieldsRecursive(val, platformPublicKey, encryptedCount);
+                } else if (val instanceof List) {
+                    for (Object item : (List<?>) val) {
+                        encryptSensitiveFieldsRecursive(item, platformPublicKey, encryptedCount);
+                    }
+                }
+            }
+        } else if (node instanceof List) {
+            for (Object item : (List<?>) node) {
+                encryptSensitiveFieldsRecursive(item, platformPublicKey, encryptedCount);
+            }
+        }
+    }
+
+    /**
+     * 避免对已是微信返回的密文、media_id、或长 Base64 再套一层 RSA(否则会触发「请确认待处理的消息是否为加密后的密文」)。
+     */
+    private static boolean looksLikeAlreadyEncryptedOrNonPlain(String plain) {
+        if (plain.length() < 32) {
+            return false;
+        }
+        // 图片/材料 media_id 常见形态
+        if (plain.startsWith("V") && plain.contains("_") && plain.length() > 40) {
+            return true;
+        }
+        // 典型 RSA2048+OAEP 密文 Base64 长度较大,且仅含 Base64 字符
+        if (plain.length() >= 200 && plain.matches("^[A-Za-z0-9+/=_-]+$")) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * 将进件 JSON 规范为微信支付 APIv3《提交申请单》当前文档结构(身份证信息须放在 {@code identity_info.id_card_info} 下)。
+     * <ul>
+     *   <li>若 {@code subject_info.identity_info} 在根级存放了 {@code id_card_copy} 等(旧写法),且未在 {@code id_card_info} 中给出,
+     *       则迁入 {@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>
+     * </ul>
+     *
+     * @param json 前端或历史模板传入的 JSON 字符串
+     * @return 规范后的 JSON;解析失败时原样返回并打日志
+     */
+    public String normalizeApplymentJsonForV3(String json) {
+        if (StringUtils.isBlank(json)) {
+            return json;
+        }
+        Map<String, Object> root;
+        try {
+            root = GSON.fromJson(json, MAP_TYPE);
+        } catch (Exception e) {
+            log.warn("[进件] normalizeApplymentJsonForV3 解析失败,原样提交: {}", e.getMessage());
+            return json;
+        }
+        if (root == null || root.isEmpty()) {
+            return json;
+        }
+        try {
+            normalizeIdentityInfoIdCardNested(root);
+            normalizeForeverToLongPeriodRecursive(root);
+        } catch (Exception e) {
+            log.error("[进件] normalizeApplymentJsonForV3 处理异常,原样提交", e);
+            return json;
+        }
+        String out = GSON.toJson(root);
+        log.info("[进件] 已按 APIv3 文档规范 identity_info.id_card_info 等结构,输出长度={}", out.length());
+        return out;
+    }
+
+    /**
+     * 将 {@code subject_info.identity_info} 下扁平身份证字段迁入 {@code id_card_info},并重命名有效期字段。
+     */
+    @SuppressWarnings("unchecked")
+    private static void normalizeIdentityInfoIdCardNested(Map<String, Object> root) {
+        Object subj = root.get("subject_info");
+        if (!(subj instanceof Map)) {
+            return;
+        }
+        Map<String, Object> subjectInfo = (Map<String, Object>) subj;
+        Object idObj = subjectInfo.get("identity_info");
+        if (!(idObj instanceof Map)) {
+            return;
+        }
+        Map<String, Object> identityInfo = (Map<String, Object>) idObj;
+
+        Object existing = identityInfo.get("id_card_info");
+        Map<String, Object> idCardInfo;
+        if (existing instanceof Map) {
+            idCardInfo = (Map<String, Object>) existing;
+        } else {
+            idCardInfo = new LinkedHashMap<>();
+            identityInfo.put("id_card_info", idCardInfo);
+        }
+
+        String[] migrateKeys = {
+                "id_card_copy", "id_card_national", "id_card_name", "id_card_number", "id_card_address"
+        };
+        for (String mk : migrateKeys) {
+            if (!identityInfo.containsKey(mk)) {
+                continue;
+            }
+            Object v = identityInfo.get(mk);
+            Object cur = idCardInfo.get(mk);
+            if (isNullOrBlankish(cur) && !isNullOrBlankish(v)) {
+                idCardInfo.put(mk, v);
+            }
+            identityInfo.remove(mk);
+        }
+        if (identityInfo.containsKey("id_card_valid_time_begin")) {
+            if (!idCardInfo.containsKey("card_period_begin")) {
+                idCardInfo.put("card_period_begin", identityInfo.get("id_card_valid_time_begin"));
+            }
+            identityInfo.remove("id_card_valid_time_begin");
+        }
+        if (identityInfo.containsKey("id_card_valid_time")) {
+            if (!idCardInfo.containsKey("card_period_end")) {
+                idCardInfo.put("card_period_end", identityInfo.get("id_card_valid_time"));
+            }
+            identityInfo.remove("id_card_valid_time");
+        }
+
+        renameMapKeyIfPresent(idCardInfo, "id_card_valid_time_begin", "card_period_begin");
+        renameMapKeyIfPresent(idCardInfo, "id_card_valid_time", "card_period_end");
+
+        if (idCardInfo.isEmpty()) {
+            identityInfo.remove("id_card_info");
+        }
+    }
+
+    private static boolean isNullOrBlankish(Object v) {
+        if (v == null) {
+            return true;
+        }
+        if (v instanceof String) {
+            return StringUtils.isBlank((String) v);
+        }
+        return false;
+    }
+
+    private static void renameMapKeyIfPresent(Map<String, Object> map, String oldKey, String newKey) {
+        if (!map.containsKey(oldKey)) {
+            return;
+        }
+        if (map.containsKey(newKey)) {
+            map.remove(oldKey);
+            return;
+        }
+        Object val = map.remove(oldKey);
+        map.put(newKey, val);
+    }
+
+    /**
+     * 文档要求身份证等到期填「长期」,部分示例使用 forever;统一为「长期」避免校验失败。
+     */
+    @SuppressWarnings("unchecked")
+    private static void normalizeForeverToLongPeriodRecursive(Object node) {
+        if (node instanceof Map) {
+            Map<String, Object> m = (Map<String, Object>) node;
+            for (Map.Entry<String, Object> e : m.entrySet()) {
+                String k = e.getKey();
+                Object v = e.getValue();
+                if (v instanceof String && "forever".equalsIgnoreCase((String) v)) {
+                    if ("period_end".equals(k) || (k != null && k.endsWith("period_end"))) {
+                        e.setValue("长期");
+                    }
+                } else if (v instanceof Map || v instanceof List) {
+                    normalizeForeverToLongPeriodRecursive(v);
+                }
+            }
+        } else if (node instanceof List) {
+            for (Object item : (List<?>) node) {
+                normalizeForeverToLongPeriodRecursive(item);
+            }
+        }
+    }
+
+    /**
+     * 提交特约商户进件申请单,请求体为微信要求的 JSON(含 business_code、subject_info、bank_account_info 等)。
+     *
+     * @param requestJson  与微信文档一致的 JSON 字符串(可先经 {@link #normalizeApplymentJsonForV3(String)} 规范结构)
+     * @param idempotencyKey 可选;传入则写入 {@code Idempotency-Key} 请求头,用于幂等重试(同一进件业务请固定同一值)
+     * @return 成功时 data 为微信返回的 JSON 解析后的 Map;失败为 R.fail
+     */
+    public R<Map<String, Object>> submitApplyment(String requestJson, String idempotencyKey) {
+        if (StringUtils.isBlank(requestJson)) {
+            log.warn("[进件] 请求体为空");
+            return R.fail("请求体不能为空");
+        }
+        final String normalizedJson = normalizeApplymentJsonForV3(requestJson);
+        final String payloadJson;
+        try {
+            payloadJson = encryptSensitiveFieldsInApplymentJson(normalizedJson);
+        } catch (Exception e) {
+            log.error("[进件] 敏感字段加密失败", e);
+            return R.fail("进件请求敏感信息加密失败: " + e.getMessage());
+        }
+
+        // 落库:提交前先写入/更新一条记录(以 sp_mchid + business_code 唯一)
+        String businessCode = extractBusinessCodeSafe(normalizedJson);
+        upsertApplymentOnSubmitStart(businessCode, idempotencyKey, payloadJson);
+
+        String uri = applyment4subPath;
+        String url = wechatPayApiHost + uri;
+        log.info("[进件] 调用微信进件接口 url={}, bodyLen={}, hasIdempotencyKey={}",
+                url, payloadJson.length(), StringUtils.isNotBlank(idempotencyKey));
+
+        Request.Builder builder = new Request.Builder().url(url);
+        builder.addHeader("Accept", "application/json");
+        builder.addHeader("Content-Type", "application/json; charset=utf-8");
+        builder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
+        builder.addHeader("Authorization", WXPayUtility.buildAuthorization(
+                spMchId, merchantSerialNumber, privateKey, POST, uri, payloadJson));
+        if (StringUtils.isNotBlank(idempotencyKey)) {
+            builder.addHeader("Idempotency-Key", idempotencyKey);
+        }
+
+        MediaType jsonMediaType = MediaType.parse("application/json; charset=utf-8");
+        RequestBody body = RequestBody.create(payloadJson, jsonMediaType);
+        builder.method(POST, body);
+        Request request = builder.build();
+
+        OkHttpClient client = new OkHttpClient.Builder().build();
+        try (Response response = client.newCall(request).execute()) {
+            String respBody = WXPayUtility.extractBody(response);
+            if (response.code() >= 200 && response.code() < 300) {
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey,
+                        response.headers(), respBody);
+                Map<String, Object> map = parseJsonToMap(respBody);
+                log.info("[进件] 微信受理成功 httpCode={}", response.code());
+                upsertApplymentOnSubmitResult(businessCode, map, respBody);
+                return R.data(map);
+            }
+            log.error("[进件] 微信返回失败 httpCode={}, body={}", response.code(), respBody);
+            WXPayUtility.ApiException ex = new WXPayUtility.ApiException(response.code(), respBody, response.headers());
+            String errMsg = StringUtils.isNotBlank(ex.getErrorMessage()) ? ex.getErrorMessage()
+                    : (StringUtils.isNotBlank(respBody) ? respBody : "进件请求失败");
+            upsertApplymentOnSubmitError(businessCode, errMsg, respBody);
+            return R.fail(errMsg);
+        } catch (IOException e) {
+            log.error("[进件] HTTP 调用异常 url={}", url, e);
+            upsertApplymentOnSubmitError(businessCode, "进件请求网络异常: " + e.getMessage(), null);
+            return R.fail("进件请求网络异常: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 申请单号查询申请状态,对应微信
+     * <a href="https://pay.weixin.qq.com/doc/v3/partner/4012697052">GET /v3/applyment4sub/applyment/applyment_id/{applyment_id}</a>。
+     * <p>
+     * 成功时返回体含 {@code applyment_state}、{@code applyment_state_msg}、{@code sign_url}、
+     * {@code audit_detail}(驳回时)等,与官方文档一致。
+     * </p>
+     *
+     * @param applymentId 微信支付分配的申请单号
+     * @return 成功时 data 为微信 JSON 解析后的 Map;失败为 R.fail
+     */
+    public R<Map<String, Object>> queryApplymentStateByApplymentId(long applymentId) {
+        if (applymentId <= 0) {
+            log.warn("[进件查询] applyment_id 非法 applymentId={}", applymentId);
+            return R.fail("applyment_id 必须为正整数");
+        }
+        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 url = wechatPayApiHost + uri;
+        log.info("[进件查询] 调用微信查询申请单状态 url={}, uri={}", url, uri);
+
+        Request.Builder builder = new Request.Builder().url(url);
+        builder.addHeader("Accept", "application/json");
+        builder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
+        builder.addHeader("Authorization", WXPayUtility.buildAuthorization(
+                spMchId, merchantSerialNumber, privateKey, GET, uri, null));
+        builder.get();
+        Request request = builder.build();
+
+        OkHttpClient client = new OkHttpClient.Builder().build();
+        try (Response response = client.newCall(request).execute()) {
+            String respBody = WXPayUtility.extractBody(response);
+            if (response.code() >= 200 && response.code() < 300) {
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey,
+                        response.headers(), respBody);
+                Map<String, Object> map = parseJsonToMap(respBody);
+                log.info("[进件查询] 成功 applymentId={}, state={}",
+                        applymentId, map != null ? map.get("applyment_state") : null);
+                upsertApplymentOnQueryResult(applymentId, map, respBody);
+                return R.data(map);
+            }
+            log.error("[进件查询] 微信返回失败 applymentId={}, httpCode={}, body={}",
+                    applymentId, response.code(), respBody);
+            WXPayUtility.ApiException ex = new WXPayUtility.ApiException(response.code(), respBody, response.headers());
+            String errMsg = StringUtils.isNotBlank(ex.getErrorMessage()) ? ex.getErrorMessage()
+                    : (StringUtils.isNotBlank(respBody) ? respBody : "查询申请单状态失败");
+            upsertApplymentOnQueryError(applymentId, errMsg, respBody);
+            return R.fail(errMsg);
+        } catch (IOException e) {
+            log.error("[进件查询] HTTP 异常 applymentId={}, url={}", applymentId, url, e);
+            upsertApplymentOnQueryError(applymentId, "查询申请单状态网络异常: " + e.getMessage(), null);
+            return R.fail("查询申请单状态网络异常: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 从进件 JSON 中提取 business_code(用于落库幂等主键);解析失败则返回 null。
+     */
+    private static String extractBusinessCodeSafe(String requestJson) {
+        if (StringUtils.isBlank(requestJson)) {
+            return null;
+        }
+        try {
+            Map<String, Object> root = GSON.fromJson(requestJson, MAP_TYPE);
+            Object v = root != null ? root.get("business_code") : null;
+            if (v == null) {
+                return null;
+            }
+            String s = String.valueOf(v).trim();
+            return StringUtils.isNotBlank(s) ? s : null;
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private void upsertApplymentOnSubmitStart(String businessCode, String idempotencyKey, String payloadJson) {
+        if (StringUtils.isBlank(businessCode)) {
+            log.warn("[进件落库] business_code 为空,跳过落库(请在请求体中填写 business_code)");
+            return;
+        }
+        String dbSpMchid = spMchidForDb();
+        Date now = new Date();
+        WechatPartnerApplyment record = findByBusinessCode(businessCode);
+        if (record == null) {
+            record = new WechatPartnerApplyment();
+            record.setSpMchid(dbSpMchid);
+            record.setBusinessCode(businessCode);
+            record.setCreatedAt(now);
+            record.setIsApproved(0);
+            record.setApplymentState("APPLYMENT_STATE_EDITTING");
+        }
+        // 若历史记录的 sp_mchid 为空(或 UNKNOWN),且现在已可确定,则补齐
+        if (StringUtils.isBlank(record.getSpMchid()) || "UNKNOWN".equalsIgnoreCase(record.getSpMchid())) {
+            record.setSpMchid(dbSpMchid);
+        }
+        record.setIdempotencyKey(idempotencyKey);
+        record.setLastSubmitTime(now);
+        record.setRequestJson(payloadJson);
+        record.setUpdatedAt(now);
+        if (record.getId() == null) {
+            wechatPartnerApplymentMapper.insert(record);
+        } else {
+            wechatPartnerApplymentMapper.updateById(record);
+        }
+        log.info("[进件落库] submitStart sp_mchid={}, business_code={}, id={}", record.getSpMchid(), businessCode, record.getId());
+    }
+
+    private void upsertApplymentOnSubmitResult(String businessCode, Map<String, Object> respMap, String respBody) {
+        if (StringUtils.isBlank(businessCode)) {
+            return;
+        }
+        String dbSpMchid = spMchidForDb();
+        WechatPartnerApplyment record = findByBusinessCode(businessCode);
+        if (record == null) {
+            record = new WechatPartnerApplyment();
+            record.setSpMchid(dbSpMchid);
+            record.setBusinessCode(businessCode);
+            record.setCreatedAt(new Date());
+        }
+        if (StringUtils.isBlank(record.getSpMchid()) || "UNKNOWN".equalsIgnoreCase(record.getSpMchid())) {
+            record.setSpMchid(dbSpMchid);
+        }
+        Date now = new Date();
+        record.setLastSubmitRespJson(respBody);
+        record.setUpdatedAt(now);
+        record.setApplymentId(asLong(respMap != null ? respMap.get("applyment_id") : null));
+        record.setSubMchid(asString(respMap != null ? respMap.get("sub_mchid") : null));
+        record.setSignUrl(asString(respMap != null ? respMap.get("sign_url") : null));
+        record.setApplymentState(asString(respMap != null ? respMap.get("applyment_state") : null));
+        record.setApplymentStateMsg(asString(respMap != null ? respMap.get("applyment_state_msg") : null));
+        record.setIsApproved(calcIsApproved(record.getApplymentState()));
+        if (record.getIsApproved() != null && record.getIsApproved() == 2) {
+            record.setRejectReason(extractRejectReason(respMap));
+        }
+        if (record.getId() == null) {
+            wechatPartnerApplymentMapper.insert(record);
+        } else {
+            wechatPartnerApplymentMapper.updateById(record);
+        }
+        log.info("[进件落库] submitResult business_code={}, applyment_id={}, state={}",
+                businessCode, record.getApplymentId(), record.getApplymentState());
+    }
+
+    private void upsertApplymentOnSubmitError(String businessCode, String errMsg, String respBody) {
+        if (StringUtils.isBlank(businessCode)) {
+            return;
+        }
+        String dbSpMchid = spMchidForDb();
+        WechatPartnerApplyment record = findByBusinessCode(businessCode);
+        if (record == null) {
+            record = new WechatPartnerApplyment();
+            record.setSpMchid(dbSpMchid);
+            record.setBusinessCode(businessCode);
+            record.setCreatedAt(new Date());
+        }
+        if (StringUtils.isBlank(record.getSpMchid()) || "UNKNOWN".equalsIgnoreCase(record.getSpMchid())) {
+            record.setSpMchid(dbSpMchid);
+        }
+        Date now = new Date();
+        record.setLastSubmitRespJson(respBody);
+        record.setRejectReason(trimTo(errMsg, 2048));
+        record.setUpdatedAt(now);
+        // 错误时不强制设置 isApproved=2,因为可能是参数错误/系统错误;保留 0
+        if (record.getIsApproved() == null) {
+            record.setIsApproved(0);
+        }
+        if (record.getId() == null) {
+            wechatPartnerApplymentMapper.insert(record);
+        } else {
+            wechatPartnerApplymentMapper.updateById(record);
+        }
+        log.warn("[进件落库] submitError business_code={}, msg={}", businessCode, errMsg);
+    }
+
+    private void upsertApplymentOnQueryResult(long applymentId, Map<String, Object> respMap, String respBody) {
+        Date now = new Date();
+        String dbSpMchid = spMchidForDb();
+        WechatPartnerApplyment record = findByApplymentId(applymentId);
+        if (record == null) {
+            record = new WechatPartnerApplyment();
+            record.setSpMchid(dbSpMchid);
+            record.setApplymentId(applymentId);
+            record.setCreatedAt(now);
+            // 兜底:尝试从响应里带回 business_code,方便后续用业务编号对齐
+            record.setBusinessCode(asString(respMap != null ? respMap.get("business_code") : null));
+        }
+        if (StringUtils.isBlank(record.getSpMchid()) || "UNKNOWN".equalsIgnoreCase(record.getSpMchid())) {
+            record.setSpMchid(dbSpMchid);
+        }
+        record.setLastQueryTime(now);
+        record.setLastQueryRespJson(respBody);
+        record.setUpdatedAt(now);
+        record.setSubMchid(asString(respMap != null ? respMap.get("sub_mchid") : null));
+        record.setSignUrl(asString(respMap != null ? respMap.get("sign_url") : null));
+        record.setApplymentState(asString(respMap != null ? respMap.get("applyment_state") : null));
+        record.setApplymentStateMsg(asString(respMap != null ? respMap.get("applyment_state_msg") : null));
+        record.setIsApproved(calcIsApproved(record.getApplymentState()));
+        if (record.getIsApproved() != null && record.getIsApproved() == 2) {
+            record.setRejectReason(extractRejectReason(respMap));
+        }
+        if (record.getId() == null) {
+            wechatPartnerApplymentMapper.insert(record);
+        } else {
+            wechatPartnerApplymentMapper.updateById(record);
+        }
+    }
+
+    private void upsertApplymentOnQueryError(long applymentId, String errMsg, String respBody) {
+        WechatPartnerApplyment record = findByApplymentId(applymentId);
+        if (record == null) {
+            // 查询失败但本地无记录时,不强行插入;避免污染
+            log.warn("[进件落库] queryError 本地无记录 applyment_id={}, msg={}", applymentId, errMsg);
+            return;
+        }
+        Date now = new Date();
+        record.setLastQueryTime(now);
+        record.setLastQueryRespJson(respBody);
+        record.setRejectReason(trimTo(errMsg, 2048));
+        record.setUpdatedAt(now);
+        wechatPartnerApplymentMapper.updateById(record);
+    }
+
+    private WechatPartnerApplyment findByBusinessCode(String businessCode) {
+        LambdaQueryWrapper<WechatPartnerApplyment> qw = new LambdaQueryWrapper<WechatPartnerApplyment>()
+                .eq(WechatPartnerApplyment::getBusinessCode, businessCode)
+                .last("limit 1");
+        // 兼容:提交初期可能未能确定 sp_mchid,因此此处不强制按 sp_mchid 过滤
+        return wechatPartnerApplymentMapper.selectOne(qw);
+    }
+
+    private WechatPartnerApplyment findByApplymentId(long applymentId) {
+        LambdaQueryWrapper<WechatPartnerApplyment> qw = new LambdaQueryWrapper<WechatPartnerApplyment>()
+                .eq(WechatPartnerApplyment::getApplymentId, applymentId)
+                .last("limit 1");
+        return wechatPartnerApplymentMapper.selectOne(qw);
+    }
+
+    /**
+     * 提交初期某些环境可能尚未配置/注入 sp_mchid。为保证落库可用:
+     * - 若已配置,则返回真实 sp_mchid;
+     * - 若未配置,则返回固定占位值 UNKNOWN(避免 NOT NULL/唯一键问题)。
+     */
+    private String spMchidForDb() {
+        if (StringUtils.isNotBlank(spMchId)) {
+            return spMchId;
+        }
+        log.warn("[进件落库] sp_mchid 未配置,使用占位 UNKNOWN(建议尽快补齐 payment.wechatPartnerPay.business.spMchId)");
+        return "UNKNOWN";
+    }
+
+    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;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static String extractRejectReason(Map<String, Object> respMap) {
+        if (respMap == null) {
+            return null;
+        }
+        Object audit = respMap.get("audit_detail");
+        if (!(audit instanceof List)) {
+            return null;
+        }
+        List<Object> list = (List<Object>) audit;
+        List<String> reasons = new ArrayList<>();
+        for (Object item : list) {
+            if (!(item instanceof Map)) {
+                continue;
+            }
+            Map<String, Object> m = (Map<String, Object>) item;
+            String field = asString(m.get("field"));
+            String name = asString(m.get("field_name"));
+            String reason = asString(m.get("reject_reason"));
+            if (StringUtils.isBlank(reason)) {
+                continue;
+            }
+            String line = StringUtils.isNotBlank(name) ? name : (StringUtils.isNotBlank(field) ? field : "");
+            if (StringUtils.isNotBlank(line)) {
+                reasons.add(line + ":" + reason);
+            } else {
+                reasons.add(reason);
+            }
+        }
+        return trimTo(String.join(";", reasons), 2048);
+    }
+
+    private static String asString(Object v) {
+        if (v == null) {
+            return null;
+        }
+        String s = String.valueOf(v);
+        s = s != null ? s.trim() : null;
+        return StringUtils.isNotBlank(s) ? s : null;
+    }
+
+    private static Long asLong(Object v) {
+        if (v == null) {
+            return null;
+        }
+        try {
+            if (v instanceof Number) {
+                return ((Number) v).longValue();
+            }
+            String s = String.valueOf(v).trim();
+            if (StringUtils.isBlank(s)) {
+                return null;
+            }
+            return Long.parseLong(s);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private static String trimTo(String s, int maxLen) {
+        if (s == null) {
+            return null;
+        }
+        if (s.length() <= maxLen) {
+            return s;
+        }
+        return s.substring(0, maxLen);
+    }
+
+    private static Map<String, Object> parseJsonToMap(String json) {
+        if (StringUtils.isBlank(json)) {
+            return new HashMap<>();
+        }
+        try {
+            return GSON.fromJson(json, MAP_TYPE);
+        } catch (Exception e) {
+            log.warn("[进件] 响应 JSON 解析为 Map 失败,返回原始字符串封装", e);
+            Map<String, Object> m = new HashMap<>();
+            m.put("raw", json);
+            return m;
+        }
+    }
+}

+ 413 - 0
alien-store/src/main/java/shop/alien/store/strategy/payment/impl/WeChatPartnerPaymentStrategyImpl.java

@@ -0,0 +1,413 @@
+package shop.alien.store.strategy.payment.impl;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+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.RefundRecord;
+import shop.alien.store.service.RefundRecordService;
+import shop.alien.store.strategy.payment.PaymentStrategy;
+import shop.alien.store.util.WXPayUtility;
+import shop.alien.util.common.UniqueRandomNumGenerator;
+import shop.alien.util.common.constant.PaymentEnum;
+import shop.alien.util.system.OSUtil;
+
+import javax.annotation.PostConstruct;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 微信支付 — 服务商模式(特约商户 / partner)
+ * <p>
+ * 使用微信支付 APIv3 服务商接口:下单、查单、退款均走 sp_mchid + sub_mchid。
+ * </p>
+ * <p>
+ * <b>【需您提供并写入配置】</b>(前缀 {@code payment.wechatPartnerPay},见各字段注释)
+ * </p>
+ * <ul>
+ *   <li>服务商商户号 {@code sp_mchid}、子商户号 {@code sub_mchid}</li>
+ *   <li>服务商 AppID {@code sp_appid};若子商户有独立移动应用 AppID 则配置 {@code sub_appid}</li>
+ *   <li>API 证书私钥、证书序列号、平台公钥、APIv3 密钥、回调 URL 等(与直连类似,但商户号为服务商)</li>
+ *   <li>各接口路径若微信有变更,以官方文档为准,可通过配置覆盖默认值</li>
+ * </ul>
+ *
+ * @see <a href="https://pay.weixin.qq.com/doc/v3/merchant/4012062547">服务商模式产品文档</a>
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class WeChatPartnerPaymentStrategyImpl implements PaymentStrategy {
+
+    private final RefundRecordService refundRecordService;
+    /** 复用直连实现中的退款单模型、HTTP 工具与 RefundRecord 构建逻辑(构建后覆盖 payType 为服务商类型) */
+    private final WeChatPaymentStrategyImpl weChatPaymentStrategy;
+
+    // ———— 【需您配置】基础域名(一般保持默认) ————
+    @Value("${payment.wechatPartnerPay.host:https://api.mch.weixin.qq.com}")
+    private String wechatPayApiHost;
+
+    /** 【需您配置】服务商 APP 下单路径,默认服务商 APP 支付 */
+    @Value("${payment.wechatPartnerPay.prePayPath:/v3/pay/partner/transactions/app}")
+    private String prePayPath;
+
+    /** 【需您配置】按商户订单号查单(路径模板中的 {out_trade_no} 与直连一致) */
+    @Value("${payment.wechatPartnerPay.searchOrderByOutTradeNoPath:/v3/pay/partner/transactions/out-trade-no/{out_trade_no}}")
+    private String searchOrderByOutTradeNoPath;
+
+    /** 【需您配置】退款申请路径(与直连相同) */
+    @Value("${payment.wechatPartnerPay.refundPath:/v3/refund/domestic/refunds}")
+    private String refundPath;
+
+    // ———— 【需您配置】服务商与子商户标识 ————
+    /** 【需您配置】服务商 AppID(sp_appid) */
+    @Value("${payment.wechatPartnerPay.business.spAppId}")
+    private String spAppId;
+
+    /** 【需您配置】服务商商户号(sp_mchid),请求 Authorization 与查单参数均使用 */
+    @Value("${payment.wechatPartnerPay.business.spMchId}")
+    private String spMchId;
+
+    /**
+     * 【需您配置】子商户/特约商户号(sub_mchid)。
+     * 若业务为「单个子商户」可写死;多子商户时需改为从订单/门店动态传入(当前策略为配置项)。
+     */
+    @Value("${payment.wechatPartnerPay.business.subMchId}")
+    private String subMchId;
+
+    /**
+     * 【可选配置】子商户 AppID(sub_appid)。
+     * 若与服务商共用或仅 sp_appid 调起支付,可留空;调起客户端支付包签名时优先用此值。
+     */
+    @Value("${payment.wechatPartnerPay.business.subAppId:}")
+    private String subAppId;
+
+    // ———— 【需您配置】证书与密钥(与直连结构一致,一般为服务商商户证书) ————
+    @Value("${payment.wechatPartnerPay.business.win.privateKeyPath}")
+    private String privateWinKeyPath;
+
+    @Value("${payment.wechatPartnerPay.business.linux.privateKeyPath}")
+    private String privateLinuxKeyPath;
+
+    @Value("${payment.wechatPartnerPay.business.win.wechatPayPublicKeyFilePath}")
+    private String wechatWinPayPublicKeyFilePath;
+
+    @Value("${payment.wechatPartnerPay.business.linux.wechatPayPublicKeyFilePath}")
+    private String wechatLinuxPayPublicKeyFilePath;
+
+    @Value("${payment.wechatPartnerPay.business.merchantSerialNumber}")
+    private String merchantSerialNumber;
+
+    @Value("${payment.wechatPartnerPay.business.wechatPayPublicKeyId}")
+    private String wechatPayPublicKeyId;
+
+    /** 【需您配置】支付结果通知 URL */
+    @Value("${payment.wechatPartnerPay.business.prePayNotifyUrl}")
+    private String prePayNotifyUrl;
+
+    /** 【需您配置】退款结果通知 URL */
+    @Value("${payment.wechatPartnerPay.business.refundNotifyUrl}")
+    private String refundNotifyUrl;
+
+    private PrivateKey privateKey;
+    private PublicKey wechatPayPublicKey;
+
+    private static final String POST_METHOD = "POST";
+    private static final String GET_METHOD = "GET";
+
+    @PostConstruct
+    public void loadPartnerCertificates() {
+        String privateKeyPath;
+        String wechatPayPublicKeyFilePath;
+        if ("windows".equals(OSUtil.getOsName())) {
+            privateKeyPath = privateWinKeyPath;
+            wechatPayPublicKeyFilePath = wechatWinPayPublicKeyFilePath;
+        } else {
+            privateKeyPath = privateLinuxKeyPath;
+            wechatPayPublicKeyFilePath = wechatLinuxPayPublicKeyFilePath;
+        }
+        log.info("[WeChatPartner] 加载服务商商户证书私钥与平台公钥,os={}, keyPath={}", OSUtil.getOsName(), privateKeyPath);
+        this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyPath);
+        this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
+    }
+
+    @Override
+    public R createPrePayOrder(String price, String subject) throws Exception {
+        log.info("[WeChatPartner] 创建预支付订单,price={}, subject={}, spMchId={}, subMchId={}", price, subject, spMchId, subMchId);
+        if (price == null || price.trim().isEmpty()) {
+            return R.fail("价格不能为空");
+        }
+        if (subject == null || subject.trim().isEmpty()) {
+            return R.fail("订单描述不能为空");
+        }
+        try {
+            BigDecimal amount = new BigDecimal(price);
+            if (amount.compareTo(BigDecimal.ZERO) <= 0) {
+                return R.fail("价格必须大于0");
+            }
+        } catch (NumberFormatException e) {
+            return R.fail("价格格式不正确");
+        }
+
+        PartnerAppPrepayRequest request = new PartnerAppPrepayRequest();
+        request.spAppid = spAppId;
+        request.spMchid = spMchId;
+        request.subMchid = subMchId;
+        if (StringUtils.isNotBlank(subAppId)) {
+            request.subAppid = subAppId;
+        }
+        request.description = subject;
+        request.outTradeNo = UniqueRandomNumGenerator.generateUniqueCode(19);
+        request.notifyUrl = prePayNotifyUrl;
+        request.amount = new WeChatPaymentStrategyImpl.CommonAmountInfo();
+        request.amount.total = new BigDecimal(price).multiply(new BigDecimal(100)).longValue();
+        request.amount.currency = "CNY";
+
+        try {
+            WeChatPaymentStrategyImpl.DirectAPIv3AppPrepayResponse response = partnerPrePayOrderRun(request);
+            log.info("[WeChatPartner] 预下单成功 prepayId={}, outTradeNo={}", response.prepayId, request.outTradeNo);
+
+            String clientAppId = StringUtils.isNotBlank(subAppId) ? subAppId : spAppId;
+            Map<String, String> result = new HashMap<>();
+            result.put("prepayId", response.prepayId);
+            result.put("appId", clientAppId);
+            result.put("spAppId", spAppId);
+            result.put("subAppId", subAppId);
+            result.put("spMchId", spMchId);
+            result.put("subMchId", subMchId);
+            result.put("orderNo", request.outTradeNo);
+
+            long timestamp = System.currentTimeMillis() / 1000;
+            String nonce = WXPayUtility.createNonce(32);
+            String prepayId = response.prepayId;
+            String message = String.format("%s\n%s\n%s\n%s\n", clientAppId, timestamp, nonce, prepayId);
+            Signature sign = Signature.getInstance("SHA256withRSA");
+            sign.initSign(privateKey);
+            sign.update(message.getBytes(StandardCharsets.UTF_8));
+            result.put("sign", Base64.getEncoder().encodeToString(sign.sign()));
+            result.put("timestamp", String.valueOf(timestamp));
+            result.put("nonce", nonce);
+            return R.data(result);
+        } catch (WXPayUtility.ApiException e) {
+            log.error("[WeChatPartner] 预下单失败 code={}, body={}", e.getErrorCode(), e.getMessage());
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @Override
+    public R handleNotify(String notifyData) throws Exception {
+        return null;
+    }
+
+    @Override
+    public R searchOrderByOutTradeNoPath(String outTradeNo) throws Exception {
+        log.info("[WeChatPartner] 查单 outTradeNo={}", outTradeNo);
+        try {
+            WeChatPaymentStrategyImpl.DirectAPIv3QueryResponse response = partnerSearchOrderRun(outTradeNo);
+            return R.data(response);
+        } catch (WXPayUtility.ApiException e) {
+            log.error("[WeChatPartner] 查单失败 code={}, msg={}", e.getErrorCode(), e.getMessage());
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @Override
+    public String handleRefund(Map<String, String> params) throws Exception {
+        PartnerRefundCreateRequest request = new PartnerRefundCreateRequest();
+        request.subMchid = subMchId;
+        request.outTradeNo = params.get("outTradeNo");
+        request.outRefundNo = UniqueRandomNumGenerator.generateUniqueCode(19);
+        request.reason = params.get("reason");
+        request.notifyUrl = refundNotifyUrl;
+        request.amount = new WeChatPaymentStrategyImpl.AmountReq();
+        request.amount.refund = new BigDecimal(params.get("refundAmount")).longValue();
+        request.amount.total = new BigDecimal(params.get("totalAmount")).longValue();
+        request.amount.currency = "CNY";
+
+        log.info("[WeChatPartner] 退款 outTradeNo={}, outRefundNo={}, refundFen={}, totalFen={}",
+                request.outTradeNo, request.outRefundNo, request.amount.refund, request.amount.total);
+
+        try {
+            WeChatPaymentStrategyImpl.Refund response = partnerRefundRun(request);
+            String status = response.status != null ? response.status.name() : "UNKNOWN";
+            if ("SUCCESS".equals(status) || "PROCESSING".equals(status)) {
+                saveRefundRecord(() -> patchPayType(weChatPaymentStrategy.buildRefundRecordFromWeChatResponse(response,
+                        request, params)));
+                return "调用成功";
+            }
+            log.error("[WeChatPartner] 退款未成功 status={}, outTradeNo={}", status, request.outTradeNo);
+            saveRefundRecord(() -> patchPayType(weChatPaymentStrategy.buildRefundRecordFromWeChatError(response, request, params, status)));
+            return "退款失败";
+        } catch (Exception e) {
+            log.error("[WeChatPartner] 退款异常 outTradeNo={}", request.outTradeNo, e);
+            saveRefundRecord(() -> patchPayType(weChatPaymentStrategy.buildRefundRecordFromWeChatException(request, params, e)));
+            return "退款处理异常:" + e.getMessage();
+        }
+    }
+
+    private RefundRecord patchPayType(RefundRecord record) {
+        if (record != null) {
+            record.setPayType(PaymentEnum.WECHAT_PAY_PARTNER.getType());
+        }
+        return record;
+    }
+
+    private void saveRefundRecord(java.util.function.Supplier<RefundRecord> supplier) {
+        try {
+            RefundRecord refundRecord = supplier.get();
+            if (refundRecord == null) {
+                return;
+            }
+            long count = refundRecordService.lambdaQuery()
+                    .eq(RefundRecord::getOutRefundNo, refundRecord.getOutRefundNo())
+                    .count();
+            if (count == 0) {
+                refundRecordService.save(refundRecord);
+                log.info("[WeChatPartner] 已写入 RefundRecord,outRefundNo={}", refundRecord.getOutRefundNo());
+            }
+        } catch (Exception e) {
+            log.error("[WeChatPartner] 保存 RefundRecord 失败", e);
+        }
+    }
+
+    @Override
+    public R searchRefundRecordByOutRefundNo(String outRefundNo) throws Exception {
+        return null;
+    }
+
+    @Override
+    public String getType() {
+        return PaymentEnum.WECHAT_PAY_PARTNER.getType();
+    }
+
+    private WeChatPaymentStrategyImpl.DirectAPIv3AppPrepayResponse partnerPrePayOrderRun(PartnerAppPrepayRequest request) {
+        String uri = prePayPath;
+        String reqBody = WXPayUtility.toJson(request);
+        log.debug("[WeChatPartner] POST {} bodyLen={}", uri, reqBody.length());
+
+        Request.Builder reqBuilder = new Request.Builder().url(wechatPayApiHost + uri);
+        reqBuilder.addHeader("Accept", "application/json");
+        reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
+        reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(spMchId, merchantSerialNumber, privateKey, POST_METHOD, uri, reqBody));
+        reqBuilder.addHeader("Content-Type", "application/json");
+        RequestBody body = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
+        reqBuilder.method(POST_METHOD, body);
+        Request httpRequest = reqBuilder.build();
+
+        OkHttpClient client = new OkHttpClient.Builder().build();
+        try (Response httpResponse = client.newCall(httpRequest).execute()) {
+            String respBody = WXPayUtility.extractBody(httpResponse);
+            if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey, httpResponse.headers(), respBody);
+                return WXPayUtility.fromJson(respBody, WeChatPaymentStrategyImpl.DirectAPIv3AppPrepayResponse.class);
+            }
+            throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
+        } catch (IOException e) {
+            throw new UncheckedIOException("WeChatPartner prepay request failed: " + uri, e);
+        }
+    }
+
+    private WeChatPaymentStrategyImpl.DirectAPIv3QueryResponse partnerSearchOrderRun(String outTradeNo) {
+        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);
+        String queryString = WXPayUtility.urlEncode(args);
+        if (!queryString.isEmpty()) {
+            uri = uri + "?" + queryString;
+        }
+
+        Request.Builder reqBuilder = new Request.Builder().url(wechatPayApiHost + uri);
+        reqBuilder.addHeader("Accept", "application/json");
+        reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
+        reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(spMchId, merchantSerialNumber, privateKey, GET_METHOD, uri, null));
+        reqBuilder.method(GET_METHOD, null);
+        Request httpRequest = reqBuilder.build();
+
+        OkHttpClient client = new OkHttpClient.Builder().build();
+        try (Response httpResponse = client.newCall(httpRequest).execute()) {
+            String respBody = WXPayUtility.extractBody(httpResponse);
+            if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey, httpResponse.headers(), respBody);
+                return WXPayUtility.fromJson(respBody, WeChatPaymentStrategyImpl.DirectAPIv3QueryResponse.class);
+            }
+            throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
+        } catch (IOException e) {
+            throw new UncheckedIOException("WeChatPartner query order failed: " + uri, e);
+        }
+    }
+
+    private WeChatPaymentStrategyImpl.Refund partnerRefundRun(PartnerRefundCreateRequest request) {
+        String uri = refundPath;
+        String reqBody = WXPayUtility.toJson(request);
+
+        Request.Builder reqBuilder = new Request.Builder().url(wechatPayApiHost + uri);
+        reqBuilder.addHeader("Accept", "application/json");
+        reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
+        reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(spMchId, merchantSerialNumber, privateKey, POST_METHOD, uri, reqBody));
+        reqBuilder.addHeader("Content-Type", "application/json");
+        RequestBody body = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
+        reqBuilder.method(POST_METHOD, body);
+        Request httpRequest = reqBuilder.build();
+
+        OkHttpClient client = new OkHttpClient.Builder().build();
+        try (Response httpResponse = client.newCall(httpRequest).execute()) {
+            String respBody = WXPayUtility.extractBody(httpResponse);
+            if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
+                WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey, httpResponse.headers(), respBody);
+                return WXPayUtility.fromJson(respBody, WeChatPaymentStrategyImpl.Refund.class);
+            }
+            throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
+        } catch (IOException e) {
+            throw new UncheckedIOException("WeChatPartner refund failed: " + uri, e);
+        }
+    }
+
+    /**
+     * 服务商 APP 下单请求体(字段名与微信 APIv3 一致)
+     */
+    public static class PartnerAppPrepayRequest {
+        @SerializedName("sp_appid")
+        public String spAppid;
+
+        @SerializedName("sp_mchid")
+        public String spMchid;
+
+        @SerializedName("sub_appid")
+        public String subAppid;
+
+        @SerializedName("sub_mchid")
+        public String subMchid;
+
+        @SerializedName("description")
+        public String description;
+
+        @SerializedName("out_trade_no")
+        public String outTradeNo;
+
+        @SerializedName("notify_url")
+        public String notifyUrl;
+
+        @SerializedName("amount")
+        public WeChatPaymentStrategyImpl.CommonAmountInfo amount;
+    }
+
+    /**
+     * 退款请求:在直连 {@link WeChatPaymentStrategyImpl.CreateRequest} 基础上增加 sub_mchid(服务商退款必填)
+     */
+    public static class PartnerRefundCreateRequest extends WeChatPaymentStrategyImpl.CreateRequest {
+        @SerializedName("sub_mchid")
+        public String subMchid;
+    }
+}