feat(merchant): 增加存量商户微信开通进度核验
| New file |
| | |
| | | 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)); } |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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; } |
| | | } |
| New file |
| | |
| | | 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); } |
| | | } |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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) |
| | | ); |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |