yeg
2026-09-17 0d14c6214ef7afc4be02723326229a48b3b7d64f
refactor(merchant): 自查接口补冷却剩余秒数,refreshed 改为单点返回

两轴 code review 的后端修复,三处都是口径问题而不是功能缺失。

- merchantView 新增 retryAfterSeconds:冷却剩余秒数由服务端算好,客户端据此做倒计时,
不必自己解析数据库时间戳。它同时替掉了原先另查一次窗口是否放开的 select,
verifyAvailable 改由 retryAfterSeconds == 0 推出,冷却的读侧只剩 capabilityCooldownSeconds、
写侧只剩 acquireVerifyWindow,判据不再写两遍。
- merchantVerify 删掉通道校验关掉/汇付未启用那条早退:它不返回 refreshed,而 D9 要求
手动核实一定带这个字段,客户端把缺失当 false,会在根本没查通道时弹刚核实过。
现在 refreshed 由唯一返回点统一带上。
- stages_json 的证据键 merchantStatus 改名 merchantStat:前者与阶段状态同形,容易被读成
另一个阶段;后者与申请行的通道快照列同名同义。老记录没有这个键,回落到申请行的
merchant_stat(新增测试覆盖)。附带把 incompleteReason 里的全限定 Collectors.joining
换成 String.join。

测试:新增 1 项(回退路径),相关两类 13 项全绿;全量 319 项仅 docs/agents/testing.md
记录的 3 项基线失败。
4个文件已修改
92 ■■■■■ 已修改文件
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantOnboardingCompletionChecksTests.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessMerchantViewTests.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
@@ -68,8 +68,9 @@
                    if (capabilities.containsKey("mer_stat")) {
                        stages.put("merchantNormal", "NORMAL".equals(text(capabilities.get("mer_stat"))));
                        // 原始值一起存下来,只是为了在完成判定里说清「状态异常(CLOSED)」这种话。
                        // 它不属于六项阶段,不会进 stages 列表。
                        stages.put("merchantStatus", text(capabilities.get("mer_stat")));
                        // 与 realnameQr 一样,它是对应布尔项的**证据**,不是第七个阶段:stages 列表由
                        // LABELS 生成,非 LABELS 的键不会进列表,完成判定也只认 LABELS 的六个键。
                        stages.put("merchantStat", text(capabilities.get("mer_stat")));
                    }
                    if ("S".equals(text(response.get("bind_card_status"))) && capabilities.containsKey("qry_cash_card_info_list") && capabilities.containsKey("qry_settle_config_list")) {
                        stages.put("bank", confirmOriginalCard(application,capabilities,owner));
@@ -275,7 +276,8 @@
     * 其余六项必须是字符串 {@code "confirmed"},加进去会让进件永远无法 approved。
     */
    private static List<Map<String,Object>> completionChecks(Map<String,Object> stages, Map<String,Object> application) {
        String merchantStatus = text(stages.get("merchantStatus"));
        // 证据优先取刷新那一刻存下来的通道原值;老记录里没有这个键时回落到申请行上的快照列。
        String merchantStatus = text(stages.get("merchantStat"));
        if (merchantStatus.isBlank()) merchantStatus = text(application.get("merchantStat"));
        List<Map<String,Object>> checks=new ArrayList<>();
        checks.add(completionCheck("bank","结算银行卡核实",stages.get("bank"),
@@ -303,10 +305,10 @@
    private static String incompleteReason(List<Map<String,Object>> progress, List<Map<String,Object>> checks) {
        boolean stagesAllConfirmed = progress.stream().allMatch(stage -> "confirmed".equals(stage.get("status")));
        if (!stagesAllConfirmed) return "请刷新进度核实各项能力,并按待办理项补齐缺失能力";
        String failed = checks.stream().filter(check -> !"confirmed".equals(check.get("status")))
                .map(check -> String.valueOf(check.get("label"))).collect(java.util.stream.Collectors.joining("、"));
        return failed.isBlank() ? "请刷新进度核实各项能力,并按待办理项补齐缺失能力"
                : "进件阶段已全部确认,但" + failed + "未通过核实,见下方核实结果";
        List<String> failed = checks.stream().filter(check -> !"confirmed".equals(check.get("status")))
                .map(check -> String.valueOf(check.get("label"))).toList();
        return failed.isEmpty() ? "请刷新进度核实各项能力,并按待办理项补齐缺失能力"
                : "进件阶段已全部确认,但" + String.join("、", failed) + "未通过核实,见下方核实结果";
    }
    /** Called only after the existing HTTP callback boundary has verified Huifu's signature. */
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java
@@ -99,7 +99,7 @@
    }
    /**
     * 商户端自查:与下单闸门同源、同文案的判定结果,外加最近核实时间。
     * 商户端自查:与下单闸门同源、同文案的判定结果,外加最近核实时间与冷却状态。
     *
     * <p>只读,不查通道:快照的新鲜度由既有的定时核实与 {@link #merchantVerify} 负责。只返回派生
     * 结果——通道原始值({@code merchant_stat} 等)不下发给商户端。
@@ -107,12 +107,15 @@
    public Map<String, Object> merchantView(Long merchantId, Long storeId) {
        Application application = approvedApplication(merchantId, storeId);
        Readiness readiness = readiness(merchantId, storeId);
        // 冷却剩余时间由服务端算好:客户端不必自己解析数据库时间戳字符串,也能在冷却结束时恢复按钮。
        long retryAfterSeconds = application == null ? 0L : capabilityCooldownSeconds(application.id());
        Map<String, Object> view = new LinkedHashMap<>();
        view.put("ready", readiness.ready());
        view.put("reason", readiness.reason());
        view.put("verifiedAt", application == null ? "" : application.capabilityVerifiedAt());
        view.put("nextVerifyAt", application == null ? "" : application.capabilitySyncUntil());
        view.put("verifyAvailable", application != null && verifyWindowOpen(application.id()));
        view.put("verifyAvailable", application != null && retryAfterSeconds == 0L);
        view.put("retryAfterSeconds", retryAfterSeconds);
        return view;
    }
@@ -125,29 +128,40 @@
     *
     * <p>刻意不加 {@code @Transactional}:冷却窗口必须在通道查询之前落库,否则查询进行中的并发点击
     * 会一起穿透;而通道查询也绝不能夹在一个持有该行锁的事务里。
     *
     * <p>{@code refreshed} 在每条返回路径上都有:它回答的是「这次到底查没查通道」,客户端据此说话,
     * 通道校验关掉时不能凭空宣称刚核实过。
     */
    public Map<String, Object> merchantVerify(Long merchantId, Long storeId) {
        Application application = approvedApplication(merchantId, storeId);
        if (application == null) throw new BusinessException(ErrorCode.CONFLICT, "商户进件尚未审核通过,暂时无法收款");
        if (application.huifuId().isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "商户进件缺少斗拱商户号,暂时无法收款");
        if (!providerVerificationRequired() || !huifuPaymentProperties.isEnabled()) return merchantView(merchantId, storeId);
        boolean refreshed = jdbc.update("""
                update merchant_onboarding_application
                set capability_sync_until = date_add(now(), interval ? minute)
                where id = ? and (capability_sync_until is null or capability_sync_until < now())
                """, MERCHANT_VERIFY_COOLDOWN_MINUTES, application.id()) > 0;
        boolean refreshed = providerVerificationRequired() && huifuPaymentProperties.isEnabled() && acquireVerifyWindow(application.id());
        if (refreshed) queryProviderOutsideTransaction(application);
        Map<String, Object> view = merchantView(merchantId, storeId);
        view.put("refreshed", refreshed);
        return view;
    }
    /** 冷却窗口是否已放开:商户端据此决定「立即核实」能不能点。 */
    private boolean verifyWindowOpen(long applicationId) {
        return !rows("""
                select id from merchant_onboarding_application
    /** 抢冷却窗口:抢到才去查通道。写侧的判据只有这一处,读侧见 {@link #capabilityCooldownSeconds}。 */
    private boolean acquireVerifyWindow(long applicationId) {
        return jdbc.update("""
                update merchant_onboarding_application
                set capability_sync_until = date_add(now(), interval ? minute)
                where id = ? and (capability_sync_until is null or capability_sync_until < now())
                """, applicationId).isEmpty();
                """, MERCHANT_VERIFY_COOLDOWN_MINUTES, applicationId) > 0;
    }
    /**
     * 冷却剩余秒数,0 表示现在就能核实。与写侧读的是同一列;{@code coalesce} 兜住「从没设过冷却」
     * 与「冷却已过期」两种情况——{@code timestampdiff} 遇上 NULL 会连 {@code greatest} 一起变 NULL。
     */
    private long capabilityCooldownSeconds(long applicationId) {
        List<Map<String, Object>> rows = rows("""
                select coalesce(greatest(0, timestampdiff(second, now(), capability_sync_until)), 0) as remainingSeconds
                from merchant_onboarding_application where id = ?
                """, applicationId);
        return rows.isEmpty() ? 0L : number(rows.get(0).get("remainingSeconds"));
    }
    /**
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantOnboardingCompletionChecksTests.java
@@ -63,7 +63,7 @@
     */
    @Test
    void explainsTheAbnormalChannelMerchantWhileEveryStageStaysConfirmed() {
        stages(SIX_CONFIRMED.replace("}", ",\"bank\":true,\"merchantNormal\":false,\"merchantStatus\":\"CLOSED\"}"));
        stages(SIX_CONFIRMED.replace("}", ",\"bank\":true,\"merchantNormal\":false,\"merchantStat\":\"CLOSED\"}"));
        Map<String, Object> view = progressService.view(applicationId);
@@ -76,7 +76,7 @@
    /** 结算银行卡没核实同样会让进件停在 pending,之前也看不见。 */
    @Test
    void explainsTheUnverifiedSettlementCard() {
        stages(SIX_CONFIRMED.replace("}", ",\"bank\":false,\"merchantNormal\":true,\"merchantStatus\":\"NORMAL\"}"));
        stages(SIX_CONFIRMED.replace("}", ",\"bank\":false,\"merchantNormal\":true,\"merchantStat\":\"NORMAL\"}"));
        Map<String, Object> view = progressService.view(applicationId);
@@ -89,7 +89,7 @@
    /** 两项都核实通过、六项阶段也齐了,才是完成态。 */
    @Test
    void completesOnlyAfterBothChecksAndEveryStageAreConfirmed() {
        stages(SIX_CONFIRMED.replace("}", ",\"bank\":true,\"merchantNormal\":true,\"merchantStatus\":\"NORMAL\"}"));
        stages(SIX_CONFIRMED.replace("}", ",\"bank\":true,\"merchantNormal\":true,\"merchantStat\":\"NORMAL\"}"));
        Map<String, Object> view = progressService.view(applicationId);
@@ -112,11 +112,25 @@
        assertThat(check(view, "merchantNormal")).containsEntry("status", "unknown");
    }
    /**
     * 本次改动之前落下的进度记录里没有 {@code merchantStat} 这个证据键,
     * 此时回落到申请行上的通道快照列,不能因此只说一句「状态异常」而给不出值。
     */
    @Test
    void fallsBackToTheApplicationSnapshotForTheChannelMerchantValue() {
        stages(SIX_CONFIRMED.replace("}", ",\"bank\":true,\"merchantNormal\":false}"));
        jdbc.update("update merchant_onboarding_application set merchant_stat='FREEZEWITHDRAWAL' where id=?", applicationId);
        Map<String, Object> view = progressService.view(applicationId);
        assertThat(String.valueOf(check(view, "merchantNormal").get("reason"))).contains("FREEZEWITHDRAWAL");
    }
    /** 两项完成条件不能混进阶段列表:那是完成判定的键集,混进去进件永远无法 approved。 */
    @SuppressWarnings("unchecked")
    @Test
    void keepsTheStageListAtSixEntries() {
        stages(SIX_CONFIRMED.replace("}", ",\"bank\":true,\"merchantNormal\":true,\"merchantStatus\":\"NORMAL\"}"));
        stages(SIX_CONFIRMED.replace("}", ",\"bank\":true,\"merchantNormal\":true,\"merchantStat\":\"NORMAL\"}"));
        List<Map<String, Object>> stages = (List<Map<String, Object>>) progressService.view(applicationId).get("stages");
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessMerchantViewTests.java
@@ -83,7 +83,7 @@
        int before = mockingDetails(capabilityGateway).getInvocations().size();
        Map<String, Object> view = readinessService.merchantView(merchantId, storeId);
        assertThat(view).containsEntry("ready", true).containsEntry("verifyAvailable", true);
        assertThat(view).containsEntry("ready", true).containsEntry("verifyAvailable", true).containsEntry("retryAfterSeconds", 0L);
        assertThat(String.valueOf(view.get("verifiedAt"))).isNotBlank();
        assertThat(mockingDetails(capabilityGateway).getInvocations().size()).isEqualTo(before);
    }
@@ -105,14 +105,14 @@
                .hasMessage(viewReason);
    }
    /** 进件还没通过时也打开这个页面:给结论,不查通道。 */
    /** 进件还没通过时也打开这个页面:给结论,不查通道。此时不存在冷却,剩余秒数必须是 0。 */
    @Test
    void merchantViewBlocksWithoutAnApprovedApplication() {
        jdbc.update("update merchant_onboarding_application set status='pending' where id=?", applicationId);
        Map<String, Object> view = readinessService.merchantView(merchantId, storeId);
        assertThat(view).containsEntry("ready", false).containsEntry("verifyAvailable", false);
        assertThat(view).containsEntry("ready", false).containsEntry("verifyAvailable", false).containsEntry("retryAfterSeconds", 0L);
        assertThat(String.valueOf(view.get("reason"))).contains("进件");
        assertThat(String.valueOf(view.get("verifiedAt"))).isEmpty();
        verify(capabilityGateway, never()).query(any(HuifuMerchantCapabilityQueryCommand.class));
@@ -126,11 +126,13 @@
        assertThat(verified).containsEntry("refreshed", true).containsEntry("ready", true).containsEntry("verifyAvailable", false);
        assertThat(String.valueOf(verified.get("verifiedAt"))).isNotBlank();
        assertThat(remainingSeconds(verified)).isGreaterThan(0L);
        verify(capabilityGateway, times(1)).query(any(HuifuMerchantCapabilityQueryCommand.class));
        Map<String, Object> throttled = readinessService.merchantVerify(merchantId, storeId);
        assertThat(throttled).containsEntry("refreshed", false).containsEntry("ready", true);
        assertThat(remainingSeconds(throttled)).isGreaterThan(0L);
        verify(capabilityGateway, times(1)).query(any(HuifuMerchantCapabilityQueryCommand.class));
    }
@@ -144,6 +146,7 @@
        assertThat(failed).containsEntry("refreshed", true).containsEntry("ready", false);
        assertThat(String.valueOf(failed.get("reason"))).contains("待确认");
        assertThat(remainingSeconds(failed)).isGreaterThan(0L);
        assertThat(readinessService.merchantVerify(merchantId, storeId)).containsEntry("refreshed", false);
        verify(capabilityGateway, times(1)).query(any(HuifuMerchantCapabilityQueryCommand.class));
    }
@@ -176,6 +179,11 @@
        return prefix + suffix.substring(Math.max(0, suffix.length() - 8));
    }
    /** 冷却剩余秒数:客户端据此在冷却结束时自行恢复按钮,所以它必须是可用的数字。 */
    private long remainingSeconds(Map<String, Object> view) {
        return ((Number) view.get("retryAfterSeconds")).longValue();
    }
    private void stubProvider(String merchantStatus, String settleStatus, String cashSwitch) {
        when(capabilityGateway.query(any(HuifuMerchantCapabilityQueryCommand.class)))
                .thenReturn(new HuifuMerchantCapabilityGateway.CapabilityResult(true, "00000000", "成功", merchantStatus, settleStatus, "D1", cashSwitch, "DM"));