|
@@ -0,0 +1,1103 @@
|
|
|
|
|
+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 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;
|
|
|
|
|
+
|
|
|
|
|
+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;
|
|
|
|
|
+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;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private StoreInfoMapper storeInfoMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @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>
|
|
|
|
|
+ * <li>{@code settlement_info.settlement_id} 微信要求 string;若前端传为 JSON 数字(如 {@code 716}、{@code 716.0}),转为字符串避免 {@code PARAM_ERROR}。</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);
|
|
|
|
|
+ coerceSettlementInfoSettlementIdToString(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;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 微信「入驻结算规则 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")
|
|
|
|
|
+ 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)} 规范结构)。
|
|
|
|
|
+ * 若传入 {@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, 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(jsonWithBusinessCode);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("[进件] 敏感字段加密失败", e);
|
|
|
|
|
+ return R.fail("进件请求敏感信息加密失败: " + e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 落库:提交前先写入/更新一条记录(以 sp_mchid + business_code 唯一)
|
|
|
|
|
+ String businessCode = extractBusinessCodeSafe(jsonWithBusinessCode);
|
|
|
|
|
+ upsertApplymentOnSubmitStart(businessCode, idempotencyKey, payloadJson, storeId);
|
|
|
|
|
+
|
|
|
|
|
+ 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}", 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());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 按门店查询最近一次特约商户进件状态(仅读本地库,不调微信接口)。
|
|
|
|
|
+ * <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 + "_" + System.currentTimeMillis();
|
|
|
|
|
+ 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) {
|
|
|
|
|
+ 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, Integer storeId) {
|
|
|
|
|
+ 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);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (storeId != null) {
|
|
|
|
|
+ record.setStoreId(storeId);
|
|
|
|
|
+ }
|
|
|
|
|
+ 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={}, storeId={}, id={}", record.getSpMchid(), businessCode, record.getStoreId(), 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());
|
|
|
|
|
+ syncStoreInfoWechatSubMchidIfApproved(record);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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);
|
|
|
|
|
+ }
|
|
|
|
|
+ syncStoreInfoWechatSubMchidIfApproved(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);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 进件状态为「已完成」且微信返回 {@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)
|
|
|
|
|
+ .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;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|