talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java
@@ -33,6 +33,12 @@
    private static final Logger log = LoggerFactory.getLogger(MerchantPaymentReadinessService.class);
    /**
     * 商户端「立即核实」的冷却窗口。与进件刷新那套 5 分钟租约同量级:手动核实是用户动作,
     * 不能变成对通道的无限制查询入口。
     */
    private static final int MERCHANT_VERIFY_COOLDOWN_MINUTES = 5;
    private final JdbcTemplate jdbc;
    private final HuifuMerchantCapabilityGateway capabilityGateway;
    private final HuifuPaymentProperties huifuPaymentProperties;
@@ -48,9 +54,11 @@
        this.jdbc = jdbc;
        this.capabilityGateway = capabilityGateway;
        this.huifuPaymentProperties = huifuPaymentProperties;
        this.refreshTransaction = refreshTransaction;
        // 注入的 TransactionTemplate 是容器里的单例,支付、退款和自动提现都共用它。这里绝不能改它的
        // 传播行为,否则那些服务会跟着被改成 REQUIRES_NEW,外层事务被静默挂起。两个模板都自己新建。
        // The snapshot must survive a failed provider query in the caller's transaction, so it runs
        // in its own transaction instead of joining the caller's.
        this.refreshTransaction = new TransactionTemplate(refreshTransaction.getTransactionManager());
        this.refreshTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
        // The provider is an HTTP call: never make payment or onboarding hold a database transaction
        // (and its row locks) while waiting for it.
@@ -71,11 +79,35 @@
        return readiness(application);
    }
    /** Throws a conflict describing the first unmet precondition so callers can stop early. */
    /**
     * 通道侧收款能力是否已核实且正常:快照核实过、子商户状态正常、取现可用。
     *
     * <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.
     */
    @Transactional
    public void requirePayable(Long merchantId, Long storeId) {
    public CollectingMerchant requireCollectingMerchant(Long merchantId, Long storeId) {
        Readiness readiness = readiness(merchantId, storeId);
        if (!readiness.ready()) throw new BusinessException(ErrorCode.CONFLICT, readiness.reason());
        return readiness.merchant();
    }
    /** Re-reads the provider configuration and stores the resulting snapshot. */
@@ -86,12 +118,83 @@
    }
    /**
     * 商户端自查:与下单闸门同源、同文案的判定结果,外加最近核实时间与冷却状态。
     *
     * <p>只读,不查通道:快照的新鲜度由既有的定时核实与 {@link #merchantVerify} 负责。只返回派生
     * 结果——通道原始值({@code merchant_stat} 等)不下发给商户端。
     */
    public Map<String, Object> merchantView(Long merchantId, Long storeId) {
        Application application = approvedApplication(merchantId, storeId);
        Readiness readiness = readiness(merchantId, storeId);
        // 冷却剩余时间由服务端算好:客户端不必自己解析数据库时间戳字符串,也能在冷却结束时恢复按钮。
        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", 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;
    }
    /**
     * 商户端手动核实:一次通道查询,受冷却保护,冷却期内直接回当前快照。
     *
     * <p>冷却用一个 UPDATE 兼做判定与占位:只有抢到窗口的那次调用会去查通道,并发的第二次点击
     * 直接拿当前快照。查询成功与失败都留在冷却里——失败**不**推进 {@code capability_verified_at},
     * 拿它当节流依据会让按钮被连点,而通道查不通时恰恰最需要节流。
     *
     * <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, "商户进件缺少斗拱商户号,暂时无法收款");
        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;
    }
    /** 抢冷却窗口:抢到才去查通道。写侧的判据只有这一处,读侧见 {@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())
                """, MERCHANT_VERIFY_COOLDOWN_MINUTES, applicationId) > 0;
    }
    /**
     * 冷却剩余秒数,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 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());
    }
    /**
     * Capability snapshot for the admin settlement page. It queries the provider when the snapshot
     * has never been confirmed, because the explicit admin request is the moment to find out.
     */
    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);
        }
@@ -140,28 +243,34 @@
        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
        // onboarding completion, the scheduler, or an explicit admin refresh.
        if (unknown) return Readiness.blocked("商户收款能力待确认,暂时无法收款");
        if (!isAutoSettleOff(application.autoSettleStatus())) return Readiness.blocked("商户自动结算尚未关闭,暂时无法收款");
        // 不再要求「自动结算必须关闭」:未核销的钱由延迟户兜住(不可结算、不可取现),
        // 只有核销确认后才进基本户,因此自动结算开着也不会把钱提前结走。
        if (!isCashAvailable(application.cashSwitchStatus())) return Readiness.blocked("商户取现能力不可用,暂时无法收款");
        if (!isMerchantNormal(application.merchantStatus())) {
            return Readiness.blocked("斗拱子商户状态异常(" + application.merchantStatus() + "),暂时无法收款");
        }
        return Readiness.allowed();
        // 微信支付权限的前置:业务开通生效 + 实名与授权完成。只开通不实名,微信侧并没有把收款
        // 权限给到商户——实名认证的适用对象就是「需要开通微信支付权限的商户」。证据取自分阶段
        // 进度(由通道查询落库),不靠请求参数推断:「请求里带了参数」不等于通道侧生效。
        List<String> missingWechat = missingWechatCapabilities(application.id());
        if (!missingWechat.isEmpty()) {
            return Readiness.blocked("微信收款能力未就绪:" + String.join("、", missingWechat) + ",暂时无法收款");
        }
        return Readiness.allowed(new CollectingMerchant(application.id(), application.storeId(), application.huifuId()));
    }
    /** The payment integration is switched off, so provider-side network checks cannot be required. */
    private Readiness localOnly(Application application) {
        if (!application.autoSettleStatus().isBlank() && !isAutoSettleOff(application.autoSettleStatus())) {
            return Readiness.blocked("商户自动结算尚未关闭,暂时无法收款");
        }
        if (!application.cashSwitchStatus().isBlank() && !isCashAvailable(application.cashSwitchStatus())) {
            return Readiness.blocked("商户取现能力不可用,暂时无法收款");
        }
        return Readiness.allowed();
        return Readiness.allowed(new CollectingMerchant(application.id(), application.storeId(), application.huifuId()));
    }
    /**
@@ -214,8 +323,16 @@
        return rows.isEmpty() ? null : application(rows.get(0));
    }
    /**
     * 读取一条已通过的进件。这里刻意不加 {@code for update}:能力快照的 provider 查询和写入都在
     * {@code providerTransaction}({@code PROPAGATION_NOT_SUPPORTED},另一条连接)里完成,锁住这一行
     * 挡不住并发刷新(快照本来就是后写覆盖先写),却会和调用方事务已经持有的行锁撞在一起。进件审核的
     * 微信配置回调和业务开通回调都是在锁着这条进件记录的事务里走到这里的,行锁加在这里会让快照写入一直
     * 等到 {@code innodb_lock_wait_timeout} 后以 {@code CannotAcquireLockException} 失败,商户进件通过
     * 却永远无法收款。
     */
    private Application requireApprovedApplication(Long applicationId) {
        List<Map<String, Object>> rows = rows("select * from merchant_onboarding_application where id = ? limit 1 for update", applicationId);
        List<Map<String, Object>> rows = rows("select * from merchant_onboarding_application where id = ? limit 1", applicationId);
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商户进件不存在");
        Application application = application(rows.get(0));
        if (!"approved".equals(application.status())) throw new BusinessException(ErrorCode.CONFLICT, "商户进件尚未审核通过,无法确认收款能力");
@@ -232,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;
    }
@@ -252,6 +369,22 @@
        return huifuPaymentProperties.isMerchantCapabilityRequired();
    }
    /**
     * 分阶段进度里代表微信收款权限的两个阶段,缺哪个报哪个。
     *
     * <p>没有进度记录时两个都算缺——查不到证据就不能认为能收款。后续配置(延迟入账、自动结算、
     * 银行卡)另有各自的阶段与快照,这里只管微信侧这两项。
     */
    private List<String> missingWechatCapabilities(long applicationId) {
        List<Map<String, Object>> saved = rows("select stages_json from merchant_onboarding_progress where application_id=?", applicationId);
        if (saved.isEmpty()) return List.of("微信支付业务开通", "微信实名与授权");
        Map<String, Object> stages = com.alibaba.fastjson.JSON.parseObject(clean(saved.get(0).get("stagesJson")), Map.class);
        List<String> missing = new java.util.ArrayList<>();
        if (!"confirmed".equals(clean(stages.get("wechat")))) missing.add("微信支付业务开通");
        if (!"confirmed".equals(clean(stages.get("realname")))) missing.add("微信实名与授权");
        return missing;
    }
    private boolean isAutoSettleOff(String value) { return "0".equals(value) || "N".equalsIgnoreCase(value); }
    private boolean isCashAvailable(String value) { return "1".equals(value) || "Y".equalsIgnoreCase(value); }
@@ -262,14 +395,21 @@
        return new Application(
                ((Number) row.get("id")).longValue(),
                ((Number) row.get("merchantId")).longValue(),
                ((Number) row.get("storeId")).longValue(),
                number(row.get("storeId")),
                clean(row.get("status")),
                clean(row.get("providerHuifuId")),
                clean(row.get("merchantStat")),
                clean(row.get("autoSettleStatus")),
                clean(row.get("cashSwitchStatus")),
                clean(row.get("capabilityVerifiedAt"))
                // DatabaseRows 已把 datetime 归一成 LocalDateTime;缺列时这里是 null,不是异常。
                (java.time.LocalDateTime) row.get("capabilityVerifiedAt"),
                (java.time.LocalDateTime) row.get("capabilitySyncUntil")
        );
    }
    /** 进件的 store_id 允许为空(历史数据):这里不能直接拆箱,否则 NullPointerException 会冒到调用方。 */
    private static long number(Object value) {
        return value instanceof Number number ? number.longValue() : 0L;
    }
    private List<Map<String, Object>> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); }
@@ -285,12 +425,30 @@
            String merchantStatus,
            String autoSettleStatus,
            String cashSwitchStatus,
            String capabilityVerifiedAt
            java.time.LocalDateTime capabilityVerifiedAt,
            java.time.LocalDateTime capabilitySyncUntil
    ) {}
    /** Whether a merchant can take money right now, with the first unmet precondition as the reason. */
    public record Readiness(boolean ready, String reason) {
        public static Readiness allowed() { return new Readiness(true, ""); }
        public static Readiness blocked(String reason) { return new Readiness(false, reason); }
    /**
     * Whether a merchant can take money right now, with the first unmet precondition as the reason.
     * A ready result carries the collecting sub-merchant that orders must be bound to.
     */
    public record Readiness(boolean ready, String reason, CollectingMerchant merchant) {
        public static Readiness allowed(CollectingMerchant merchant) { return new Readiness(true, "", merchant); }
        public static Readiness blocked(String reason) { return new Readiness(false, reason, null); }
    }
    /**
     * The sub-merchant that collects an order's money: the approved onboarding, its store and the
     * Huifu merchant number. The null-safe accessors exist for the order inserts, where a free order
     * legitimately carries no collecting merchant.
     */
    public record CollectingMerchant(long applicationId, long storeId, String huifuId) {
        public static Long applicationIdOf(CollectingMerchant merchant) { return merchant == null ? null : merchant.applicationId(); }
        public static Long storeIdOf(CollectingMerchant merchant) { return merchant == null ? null : merchant.storeId(); }
        public static String huifuIdOf(CollectingMerchant merchant) { return merchant == null ? null : merchant.huifuId(); }
    }
}