yeg
2026-09-15 dcc23e37a74502d027b7e1efeca57378c94273b1
feat(merchant): 增加存量商户微信开通进度核验
6个文件已添加
1个文件已修改
295 ■■■■■ 已修改文件
.scratch/merchant-wechat-onboarding/InspectExisting.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/merchant-wechat-onboarding/QueryHuifu.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingProgressGateway.java 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V144__existing_merchant_onboarding_progress.sql 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/merchant-wechat-onboarding/InspectExisting.java
New file
@@ -0,0 +1,10 @@
import java.sql.*;
public class InspectExisting {
  public static void main(String[] args) throws Exception {
    try (Connection c = DriverManager.getConnection("jdbc:mysql://127.0.0.1:8134/talent_card?useSSL=false&allowPublicKeyRetrieval=true", "root", "123456")) {
      c.setReadOnly(true);
      String sql = "select a.id,a.merchant_id,a.store_id,a.application_no,a.status,a.subject_type,a.provider_huifu_id,a.provider_apply_no,a.provider_response_code,a.provider_response_message,a.wechat_config_status,a.wechat_config_request_no,a.wechat_config_response_code,a.wechat_config_response_message,a.business_open_status,a.business_open_request_no,a.business_open_apply_no,a.business_open_response_code,a.business_open_response_message,a.initial_bank_card_id,a.created_at,b.status as card_status,b.card_tail,m.merchant_type from merchant_onboarding_application a left join merchant_bank_card b on b.id=a.initial_bank_card_id left join merchant m on m.id=a.merchant_id where a.provider_huifu_id='6666000241335656'";
      try (ResultSet r=c.createStatement().executeQuery(sql)) { while(r.next()) for(int i=1;i<=r.getMetaData().getColumnCount();i++) System.out.println(r.getMetaData().getColumnLabel(i)+"="+r.getString(i)); }
    }
  }
}
.scratch/merchant-wechat-onboarding/QueryHuifu.java
New file
@@ -0,0 +1,24 @@
import java.nio.file.*;
import java.time.*;
import java.time.format.*;
import java.util.*;
import com.alibaba.fastjson.JSON;
import com.huifu.bspay.sdk.opps.client.BasePayClient;
import com.huifu.bspay.sdk.opps.core.BasePay;
import com.huifu.bspay.sdk.opps.core.config.MerConfig;
import org.yaml.snakeyaml.Yaml;
public class QueryHuifu {
  public static void main(String[] args) throws Exception {
    Map root = new Yaml().load(Files.readString(Path.of("talent-card-server/src/main/resources/application.yml")));
    Map config = find(root);
    BasePay.debug=false; BasePay.prodMode=BasePay.MODE_PROD;
    MerConfig c=new MerConfig(); c.setSysId(""+config.get("system-id")); c.setProductId(""+config.get("product-id")); c.setRsaPrivateKey(""+config.get("private-key")); c.setRsaPublicKey(""+config.get("platform-public-key")); BasePay.initWithMerConfig(c);
    if(!Set.of("v2.merchant.basicdata.query","v2.user.apply.query","v2.merchant.busi.config.query","v2.merchant.busi.realname.query").contains(args[0])) throw new IllegalArgumentException("Read only query required");
    Map<String,Object> request=JSON.parseObject(args[1],Map.class);
    Map<String,Object> response=BasePayClient.request(request,args[0],false);
    Map data=response.get("data") instanceof Map ? (Map)response.get("data") : JSON.parseObject(""+response.get("data"),Map.class);
    if(data==null) { System.out.println("Empty data; code="+response.get("resp_code")+"; message="+response.get("resp_desc")); return; }
    for(String key: args[2].split(",")) if(data.containsKey(key)) System.out.println(key+"="+JSON.toJSONString(data.get(key)));
  }
  static Map find(Map root) { if(root.containsKey("system-id") && root.containsKey("private-key")) return root; for(Object v:root.values()) if(v instanceof Map m) {Map r=find(m); if(r!=null)return r;} return null; }
}
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
New file
@@ -0,0 +1,94 @@
package com.talentcard.server.business.merchant;
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/** Six independent evidence-backed stages; provider calls never hold application row locks. */
@Service
public class MerchantOnboardingProgressService {
    public static final String RECOVERY_HUIFU_ID = "6666000241335656";
    private static final Map<String,String> LABELS = new LinkedHashMap<>();
    static {
        LABELS.put("basic", "基础审核"); LABELS.put("wechat", "微信支付");
        LABELS.put("appId", "人才端绑定"); LABELS.put("realname", "实名与授权");
        LABELS.put("delay", "延迟入账"); LABELS.put("settlement", "自动结算");
    }
    private final JdbcTemplate jdbc;
    private final HuifuOnboardingProgressGateway gateway;
    public MerchantOnboardingProgressService(JdbcTemplate jdbc, HuifuOnboardingProgressGateway gateway) { this.jdbc=jdbc; this.gateway=gateway; }
    public boolean supports(Map<String,Object> application) { return RECOVERY_HUIFU_ID.equals(text(application.get("providerHuifuId"))); }
    public Map<String,Object> refresh(Long applicationId, boolean retry) {
        Map<String,Object> application=application(applicationId);
        if (!supports(application)) throw new BusinessException(ErrorCode.CONFLICT,"This recovery is limited to the designated existing merchant");
        jdbc.update("insert ignore into merchant_onboarding_progress(application_id,stages_json) values (?, '{}')", applicationId);
        String owner=UUID.randomUUID().toString();
        if(jdbc.update("update merchant_onboarding_progress set sync_owner=?,sync_until=date_add(now(),interval 5 minute) where application_id=? and (sync_owner is null or sync_until<now())", owner, applicationId)==0) return view(application);
        try {
            Map<String,Object> response=gateway.queryApplication(text(application.get("providerHuifuId")), text(application.get("providerApplyNo")));
            if (!"00000000".equals(text(response.get("resp_code")))) {
                String error="20002".equals(text(response.get("resp_code"))) ? "汇付查询权限不足(20002),请联系平台运营核实接口权限" : "汇付查询未成功,请稍后刷新或联系平台运营";
                jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?",error,applicationId,owner);
            } else {
                Map<String,Object> stages = savedStages(applicationId);
                String audit=text(response.get("apply_status"));
                if ("Y".equals(audit) && "S".equals(text(response.get("reg_status")))) stages.put("basic", "confirmed");
                else if (!"confirmed".equals(stages.get("basic"))) stages.put("basic",List.of("N","F").contains(audit) ? "failed" : "pending");
                jdbc.update("update merchant_onboarding_progress set stages_json=?,last_synced_at=now(),sync_error='' where application_id=? and sync_owner=?",JSON.toJSONString(stages),applicationId,owner);
                Map<String,Object> capabilities=gateway.queryCapabilities(text(application.get("providerHuifuId")));
                if (capabilities != null && "00000000".equals(text(capabilities.get("resp_code")))) {
                    stages.put("delay", "Y".equals(capabilities.get("delay_flag")) && "N".equals(capabilities.get("forced_delay_flag")) ? "confirmed" : "unknown");
                    jdbc.update("update merchant_onboarding_progress set stages_json=? where application_id=? and sync_owner=?",JSON.toJSONString(stages),applicationId,owner);
                }
                jdbc.update("update merchant_onboarding_progress set last_synced_at=now() where application_id=? and sync_owner=?",applicationId,owner);
            }
        } catch (RuntimeException exception) {
            jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?","查询结果暂未确认,已保留最后有效进度,请稍后刷新",applicationId,owner);
        } finally {
            jdbc.update("update merchant_onboarding_progress set sync_owner=null,sync_until=null where application_id=? and sync_owner=?",applicationId,owner);
        }
        return view(application(applicationId));
    }
    public Map<String,Object> view(Map<String,Object> application) {
        Map<String,Object> result=new LinkedHashMap<>();
        result.put("id",text(application.get("id"))); result.put("applicationNo",text(application.get("applicationNo")));
        result.put("status","pending"); result.put("subjectType",text(application.get("subjectType")));
        result.put("submittedAt",text(application.get("createdAt"))); result.put("retryable",false);
        result.put("refreshable",true); result.put("lastSyncedAt",""); result.put("syncError","");
        List<Map<String,Object>> saved=rows("select * from merchant_onboarding_progress where application_id=?",application.get("id"));
        Map<String,Object> stages=new LinkedHashMap<>();
        if (!saved.isEmpty()) {
            stages=JSON.parseObject(text(saved.get(0).get("stagesJson")), Map.class);
            result.put("lastSyncedAt",text(saved.get(0).get("lastSyncedAt")));
            result.put("syncError",text(saved.get(0).get("syncError")));
        }
        List<Map<String,Object>> progress=new ArrayList<>();
        for (var entry:LABELS.entrySet()) progress.add(Map.of("key",entry.getKey(),"label",entry.getValue(),"status",text(stages.getOrDefault(entry.getKey(),"unknown")),"reason","尚未取得通道生效证据"));
        result.put("stages",progress); result.put("reason","请刷新进度,核实原申请与各项能力");
        return result;
    }
    private Map<String,Object> application(Long id) {
        List<Map<String,Object>> records=rows("select * from merchant_onboarding_application where id=? and initial_bank_card_id is not null",id);
        if(records.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND,"Onboarding application not found");
        return records.get(0);
    }
    @SuppressWarnings("unchecked")
    private Map<String,Object> savedStages(Long id) {
        return JSON.parseObject(jdbc.queryForObject("select stages_json from merchant_onboarding_progress where application_id=?",String.class,id),Map.class);
    }
    private List<Map<String,Object>> rows(String sql,Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql,args)); }
    private static String text(Object value) { return value==null ? "" : String.valueOf(value); }
}
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -53,6 +53,15 @@
    private final HuifuPaymentProperties huifuPaymentProperties;
    private final HuifuPictureUploadGateway pictureUploadGateway;
    private final MerchantPaymentReadinessService paymentReadinessService;
    @org.springframework.beans.factory.annotation.Autowired
    private MerchantOnboardingProgressService onboardingProgressService;
    public Map<String, Object> refreshOnboarding(MerchantPrincipal principal, boolean retry) {
        store(principal);
        List<Map<String,Object>> records=rows("select * from merchant_onboarding_application where merchant_id=? and store_id=? and initial_bank_card_id is not null order by id desc limit 1",principal.merchantId(),principal.storeId());
        if(records.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND,"Onboarding application not found");
        return onboardingProgressService.refresh(((Number)records.get(0).get("id")).longValue(),retry);
    }
    public MerchantSettlementService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, HuifuIndividualOnboardingGateway individualOnboardingGateway, HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway, HuifuMerchantWechatConfigGateway merchantWechatConfigGateway, HuifuMerchantBusinessOpenGateway merchantBusinessOpenGateway, HuifuUserBusinessOpenGateway userBusinessOpenGateway, HuifuPaymentProperties huifuPaymentProperties, HuifuPictureUploadGateway pictureUploadGateway, MerchantPaymentReadinessService paymentReadinessService) {
        this.jdbc = jdbc;
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingProgressGateway.java
New file
@@ -0,0 +1,71 @@
package com.talentcard.server.business.payment.huifu;
import com.alibaba.fastjson.JSON;
import com.huifu.bspay.sdk.opps.client.BasePayClient;
import com.huifu.bspay.sdk.opps.core.BasePay;
import com.huifu.bspay.sdk.opps.core.config.MerConfig;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import org.springframework.stereotype.Service;
/** External boundary for manual recovery of an existing Huifu merchant. */
@Service
public class HuifuOnboardingProgressGateway {
    private final HuifuPaymentProperties properties;
    public HuifuOnboardingProgressGateway(HuifuPaymentProperties properties) { this.properties = properties; }
    public Map<String,Object> queryApplication(String huifuId, String applyNo) {
        Map<String,Object> request = queryRequest(huifuId);
        request.put("apply_no", applyNo);
        return send("v2.merchant.basicdata.status.query", request);
    }
    public Map<String,Object> queryCapabilities(String huifuId) {
        return send("v2.merchant.basicdata.query", queryRequest(huifuId));
    }
    public Map<String,Object> openWechat(String huifuId, String requestNo, Map<String,Object> profile) {
        throw new BusinessException(ErrorCode.CONFLICT, "Wechat business profile is not confirmed");
    }
    protected Map<String,Object> send(String function, Map<String,Object> request) {
        if (!properties.isEnabled()) throw new BusinessException(ErrorCode.CONFLICT, "Huifu payment is disabled");
        try {
            synchronized (BasePay.class) {
                BasePay.debug = false;
                BasePay.prodMode = "prod".equalsIgnoreCase(properties.getEnvironment()) ? BasePay.MODE_PROD : BasePay.MODE_TEST;
                MerConfig config = new MerConfig();
                config.setSysId(properties.getSystemId());
                config.setProductId(properties.getProductId());
                config.setRsaPrivateKey(properties.getPrivateKey());
                config.setRsaPublicKey(properties.getPlatformPublicKey());
                BasePay.initWithMerConfig(config);
                return data(BasePayClient.request(request, function, false));
            }
        } catch (BusinessException exception) { throw exception; }
        catch (Exception exception) { throw new HuifuPaymentGatewayException("Huifu request outcome is unknown", true, exception); }
    }
    static Map<String,Object> queryRequest(String huifuId) {
        Map<String,Object> request = new LinkedHashMap<>();
        request.put("req_seq_id", UUID.randomUUID().toString().replace("-", ""));
        request.put("req_date", LocalDate.now(ZoneId.of("Asia/Shanghai")).format(DateTimeFormatter.BASIC_ISO_DATE));
        request.put("huifu_id", huifuId);
        return request;
    }
    @SuppressWarnings("unchecked")
    public static Map<String,Object> data(Map<String,Object> response) {
        if (response == null) throw new IllegalArgumentException("Empty Huifu response");
        Object nested = response.get("data");
        if (nested instanceof Map<?,?> value) return (Map<String,Object>) value;
        if (nested instanceof String value && !value.isBlank()) return JSON.parseObject(value, Map.class);
        return response;
    }
}
talent-card-server/src/main/resources/db/migration/V144__existing_merchant_onboarding_progress.sql
New file
@@ -0,0 +1,24 @@
CREATE TABLE merchant_onboarding_progress (
    application_id BIGINT NOT NULL PRIMARY KEY,
    stages_json TEXT NOT NULL,
    last_synced_at DATETIME NULL,
    sync_error VARCHAR(512) NOT NULL DEFAULT '',
    sync_owner VARCHAR(36) NULL,
    sync_until DATETIME NULL,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE merchant_onboarding_attempt (
    id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
    application_id BIGINT NOT NULL,
    step VARCHAR(24) NOT NULL,
    request_no VARCHAR(32) NOT NULL,
    request_date CHAR(8) NOT NULL,
    apply_no VARCHAR(32) NULL,
    status VARCHAR(24) NOT NULL,
    response_code VARCHAR(32) NULL,
    response_message VARCHAR(512) NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_onboarding_attempt_request (request_no),
    KEY idx_onboarding_attempt_step (application_id, step, id)
);
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java
New file
@@ -0,0 +1,63 @@
package com.talentcard.server.business.merchant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.Map;
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@SpringBootTest
@ActiveProfiles("test")
class MerchantExistingOnboardingTests {
    @Autowired JdbcTemplate jdbc;
    @Autowired MerchantSettlementService service;
    @MockitoBean HuifuOnboardingProgressGateway gateway;
    @Test
    void approvedBasicApplicationIsNotCompleteWithoutWechatAndFundsEvidence() {
        Map<String,Object> application=fixture();
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S","bind_card_status","S"));
        when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL"));
        Map<String,Object> result=service.refreshOnboarding((MerchantPrincipal)application.get("principal"),false);
        assertThat(result).containsEntry("status","pending");
        assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","basic").containsEntry("status","confirmed"));
        assertThat(result.get("lastSyncedAt")).isNotEqualTo("");
    }
    private Map<String,Object> fixture() {
        String key="T02-"+System.nanoTime();
        jdbc.update("insert into merchant(merchant_name,status,deleted) values (?,1,0)",key);
        Long merchant=jdbc.queryForObject("select id from merchant where merchant_name=?",Long.class,key);
        jdbc.update("insert into merchant_store(merchant_id,store_name,status,deleted) values (?,?,1,0)",merchant,key);
        Long store=jdbc.queryForObject("select id from merchant_store where merchant_id=?",Long.class,merchant);
        jdbc.update("insert into merchant_bank_card(merchant_id,store_id,bank_name,branch_name,city_code,card_tail,card_masked,card_hash,status,idempotency_key) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)",merchant,store,key,key);
        Long card=jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?",Long.class,key);
        jdbc.update("insert into merchant_onboarding_application(merchant_id,store_id,application_no,subject_type,subject_name,contact_name,contact_mobile,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id) values (?, ?, ?, 'enterprise','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?)",merchant,store,key,key,card);
        return Map.of("principal",new MerchantPrincipal(1L,merchant,store,"owner"),"applicationNo",key);
    }
    @Test
    void queryPermissionErrorPreservesOriginalApplicationAndCard() {
        String key = "T02-" + System.nanoTime();
        jdbc.update("insert into merchant(merchant_name,status,deleted) values (?,1,0)", key);
        Long merchant = jdbc.queryForObject("select id from merchant where merchant_name=?", Long.class,key);
        jdbc.update("insert into merchant_store(merchant_id,store_name,status,deleted) values (?,?,1,0)",merchant,key);
        Long store = jdbc.queryForObject("select id from merchant_store where merchant_id=?",Long.class,merchant);
        jdbc.update("insert into merchant_bank_card(merchant_id,store_id,bank_name,branch_name,city_code,card_tail,card_masked,card_hash,status,idempotency_key) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)",merchant,store,key,key);
        Long card = jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?",Long.class,key);
        jdbc.update("insert into merchant_onboarding_application(merchant_id,store_id,application_no,subject_type,subject_name,contact_name,contact_mobile,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id) values (?, ?, ?, 'enterprise','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?)",merchant,store,key,key,card);
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","20002","resp_desc","数据权限认证失败"));
        Map<String,Object> result=service.refreshOnboarding(new MerchantPrincipal(1L,merchant,store,"owner"),false);
        assertThat(result).containsEntry("applicationNo",key).containsEntry("status","pending").containsEntry("syncError","汇付查询权限不足(20002),请联系平台运营核实接口权限");
        assertThat(service.bankCards(new MerchantPrincipal(1L,merchant,store,"owner"))).isNotNull();
        verify(gateway,never()).openWechat(any(),any(),any());
        assertThat(jdbc.queryForObject("select initial_bank_card_id from merchant_onboarding_application where application_no=?",Long.class,key)).isEqualTo(card);
    }
}