talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java
@@ -80,6 +80,25 @@
    }
    /**
     * 通道侧收款能力是否已核实且正常:快照核实过、子商户状态正常、取现可用。
     *
     * <p>它只回答「通道那边这家商户开通正常吗」,不含 {@link #readiness(Long, Long)} 额外要求的
     * 微信/实名与绑卡前置(那一条才回答「现在能不能收款」)。只读的监控页面用它决定「该不该去问
     * 通道」:通道侧没开通或被关停的商户问过去也会被拒(实测被关闭的子商户回「客户与产品关系已关闭」),
     * 而本地缺进度记录、通道侧却正常且在结算的存量商户不该因此被摘出监控。
     */
    @Transactional(readOnly = true)
    public boolean channelCapabilityNormal(Long applicationId) {
        List<Map<String, Object>> rows = rows("select * from merchant_onboarding_application where id = ? limit 1", applicationId);
        if (rows.isEmpty()) return false;
        Application application = application(rows.get(0));
        if (!"approved".equals(application.status()) || application.huifuId().isBlank()) return false;
        // 快照没核实过就不算正常:那意味着「通道侧到底是什么状态」还没有答案。
        if (clean(application.capabilityVerifiedAt()).isBlank()) return false;
        return isMerchantNormal(application.merchantStatus()) && isCashAvailable(application.cashSwitchStatus());
    }
    /**
     * Returns the collecting sub-merchant an order must be bound to, or throws with the first unmet
     * precondition. Callers persist the returned application, store and Huifu id on the order so the
     * payment request cannot drift to another sub-merchant later.
@@ -108,12 +127,12 @@
        Application application = approvedApplication(merchantId, storeId);
        Readiness readiness = readiness(merchantId, storeId);
        // 冷却剩余时间由服务端算好:客户端不必自己解析数据库时间戳字符串,也能在冷却结束时恢复按钮。
        long retryAfterSeconds = application == null ? 0L : capabilityCooldownSeconds(application.id());
        long retryAfterSeconds = cooldownSeconds(application == null ? null : application.capabilitySyncUntil());
        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("verifiedAt", clean(application == null ? null : application.capabilityVerifiedAt()));
        view.put("nextVerifyAt", clean(application == null ? null : application.capabilitySyncUntil()));
        view.put("verifyAvailable", application != null && retryAfterSeconds == 0L);
        view.put("retryAfterSeconds", retryAfterSeconds);
        return view;
@@ -153,15 +172,20 @@
    }
    /**
     * 冷却剩余秒数,0 表示现在就能核实。与写侧读的是同一列;{@code coalesce} 兜住「从没设过冷却」
     * 与「冷却已过期」两种情况——{@code timestampdiff} 遇上 NULL 会连 {@code greatest} 一起变 NULL。
     * 冷却剩余秒数,0 表示现在就能核实。用申请行上已经读到的那个时间戳算,不单发一条 select:
     * 开发库(flyway 关闭)的列由启动时 best-effort 补,见 {@code MerchantOnboardingSchemaInitializer};
     * 点名 `capability_sync_until` 的查询在缺列时会整条报错、把商户页打成 500,而读已读到的行只会少一个字段。
     *
     * <p>时区显式取 Asia/Shanghai:库里的 datetime 由 `serverTimezone=Asia/Shanghai` 解释成上海墙钟值,
     * 拿它跟"本机默认时区"的 now() 相减,在非上海时区的机器上会整体偏掉(本仓库其它地方同样显式钉这个时区)。
     *
     * <p>它只服务于按钮的倒计时:真正的闸门是 {@link #acquireVerifyWindow} 那条按数据库时钟判定的 UPDATE,
     * 所以即便这里算早了,也只会让按钮早恢复、点了回来一个 {@code refreshed=false},不会多查通道。
     */
    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"));
    private static long cooldownSeconds(java.time.LocalDateTime syncUntil) {
        if (syncUntil == null) return 0L;
        java.time.LocalDateTime now = java.time.LocalDateTime.now(java.time.ZoneId.of("Asia/Shanghai"));
        return Math.max(0L, java.time.Duration.between(now, syncUntil).getSeconds());
    }
    /**
@@ -170,7 +194,7 @@
     */
    public Map<String, Object> capability(Long applicationId) {
        Application application = requireApprovedApplication(applicationId);
        if (application.capabilityVerifiedAt().isBlank()) {
        if (clean(application.capabilityVerifiedAt()).isBlank()) {
            queryProviderOutsideTransaction(application);
            application = requireApprovedApplication(applicationId);
        }
@@ -219,7 +243,7 @@
        boolean unknown = application.autoSettleStatus().isBlank()
                || application.cashSwitchStatus().isBlank()
                || application.merchantStatus().isBlank()
                || application.capabilityVerifiedAt().isBlank();
                || clean(application.capabilityVerifiedAt()).isBlank();
        if (application.huifuId().isBlank()) return Readiness.blocked("商户进件缺少斗拱商户号,暂时无法收款");
        if (unknown && !providerVerificationRequired()) return localOnly(application);
        // The payment path never calls the provider itself: an unfilled snapshot is filled by the
@@ -325,7 +349,7 @@
        view.put("autoSettleStatus", application.autoSettleStatus());
        view.put("cashAvailable", isCashAvailable(application.cashSwitchStatus()));
        view.put("cashSwitchStatus", application.cashSwitchStatus());
        view.put("verifiedAt", application.capabilityVerifiedAt());
        view.put("verifiedAt", clean(application.capabilityVerifiedAt()));
        return view;
    }
@@ -377,8 +401,9 @@
                clean(row.get("merchantStat")),
                clean(row.get("autoSettleStatus")),
                clean(row.get("cashSwitchStatus")),
                clean(row.get("capabilityVerifiedAt")),
                clean(row.get("capabilitySyncUntil"))
                // DatabaseRows 已把 datetime 归一成 LocalDateTime;缺列时这里是 null,不是异常。
                (java.time.LocalDateTime) row.get("capabilityVerifiedAt"),
                (java.time.LocalDateTime) row.get("capabilitySyncUntil")
        );
    }
@@ -400,8 +425,8 @@
            String merchantStatus,
            String autoSettleStatus,
            String cashSwitchStatus,
            String capabilityVerifiedAt,
            String capabilitySyncUntil
            java.time.LocalDateTime capabilityVerifiedAt,
            java.time.LocalDateTime capabilitySyncUntil
    ) {}
    /**