yeg
2026-09-15 207799855341eee5c7db68b4e38f4544d526a38a
fix(merchant): 保留存量进件证据并记录接口权限核验
1个文件已添加
6个文件已修改
122 ■■■■■ 已修改文件
.scratch/merchant-wechat-onboarding/QueryHuifu.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/merchant-wechat-onboarding/evidence-2026-09-15.md 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/merchant-wechat-onboarding/issues/02-existing-merchant-wechat-activation.md 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuSignatureServiceTests.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/merchant-wechat-onboarding/QueryHuifu.java
@@ -12,11 +12,26 @@
    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);
    String identity = args.length > 3 ? args[3] : ""+config.get("system-id");
    if (!Set.of("6666000226048687","6666000241335656","6666000220591054").contains(identity)) throw new IllegalArgumentException("Unexpected query identity");
    MerConfig c=new MerConfig(); c.setSysId(identity); 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);
    Map<String,Object> response;
    try { response=BasePayClient.request(request,args[0],false); }
    catch (com.huifu.bspay.sdk.opps.core.exception.BasePayException exception) {
      String body=JSON.toJSONString(request);
      String signature=com.huifu.bspay.sdk.opps.core.utils.RsaUtils.sign(com.huifu.bspay.sdk.opps.core.sign.JsonUtils.sort4JsonString(body,0),c.getRsaPrivateKey());
      String envelope=JSON.toJSONString(Map.of("sys_id",identity,"product_id",c.getProductId(),"data",request,"sign",signature));
      var http=java.net.http.HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build();
      var req=java.net.http.HttpRequest.newBuilder(java.net.URI.create("https://api.huifu.com/"+args[0].replace('.','/'))).timeout(Duration.ofSeconds(20)).header("Content-Type","application/json").POST(java.net.http.HttpRequest.BodyPublishers.ofString(envelope)).build();
      var res=http.send(req,java.net.http.HttpResponse.BodyHandlers.ofString());
      System.out.println("HTTP status="+res.statusCode());
      Map raw=JSON.parseObject(res.body(),Map.class);
      System.out.println(raw==null ? "Empty HTTP response body; outcome unconfirmed" : "Gateway diagnostic (not capability evidence): code="+raw.get("resp_code")+", message="+raw.get("resp_desc"));
      return;
    }
    Map data=response.get("data") instanceof Map ? (Map)response.get("data") : response.get("data") instanceof String s ? JSON.parseObject(s,Map.class) : response;
    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)));
  }
.scratch/merchant-wechat-onboarding/evidence-2026-09-15.md
New file
@@ -0,0 +1,47 @@
# Ticket02 实施中的只读核验
核验时间:2026-09-15(Asia/Hong_Kong)。本记录不表示 Ticket02 验收完成,03 仍阻塞。
## 原商户与申请
- 目标:6666000241335656,成都喜望科技有限公司;控台状态“正常”。
- 本地申请 ID 449,申请号 MO202609141804572521,汇付申请单 2026091429610274。
- 本地原状态 pending,原响应码 90000000;微信配置及业务开通均 not_started。
- 原银行卡关联 ID 477,尾号 7957,保持未变。
- 控台“其他配置”中“延迟入账开通标记”为关闭;不能凭原请求参数认定已开通。
## 查询权限对照
当前配置 sys_id=6666000226048687,product_id=KAZX,使用仓库既有签名配置。
| 查询目标 | 请求流水 | 结果 |
| --- | --- | --- |
| 6666000241335656 | T02Q20260915B | 20002 数据权限认证失败 |
| 6666000241506776 | T02Q20260915C | 20002 数据权限认证失败 |
| 6666000226048687 | T02Q20260915D | 00000000 成功 |
用户随后要求用目标商户查询自身。以 sys_id=6666000241335656、huifu_id=6666000241335656、既有 KAZX 产品和密钥进行只读对照,流水 T02Q20260915E,SDK 两次报告无响应。进一步读取同类只读请求的 HTTP 状态为 922,响应体为空,无法作为能力证据,也不能据此断言具体密钥错误。正式应用配置保持不变。
第二条由用户在本轮明确授权进行只读对照;用户说明其已重新开启该商户并将其作为总部下级商户。代理未改变该商户状态或配置。
此证据只证明当前身份可以查询自身、不能查询以上两个商户;不能推出“改成总部下级进件即可解决”。需确认汇付实际认可的总部关系、产品授权及接口数据权限。未更换身份、密钥或修改任何真实商户。
补充自身查询 T02Q20260915F:返回 mer_level=1、mer_config_info.create_lower_mer_flag=Y;允许创建下级商户已开启。参考商户微信 4/5/12 场景为 0.70%、最低收费 0.00、fee_charge_type=1、out_fee_flag=2。微信详情明确返回 pay_channel_id=00016904;delay_flag=Y、forced_delay_flag=N。未复制参考商户的微信子商户号或独有资质。
## 已核实官方合同
- [商户业务开通](https://paas.huifu.com/docs/api/doc/#/shgl/shywkt/api_shjj_shywkt_kyc):已进件商户可补开;申请审核与各支付业务结果分开。企业微信使用 fee_rule_id,不能盲目复制参考商户行业。子渠道不填则使用渠道默认配置,不能把控台 00016904 直接当 API 参数。
- [申请单状态查询](https://paas.huifu.com/docs/api/doc/#/shgl/shjj/api_shjj_sqdztcx):实际端点 v2/merchant/basicdata/status/query;apply_no 和 huifu_id 必填。apply_status 为 Y/P/N/F,各业务开通状态另外返回。
- [商户详情查询](https://paas.huifu.com/docs/api/doc/#/shgl/shjj/api_shjj_shxxxxcx_kyc):mer_stat=NORMAL;delay_flag=Y、forced_delay_flag=N;qry_settle_config_list 包含 settle_status=1、settle_cycle=D1、settle_pattern=P0、min_amt/remained_amt、原 token_no。起结/留存无值语义为不设置。
## 尚待完成
真实接口访问权限、微信及 AppID/实名证据、补开与局部重试、两端页面和权限验收、并发/回调测试。当前完成五个模拟集成场景:基础查询权限错误保护、基础通过但能力不全、能力查询错误保护、返回缺少字段保留证据、旧重提入口保留目标商户申请。不能将本票标完成。
全量后端测试 179 项:176 通过、1 断言失败、2 错误,三项失败均为 docs/agents/testing.md 列明的基线问题;PaymentSettlementTests、PaymentFundConfirmationTests 在本次全量中通过。另发现既有 HuifuSignatureServiceTests 会输出运行配置明文私钥,已改为使用其现成 keySummary 摘要函数。
## 检查点代码审查
Standards:审查发现的能力查询失败遗漏和缺字段覆盖有效状态已补测试并修正。
Spec:仍为未完成的实现检查点,补开、局部重试、两端入口、实名/AppID/资金完整证据、回调一致性与真实验收均未完成。旧提交关闭原商户/银行卡的风险已针对指定商户阻断;原任务范围不变,03 仍阻塞。
.scratch/merchant-wechat-onboarding/issues/02-existing-merchant-wechat-activation.md
@@ -35,4 +35,6 @@
## Comments
- 2026-09-15 实施检查点:已完成指定商户原申请只读核对和基础进度保护,五个模拟集成场景通过。当前身份查询自身成功、查询指定商户及用户另行授权的下级对照商户均为 20002;以目标商户作为 sys_id 沿用当前凭据查询自身返回 HTTP 922 空响应,尚未确认可用权限。详细记录见 [核验证据](../evidence-2026-09-15.md)。补开、两端页面、实名/AppID、回调和完整验收尚未交付,本票未完成,03 继续阻塞。
- 2026-09-15:用户已确认两票拆分与 02 → 03 依赖,按 to-tickets 技能发布,标记 ready-for-agent。发布工单不表示已实施或验收通过。
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
@@ -38,7 +38,7 @@
        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),请联系平台运营核实接口权限" : "汇付查询未成功,请稍后刷新或联系平台运营";
                String error=queryError(response);
                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);
@@ -48,8 +48,14 @@
                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");
                    if (capabilities.containsKey("delay_flag") && capabilities.containsKey("forced_delay_flag")) {
                        stages.put("delay", "Y".equals(capabilities.get("delay_flag")) && "N".equals(capabilities.get("forced_delay_flag")) ? "confirmed" : "missing");
                    } else {
                        jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?","汇付返回缺少延迟配置字段,已保留最后有效状态",applicationId,owner);
                    }
                    jdbc.update("update merchant_onboarding_progress set stages_json=? where application_id=? and sync_owner=?",JSON.toJSONString(stages),applicationId,owner);
                } else {
                    jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?",queryError(capabilities),applicationId,owner);
                }
                jdbc.update("update merchant_onboarding_progress set last_synced_at=now() where application_id=? and sync_owner=?",applicationId,owner);
            }
@@ -91,4 +97,9 @@
    }
    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 static String queryError(Map<String,Object> response) {
        return response != null && "20002".equals(text(response.get("resp_code")))
                ? "汇付查询权限不足(20002),请联系平台运营核实接口权限"
                : "汇付查询未成功,请稍后刷新或联系平台运营";
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -89,6 +89,7 @@
        List<Map<String, Object>> repeated = rows("select * from merchant_onboarding_application where merchant_id = ? and idempotency_key = ? limit 1", principal.merchantId(), key.trim());
        if (!repeated.isEmpty()) return onboarding(repeated.get(0));
        List<Map<String, Object>> active = rows("select * from merchant_onboarding_application where merchant_id = ? and store_id = ? and initial_bank_card_id is not null and status in ('pending','approved') order by id desc limit 1", principal.merchantId(), principal.storeId());
        if (!active.isEmpty() && onboardingProgressService.supports(active.get(0))) return onboarding(active.get(0));
        if (!active.isEmpty() && !retryable(active.get(0))) return onboarding(active.get(0));
        boolean enterprise = "enterprise".equals(request.subjectType());
        if (enterprise && (request.creditCode() == null || !request.creditCode().trim().matches("[0-9A-Z]{18}"))) throw new BusinessException(ErrorCode.BAD_REQUEST, "请输入正确的18位统一社会信用代码");
@@ -598,6 +599,7 @@
        if (applications.isEmpty()) return;
        Map<String, Object> application = applications.get(0);
        if (!retryable(application)) return;
        if (onboardingProgressService.supports(application)) return;
        Object bankCardId = application.get("initialBankCardId");
        jdbc.update("update merchant_bank_card set status='rejected', reject_reason=?, reviewed_at=now() where id=? and status in ('pending','approved')", "通道侧配置失败,商户重新提交进件", bankCardId);
        jdbc.update("update merchant_onboarding_application set status='rejected', reject_reason=?, reviewed_at=now() where id=?", "通道侧配置失败,商户重新提交进件", application.get("id"));
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java
@@ -21,6 +21,37 @@
    @MockitoBean HuifuOnboardingProgressGateway gateway;
    @Test
    void submittingAgainCannotReplaceDesignatedExistingMerchantAfterConfigFailure() {
        Map<String,Object> application=fixture();
        jdbc.update("update merchant_onboarding_application set wechat_config_status='failed' where application_no=?",application.get("applicationNo"));
        Map<String,Object> result=service.submitOnboarding((MerchantPrincipal)application.get("principal"),null,"T02-retry-"+System.nanoTime());
        assertThat(result.get("applicationNo")).isEqualTo(application.get("applicationNo"));
    }
    @Test
    void missingDelayFieldsKeepLastConfirmedEvidenceAndReportIncompleteQuery() {
        MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"),Map.of("resp_code","00000000"));
        service.refreshOnboarding(principal,false);
        Map<String,Object> result=service.refreshOnboarding(principal,false);
        assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","delay").containsEntry("status","confirmed"));
        assertThat(result.get("syncError")).isEqualTo("汇付返回缺少延迟配置字段,已保留最后有效状态");
    }
    @Test
    void capabilityQueryErrorKeepsConfirmedDelayAndReportsSyncError() {
        Map<String,Object> application=fixture();
        MerchantPrincipal principal=(MerchantPrincipal)application.get("principal");
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"),Map.of("resp_code","20002","resp_desc","数据权限认证失败"));
        service.refreshOnboarding(principal,false);
        Map<String,Object> result=service.refreshOnboarding(principal,false);
        assertThat(result.get("syncError")).isEqualTo("汇付查询权限不足(20002),请联系平台运营核实接口权限");
        assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","delay").containsEntry("status","confirmed"));
    }
    @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"));
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuSignatureServiceTests.java
@@ -28,8 +28,8 @@
        System.out.println("Huifu merchantId=" + runtimeProperties.getMerchantId());
        System.out.println("Huifu miniProgramAppId=" + runtimeProperties.getMiniProgramAppId());
        System.out.println("Huifu notifyUrl=" + runtimeProperties.getNotifyUrl());
        System.out.println("Huifu privateKey=" + runtimeProperties.getPrivateKey());
        System.out.println("Huifu platformPublicKey=" + runtimeProperties.getPlatformPublicKey());
        System.out.println("Huifu privateKey=" + keySummary(runtimeProperties.getPrivateKey()));
        System.out.println("Huifu platformPublicKey=" + keySummary(runtimeProperties.getPlatformPublicKey()));
    }
    @Test