yeg
2026-09-17 20e19d8693d08ccb47e6533d8523fd44cd546a70
fix(merchant): 收款能力自查不再因缺列打成 500

真机点击「收款能力」报 Unknown column 'capability_sync_until':开发库 flyway 关着,
新列靠启动时 MerchantOnboardingSchemaInitializer 补,而那条 alter table 没成功(初始化等
DDL 元数据锁的上限 5 秒,等不到就记 error 放行)。已在开发库按 V148 的 DDL 手工补列。

代码侧同时修掉那处脆弱点:capabilityCooldownSeconds 是本文件里唯一点名列的查询,
缺列会让整条 SQL 报错、把商户页打成 500;现在改用申请行上已经读到的 capability_sync_until
自己算剩余秒数——少一条 select,缺列只是少一个字段(按没有冷却处理,页面照常显示判定)。
时区显式取 Asia/Shanghai,与 serverTimezone 对 datetime 的解释一致;verifiedAt/nextVerifyAt
的对外字符串格式未变。

Application 记录里的两个能力时间戳改为 LocalDateTime(DatabaseRows 归一后的真实类型),
展示处统一走 clean()。
2个文件已修改
45 ■■■■■ 已修改文件
.scratch/merchant-capability-visibility/issues/03-manual-capability-verify-with-cooldown.md 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/merchant-capability-visibility/issues/03-manual-capability-verify-with-cooldown.md
@@ -30,4 +30,5 @@
- 2026-09-17 由本会话 grilling 的 D7/D8/Q11 决策立项(见 [spec](../spec.md))。这是本轮唯一的新增迁移,编号前已确认 `db/migration/` 里最大版本是 147,本次为 `V148`。
- 2026-09-17 代码侧交付。冷却实现取"一个 UPDATE 兼做判定与占位":`update ... set capability_sync_until = now() + interval 5 minute where id = ? and (capability_sync_until is null or capability_sync_until < now())`,更新到行的那次才去查通道,并发点击直接拿当前快照。5 分钟按仓库既有 5 分钟租约写死在服务里(`MERCHANT_VERIFY_COOLDOWN_MINUTES`),没新开配置项——`stale-hours` 是运维口径,这个只是防连点。`merchantVerify` 刻意**不加** `@Transactional`:冷却窗口必须在通道查询之前落库,且通道查询不能夹在持有该行锁的事务里(这条在类注释里已写死)。未核实的异常路径(`!providerVerificationRequired() || !huifuPaymentProperties.isEnabled()`)直接返回当前快照、不查通道,与 `refreshQuietly` 的守卫一致。测试见 `MerchantPaymentReadinessMerchantViewTests` 的四个冷却用例(含"失败也节流"与"冷却过期后可再查")。
- 2026-09-17 复审修复(Spec 6)。原实现里"通道校验关掉/汇付未启用"那条早退分支**不返回 `refreshed`**,而 D9 说手动核实一定带它;客户端把缺失当 `false`,于是在那种配置下也会弹"刚核实过"。现在 `refreshed` 由唯一的返回点统一带上(`boolean refreshed = … && acquireVerifyWindow(...)`,早退已删除),结构上不可能再缺;前端文案也改成如实的"未重新查询通道,显示最近一次结果"。另把冷却的抢窗口逻辑提成 `acquireVerifyWindow`,与读侧的 `capabilityCooldownSeconds` 各管一侧。测试补了 `retryAfterSeconds` 的断言。
- 2026-09-17 复审修复(Spec 6)。原实现里"通道校验关掉/汇付未启用"那条早退分支**不返回 `refreshed`**,而 D9 说手动核实一定带它;客户端把缺失当 `false`,于是在那种配置下也会弹"刚核实过"。现在 `refreshed` 由唯一的返回点统一带上(`boolean refreshed = … && acquireVerifyWindow(...)`,早退已删除),结构上不可能再缺;前端文案也改成如实的"未重新查询通道,显示最近一次结果"。另把冷却的抢窗口逻辑提成 `acquireVerifyWindow`。测试补了 `retryAfterSeconds` 的断言。
- 2026-09-17 真机上报 `Unknown column 'capability_sync_until' in 'field list'`(点击「收款能力」即 500)。根因不在判断逻辑:开发库 `talent_card` 的 Flyway 是关的,新列靠启动时 `MerchantOnboardingSchemaInitializer` 补,而那条 `alter table` 没成功(初始化等 DDL 元数据锁的上限是 5 秒,等不到就记 error 放行——这是它在注释里写明的取舍)。已按 V148 同样的 DDL 在开发库手工补列,补完无需重启(列是每次请求现查的)。顺带修掉一处真实脆弱点:`capabilityCooldownSeconds` 原来是本文件里唯一"点名列"的查询,缺列时整条 SQL 报错、把商户页打成 500;现在改成用申请行上已经读到的 `capability_sync_until` 自己算剩余秒数(少一条 select,缺列只是少一个字段 → 按"没有冷却"处理,页面照常显示判定)。时区显式取 `Asia/Shanghai`,与库里 datetime 的解释方式一致。`verifiedAt`/`nextVerifyAt` 的对外字符串格式未变(仍是 `String.valueOf(LocalDateTime)`)。
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java
@@ -108,12 +108,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 +153,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 +175,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 +224,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 +330,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 +382,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 +406,8 @@
            String merchantStatus,
            String autoSettleStatus,
            String cashSwitchStatus,
            String capabilityVerifiedAt,
            String capabilitySyncUntil
            java.time.LocalDateTime capabilityVerifiedAt,
            java.time.LocalDateTime capabilitySyncUntil
    ) {}
    /**