yeg
2026-09-12 41cf4fe69e47b79de8208a06815e74895d74777a
feat(merchant): 商户收款能力就绪后才允许支付

实现工单 01(商户进件与收款能力就绪)。

- 新增 MerchantPaymentReadinessService,作为“该商户能否收款”的唯一判定:
进件已通过 + 银行卡 token 有效 + 子商户状态 NORMAL + 自动结算已关闭 +
取现开关已开通
- 新增 HuifuMerchantCapabilityGateway,通过 v2.merchant.basicdata.query 读取
子商户配置并落库快照(V130 迁移 + SchemaInitializer 兜底加列)
- 在 AppOrderPortalService 中拦截未就绪商户创建需支付订单
- 进件回调与绑卡回调保持幂等:已决记录不会被改写,晚到通知不会覆盖终态
- 为后台结算页提供能力查询/刷新接口,并定时刷新过期快照

测试:MerchantPaymentReadinessServiceTests、MerchantSettlementCallbackIdempotencyTests、
AppOrderPortalServiceTests 均通过。整套测试需指向 MySQL(TEST_DATASOURCE_*),因为部分
迁移使用了 H2 无法解析的 MySQL 专有语法。
7个文件已添加
11个文件已修改
1083 ■■■■■ 已修改文件
.scratch/automatic-withdrawal/issues/01-merchant-readiness.md 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/docs/huifu-payment-integration.md 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentCapabilityScheduler.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java 290 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantCapabilityGateway.java 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantCapabilityQueryCommand.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPaymentProperties.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/portal/AppOrderPortalService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/application.yml 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V130__merchant_payment_capability.sql 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessServiceTests.java 188 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantSettlementCallbackIdempotencyTests.java 158 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/portal/AppOrderPortalServiceTests.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/resources/application-test.yml 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/automatic-withdrawal/issues/01-merchant-readiness.md
@@ -4,10 +4,34 @@
**Blocked by:** None — can start immediately
**Status:** ready-for-agent
**Status:** ready-for-human
- [ ] 企业/个人商户进件结果、斗拱商户号和申请状态正确保存。
- [ ] 结算银行卡及 token 正确保存并支持查看、更换。
- [ ] 自动结算关闭和取现能力就绪均有明确可用判定。
- [ ] 能力未就绪的商户无法创建支付订单。
- [ ] 进件回调和重复提交具备幂等处理。
- [x] 企业/个人商户进件结果、斗拱商户号和申请状态正确保存。
- [x] 结算银行卡及 token 正确保存并支持查看、更换。
- [x] 自动结算关闭和取现能力就绪均有明确可用判定。
- [x] 能力未就绪的商户无法创建支付订单。
- [x] 进件回调和重复提交具备幂等处理。
## Comments
已实现,主要落点:
- 新增 `MerchantPaymentReadinessService`:唯一“能否收款”判定。要求存在最新 `approved` 进件、
  该进件名下有带 `provider_token_no` 的 `approved` 银行卡、`merchant_stat = NORMAL`、
  `settle_status = 0`(自动结算已关闭)、取现 `switch_state = 1`。快照未确认时按需调用斗拱查询,
  查询失败/被拒时保持“未就绪”而不是猜测通过。
- 新增 `HuifuMerchantCapabilityGateway`(`POST /v2/merchant/basicdata/query`,SDK
  `V2MerchantBasicdataQueryRequest`),把子商户状态、结算开关、取现开关与到账类型写入
  `merchant_onboarding_application`(V130 迁移 + `MerchantOnboardingSchemaInitializer` 兜底加列)。
- 支付闸口:`AppOrderPortalService.create` 在需要支付的订单(`pay_amount > 0`)落库前调用
  `requirePayable`,未就绪直接 409;免单订单不受影响。
- 幂等:`handleOnboardingCallback` 不再改写已 `approved`/`rejected` 的进件,且回调只对 `status='pending'`
  的银行卡写 token;绑定回调对已决卡片直接返回,晚到通知与重复通知都不再覆盖终态。
- 后台:`GET/POST /api/admin/management/merchant-settlement/onboardings/{id}/payment-capability[/refresh]`
  查看与刷新能力快照;`MerchantPaymentCapabilityScheduler` 定时刷新超过
  `app.payment.huifu.capability-refresh.stale-hours`(默认 6 小时)的快照。
测试(`MerchantPaymentReadinessServiceTests` 10 项、`MerchantSettlementCallbackIdempotencyTests` 5 项、
`AppOrderPortalServiceTests` 6 项)全部通过;三种失败组合、提供方查询失败与超时、晚到/重复回调均已覆盖。
注意:仓库的 H2 测试库无法解析 V77 等 MySQL 专用迁移,Spring 测试需按
`talent-card-server/docs/huifu-payment-integration.md` 的说明指向 MySQL 才能加载上下文。
talent-card-server/docs/huifu-payment-integration.md
@@ -99,6 +99,75 @@
8. Run a refund, repeat the delayed-callback test, and confirm that only a verified callback or provider query can mark `order_refund.refund_status = 2`.
9. Test invalid callback signatures, provider timeouts, duplicate payment button taps, expired orders, refunded orders, and provider-declared failures. No case may expose payment parameters, OpenID, keys, signatures, or full callback payloads in logs or API responses.
## Merchant payment readiness (settlement and encashment capability)
A merchant may only take paid orders once its sub-merchant configuration is confirmed. The server
reads that configuration with the Huifu `POST /v2/merchant/basicdata/query` interface
(`v2.merchant.basicdata.query`) and stores the result on the onboarding application:
| Stored column | Provider field | Ready value |
| --- | --- | --- |
| `merchant_stat` | `mer_stat` | `NORMAL` (any other value blocks payment) |
| `auto_settle_status` | `qry_settle_config_list[].settle_status` | `0` (automatic settlement off) |
| `cash_switch_status` | `qry_cash_config_list[].switch_state` | `1` (encashment available) |
| `cash_type` | `qry_cash_config_list[].cash_type` | recorded for reconciliation (`D0`/`D1`/`T1`/`DM`) |
| `capability_verified_at` | query timestamp | set only after a successful query |
Rules enforced by `MerchantPaymentReadinessService`:
1. The newest approved onboarding of the merchant (optionally scoped to a store) must exist.
2. That onboarding must own an approved settlement bank card with a provider `token_no`. Because a
   merchant may replace its settlement card, an approved card for the same store also satisfies
   this rule.
3. `merchant_stat` must be `NORMAL`.
4. Automatic settlement must be off; the provider console is the place to switch it off, and the
   next capability refresh confirms it.
5. `qry_cash_config_list[].switch_state` must be `1`.
When the snapshot has never been confirmed the service queries the provider on demand. An
unanswered or rejected query keeps the merchant blocked rather than guessing. When
`app.payment.huifu.enabled=false` (local development and tests) no outbound provider call is made:
only rules 1 and 2 are enforced, plus rules 4 and 5 for whatever the stored snapshot already says.
`MerchantPaymentCapabilityScheduler` refreshes approved sub-merchants whose snapshot is older than
`app.payment.huifu.capability-refresh.stale-hours` (default 6 hours). Administrators can force a
refresh from the management console.
### Management endpoints
- `GET /api/admin/management/merchant-settlement/onboardings/{id}/payment-capability`
  - Returns `applicationId`, `huifuId`, `merchantStatus`, `autoSettleDisabled`, `autoSettleStatus`,
    `cashAvailable`, `cashSwitchStatus`, `verifiedAt`. It queries the provider when the snapshot has
    never been confirmed.
- `POST /api/admin/management/merchant-settlement/onboardings/{id}/payment-capability/refresh`
  - Re-reads the provider configuration, stores a fresh snapshot and returns the same shape.
### Configuration
```text
HUIFU_MERCHANT_CAPABILITY_REQUIRED=true      # require provider confirmation (defaults to the enabled flag)
HUIFU_CAPABILITY_REFRESH_ENABLED=true
HUIFU_CAPABILITY_STALE_HOURS=6
HUIFU_CAPABILITY_REFRESH_FIXED_DELAY_MS=1800000
```
### Running the server tests
Several migrations use MySQL-only syntax that the in-memory H2 default of `application-test.yml`
cannot parse, so the Spring tests only load when pointed at MySQL:
```powershell
$env:TEST_DATASOURCE_URL='jdbc:mysql://127.0.0.1:8134/talent_card_test?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false&createDatabaseIfNotExist=true'
$env:TEST_DATASOURCE_USERNAME='root'
$env:TEST_DATASOURCE_PASSWORD='123456'
$env:TEST_DATASOURCE_DRIVER='com.mysql.cj.jdbc.Driver'
.\mvnw.cmd -o test
```
`MerchantPaymentReadinessServiceTests`, `MerchantSettlementCallbackIdempotencyTests` and
`AppOrderPortalServiceTests` cover the readiness snapshot, the payment gate for paid orders and
callback idempotency. They stub `HuifuMerchantCapabilityGateway`, so they never call Huifu.
## Remaining provider-dependent work
- Delayed-account confirmation must only be enabled after the corresponding Huifu product and confirmation API are provisioned and tested.
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
@@ -31,6 +31,8 @@
  @DeleteMapping("/staff/{id}") public ApiResponse<Map<String,Object>> deleteStaff(@PathVariable Long id) { return ApiResponse.success(service.deleteStaff(id)); }
  @GetMapping("/merchant-settlement/onboardings") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> onboardings(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.onboardings(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); }
  @GetMapping("/merchant-settlement/onboardings/{id}") public ApiResponse<Map<String,Object>> onboarding(@PathVariable Long id) { return ApiResponse.success(settlementService.onboarding(id)); }
  @GetMapping("/merchant-settlement/onboardings/{id}/payment-capability") public ApiResponse<Map<String,Object>> paymentCapability(@PathVariable Long id) { return ApiResponse.success(settlementService.paymentCapability(id)); }
  @PostMapping("/merchant-settlement/onboardings/{id}/payment-capability/refresh") public ApiResponse<Map<String,Object>> refreshPaymentCapability(@PathVariable Long id) { return ApiResponse.success(settlementService.refreshPaymentCapability(id)); }
  @GetMapping("/merchant-settlement/bank-cards") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> bankCards(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.bankCards(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); }
  @GetMapping("/merchant-settlement/withdrawals") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> withdrawals(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.withdrawals(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); }
  @GetMapping("/payment-reconciliation/issues") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> reconciliationIssues(@RequestParam(defaultValue = "open") String status, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return ApiResponse.success(reconciliationService.issues(status, page, size)); }
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
@@ -14,13 +14,15 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.talentcard.server.business.merchant.HuifuMerchantWithdrawalService;
import com.talentcard.server.business.merchant.MerchantPaymentReadinessService;
@Service
public class MerchantSettlementAdminService {
    private final JdbcTemplate jdbc;
    private final HuifuMerchantWithdrawalService withdrawalService;
    private final SensitiveDataCrypto sensitiveDataCrypto;
    public MerchantSettlementAdminService(JdbcTemplate jdbc, HuifuMerchantWithdrawalService withdrawalService, SensitiveDataCrypto sensitiveDataCrypto) { this.jdbc = jdbc; this.withdrawalService = withdrawalService; this.sensitiveDataCrypto = sensitiveDataCrypto; }
    private final MerchantPaymentReadinessService readinessService;
    public MerchantSettlementAdminService(JdbcTemplate jdbc, HuifuMerchantWithdrawalService withdrawalService, SensitiveDataCrypto sensitiveDataCrypto, MerchantPaymentReadinessService readinessService) { this.jdbc = jdbc; this.withdrawalService = withdrawalService; this.sensitiveDataCrypto = sensitiveDataCrypto; this.readinessService = readinessService; }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> onboardings(int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) {
@@ -59,6 +61,12 @@
    @Transactional public Map<String, Object> reviewBankCard(Long id, String status, String reason, String tokenNo) { return reviewBankCardInternal(id, status, reason, tokenNo); }
    public Map<String, Object> reviewWithdrawal(Long id, String status, String reason) { if ("paid".equals(normalizeWithdrawalStatus(status))) return withdrawalService.dispatch(id); return update("merchant_withdrawal", id, "failed", reason, "withdrawal"); }
    /** Provider-confirmed settlement and encashment capability of one approved sub-merchant. */
    public Map<String, Object> paymentCapability(Long applicationId) { return readinessService.capability(applicationId); }
    /** Re-reads the provider configuration and stores a fresh capability snapshot. */
    public Map<String, Object> refreshPaymentCapability(Long applicationId) { return readinessService.refresh(applicationId); }
    private Map<String,Object> reviewBankCardInternal(Long id,String status,String reason,String tokenNo) { String value=reviewStatus(status); if("approved".equals(value) && (tokenNo==null || !tokenNo.trim().matches("[A-Za-z0-9]{1,32}"))) throw new BusinessException(ErrorCode.BAD_REQUEST,"Huifu withdrawal card token is required when approving a bank card"); int affected=jdbc.update("update merchant_bank_card set status=?, reject_reason=?, provider_token_no=?, reviewed_at=now() where id=? and status='pending'",value,reason==null?"":reason.trim(),"approved".equals(value)?tokenNo.trim():null,id); if(affected==0)throw new BusinessException(ErrorCode.CONFLICT,"Bank card is already handled or does not exist");return Map.of("id",id,"status",value); }
    private String reviewStatus(String status) { String value=status==null?"":status.trim().toLowerCase(); if(!List.of("approved","rejected").contains(value))throw new BusinessException(ErrorCode.BAD_REQUEST,"Invalid review status");return value; }
    private String normalizeWithdrawalStatus(String status) { String value=status==null?"":status.trim().toLowerCase(); if(!List.of("paid","failed").contains(value))throw new BusinessException(ErrorCode.BAD_REQUEST,"Invalid withdrawal status");return value; }
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java
@@ -46,6 +46,13 @@
            columns.put("business_open_apply_no", "varchar(32) default null");
            columns.put("business_open_response_code", "varchar(32) default null");
            columns.put("business_open_response_message", "varchar(512) default null");
            columns.put("merchant_stat", "varchar(32) default null");
            columns.put("auto_settle_status", "char(1) default null");
            columns.put("cash_switch_status", "char(1) default null");
            columns.put("cash_type", "varchar(8) default null");
            columns.put("capability_response_code", "varchar(32) default null");
            columns.put("capability_response_message", "varchar(512) default null");
            columns.put("capability_verified_at", "datetime default null");
            for (Map.Entry<String, String> column : columns.entrySet()) {
                if (!hasColumn("merchant_onboarding_application", column.getKey())) jdbc.execute("alter table merchant_onboarding_application add column " + column.getKey() + " " + column.getValue());
            }
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentCapabilityScheduler.java
New file
@@ -0,0 +1,25 @@
package com.talentcard.server.business.merchant;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/** Keeps the provider-confirmed settlement and encashment snapshot fresh for approved sub-merchants. */
@Component
public class MerchantPaymentCapabilityScheduler {
    private static final Logger log = LoggerFactory.getLogger(MerchantPaymentCapabilityScheduler.class);
    private final MerchantPaymentReadinessService readinessService;
    @Value("${app.payment.huifu.capability-refresh.enabled:true}") private boolean enabled;
    @Value("${app.payment.huifu.capability-refresh.stale-hours:6}") private int staleHours;
    public MerchantPaymentCapabilityScheduler(MerchantPaymentReadinessService readinessService) { this.readinessService = readinessService; }
    @Scheduled(fixedDelayString = "${app.payment.huifu.capability-refresh.fixed-delay-ms:1800000}")
    public void refresh() {
        if (!enabled) return;
        try { readinessService.refreshStaleCapabilities(staleHours, 25); }
        catch (RuntimeException exception) { log.warn("Merchant payment capability refresh failed: {}", exception.getClass().getSimpleName()); }
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessService.java
New file
@@ -0,0 +1,290 @@
package com.talentcard.server.business.merchant;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityGateway;
import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityQueryCommand;
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
/**
 * Single decision point for "may this merchant collect money?".
 *
 * <p>A merchant is payable only when the sub-merchant onboarding is approved, its settlement bank
 * card carries a provider token, the provider account is in a normal state, automatic settlement is
 * confirmed off and the encashment (取现) capability is confirmed available. When the provider
 * integration is disabled the network configuration is not required, but the onboarding approval
 * and the bound card token still are.
 */
@Service
public class MerchantPaymentReadinessService {
    private static final Logger log = LoggerFactory.getLogger(MerchantPaymentReadinessService.class);
    private final JdbcTemplate jdbc;
    private final HuifuMerchantCapabilityGateway capabilityGateway;
    private final HuifuPaymentProperties huifuPaymentProperties;
    private final TransactionTemplate refreshTransaction;
    public MerchantPaymentReadinessService(
            JdbcTemplate jdbc,
            HuifuMerchantCapabilityGateway capabilityGateway,
            HuifuPaymentProperties huifuPaymentProperties,
            TransactionTemplate refreshTransaction
    ) {
        this.jdbc = jdbc;
        this.capabilityGateway = capabilityGateway;
        this.huifuPaymentProperties = huifuPaymentProperties;
        this.refreshTransaction = refreshTransaction;
        // 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.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
    }
    /** Readiness of the merchant's newest approved onboarding, refreshing the provider view when stale. */
    @Transactional
    public Readiness readiness(Long merchantId, Long storeId) {
        Application application = approvedApplication(merchantId, storeId);
        if (application == null) {
            return Readiness.blocked("商户进件尚未审核通过,暂时无法收款");
        }
        if (!approvedBankCardExists(merchantId, application.id())) {
            return Readiness.blocked("结算银行卡尚未绑定成功,暂时无法收款");
        }
        return readiness(application);
    }
    /** Throws a conflict describing the first unmet precondition so callers can stop early. */
    @Transactional
    public void requirePayable(Long merchantId, Long storeId) {
        Readiness readiness = readiness(merchantId, storeId);
        if (!readiness.ready()) throw new BusinessException(ErrorCode.CONFLICT, readiness.reason());
    }
    /** Re-reads the provider configuration and stores the resulting snapshot. */
    public Map<String, Object> refresh(Long applicationId) {
        Application application = requireApprovedApplication(applicationId);
        queryProvider(application);
        return capabilityView(requireApprovedApplication(applicationId));
    }
    /**
     * 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.
     */
    @Transactional
    public Map<String, Object> capability(Long applicationId) {
        Application application = requireApprovedApplication(applicationId);
        if (application.capabilityVerifiedAt().isBlank()) {
            queryProvider(application);
            application = requireApprovedApplication(applicationId);
        }
        return capabilityView(application);
    }
    /**
     * Attempts to refresh approved applications whose capability snapshot is missing or older than
     * the configured interval. Each application is refreshed in its own transaction, so a provider
     * outage leaves every previous snapshot untouched; the run stops early because one failure
     * normally means the next call fails too.
     */
    public int refreshStaleCapabilities(int staleHours, int limit) {
        if (!providerVerificationRequired() || !huifuPaymentProperties.isEnabled()) return 0;
        List<Map<String, Object>> rows = rows("""
                select id from merchant_onboarding_application
                where status = 'approved' and provider_huifu_id is not null
                  and (capability_verified_at is null or capability_verified_at <= date_sub(now(), interval ? hour))
                order by capability_verified_at asc, id asc
                limit ?
                """, Math.max(1, staleHours), Math.max(1, Math.min(limit, 100)));
        int refreshed = 0;
        for (Map<String, Object> row : rows) {
            if (!refreshQuietly(((Number) row.get("id")).longValue())) break;
            refreshed++;
        }
        return refreshed;
    }
    /**
     * Refreshes the snapshot for one application in its own transaction, so a provider failure never
     * rolls back the caller's work. Returns whether the snapshot was refreshed.
     */
    public boolean refreshQuietly(Long applicationId) {
        if (applicationId == null || !providerVerificationRequired() || !huifuPaymentProperties.isEnabled()) return false;
        try {
            refreshTransaction.executeWithoutResult(status -> refresh(applicationId));
            return true;
        } catch (RuntimeException exception) {
            log.warn("Merchant capability refresh failed: applicationId={}, type={}", applicationId, exception.getClass().getSimpleName());
            return false;
        }
    }
    private Readiness readiness(Application application) {
        boolean unknown = application.autoSettleStatus().isBlank()
                || application.cashSwitchStatus().isBlank()
                || application.merchantStatus().isBlank()
                || application.capabilityVerifiedAt().isBlank();
        if (unknown && !providerVerificationRequired()) return localOnly(application);
        if (unknown) {
            if (!queryProvider(application)) return Readiness.blocked("商户收款能力待确认,暂时无法收款");
            Application refreshed = approvedApplication(application.merchantId(), application.storeId());
            if (refreshed == null || refreshed.id() != application.id()) return Readiness.blocked("商户收款能力待确认,暂时无法收款");
            if (refreshed.autoSettleStatus().isBlank() || refreshed.cashSwitchStatus().isBlank()) {
                return Readiness.blocked("商户收款能力待确认,暂时无法收款");
            }
            application = refreshed;
        }
        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();
    }
    /** 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();
    }
    /** Returns false when the provider explicitly rejected the query; the snapshot then stays unknown. */
    private boolean queryProvider(Application application) {
        try {
            HuifuMerchantCapabilityGateway.CapabilityResult result = capabilityGateway.query(
                    new HuifuMerchantCapabilityQueryCommand(BusinessIdGenerator.next("MC"), application.huifuId()));
            if (!result.succeeded()) {
                jdbc.update("""
                        update merchant_onboarding_application
                        set capability_response_code = ?, capability_response_message = ?
                        where id = ?
                        """, clean(result.code()), clean(result.message()), application.id());
                return false;
            }
            jdbc.update("""
                    update merchant_onboarding_application
                    set merchant_stat = nullif(?,''), auto_settle_status = nullif(?,''), cash_switch_status = nullif(?,''),
                        cash_type = nullif(?,''), capability_response_code = ?, capability_response_message = ?,
                        capability_verified_at = now()
                    where id = ?
                    """, clean(result.merchantStatus()), clean(result.settleStatus()), clean(result.cashSwitchStatus()),
                    clean(result.cashType()), clean(result.code()), clean(result.message()), application.id());
            return true;
        } catch (HuifuPaymentGatewayException exception) {
            jdbc.update("""
                    update merchant_onboarding_application
                    set capability_response_code = 'QUERY_UNKNOWN', capability_response_message = ?
                    where id = ?
                    """, exception.isOutcomeUnknown() ? "商户收款能力查询结果待确认" : "商户收款能力查询失败", application.id());
            return false;
        }
    }
    private Application approvedApplication(Long merchantId, Long storeId) {
        String storeFilter = storeId == null ? "" : " and store_id = ?";
        Object[] args = storeId == null ? new Object[] { merchantId } : new Object[] { merchantId, storeId };
        List<Map<String, Object>> rows = rows("""
                select * from merchant_onboarding_application
                where merchant_id = ? and initial_bank_card_id is not null and status = 'approved'
                """ + storeFilter + " order by id desc limit 1", args);
        return rows.isEmpty() ? null : application(rows.get(0));
    }
    private Application requireApprovedApplication(Long applicationId) {
        List<Map<String, Object>> rows = rows("select * from merchant_onboarding_application where id = ? limit 1 for update", 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, "商户进件尚未审核通过,无法确认收款能力");
        if (application.huifuId().isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "缺少斗拱商户号,无法确认收款能力");
        return application;
    }
    private Map<String, Object> capabilityView(Application application) {
        Map<String, Object> view = new LinkedHashMap<>();
        view.put("applicationId", String.valueOf(application.id()));
        view.put("huifuId", application.huifuId());
        view.put("merchantStatus", application.merchantStatus());
        view.put("autoSettleDisabled", !application.autoSettleStatus().isBlank() && isAutoSettleOff(application.autoSettleStatus()));
        view.put("autoSettleStatus", application.autoSettleStatus());
        view.put("cashAvailable", isCashAvailable(application.cashSwitchStatus()));
        view.put("cashSwitchStatus", application.cashSwitchStatus());
        view.put("verifiedAt", application.capabilityVerifiedAt());
        return view;
    }
    /** Prefers the onboarding's own card; falls back to any approved card for the store. */
    private boolean approvedBankCardExists(Long merchantId, Long applicationId) {
        List<Map<String, Object>> rows = rows("""
                select id from merchant_bank_card
                where status = 'approved' and provider_token_no is not null
                  and merchant_id = ?
                  and (id = (select initial_bank_card_id from merchant_onboarding_application where id = ?) or store_id = (select store_id from merchant_onboarding_application where id = ?))
                order by id desc limit 1
                """, merchantId, applicationId, applicationId);
        return !rows.isEmpty();
    }
    private boolean providerVerificationRequired() {
        return huifuPaymentProperties.isMerchantCapabilityRequired();
    }
    private boolean isAutoSettleOff(String value) { return "0".equals(value) || "N".equalsIgnoreCase(value); }
    private boolean isCashAvailable(String value) { return "1".equals(value) || "Y".equalsIgnoreCase(value); }
    private boolean isMerchantNormal(String value) { return value.isBlank() || "NORMAL".equalsIgnoreCase(value); }
    private Application application(Map<String, Object> row) {
        return new Application(
                ((Number) row.get("id")).longValue(),
                ((Number) row.get("merchantId")).longValue(),
                ((Number) row.get("storeId")).longValue(),
                clean(row.get("status")),
                clean(row.get("providerHuifuId")),
                clean(row.get("merchantStat")),
                clean(row.get("autoSettleStatus")),
                clean(row.get("cashSwitchStatus")),
                clean(row.get("capabilityVerifiedAt"))
        );
    }
    private List<Map<String, Object>> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); }
    private String clean(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
    private record Application(
            long id,
            long merchantId,
            long storeId,
            String status,
            String huifuId,
            String merchantStatus,
            String autoSettleStatus,
            String cashSwitchStatus,
            String capabilityVerifiedAt
    ) {}
    /** 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); }
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -51,8 +51,9 @@
    private final HuifuUserBusinessOpenGateway userBusinessOpenGateway;
    private final HuifuPaymentProperties huifuPaymentProperties;
    private final HuifuPictureUploadGateway pictureUploadGateway;
    private final MerchantPaymentReadinessService paymentReadinessService;
    public MerchantSettlementService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, HuifuIndividualOnboardingGateway individualOnboardingGateway, HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway, HuifuMerchantWechatConfigGateway merchantWechatConfigGateway, HuifuMerchantBusinessOpenGateway merchantBusinessOpenGateway, HuifuMerchantWithdrawalService withdrawalService, HuifuUserBusinessOpenGateway userBusinessOpenGateway, HuifuPaymentProperties huifuPaymentProperties, HuifuPictureUploadGateway pictureUploadGateway) {
    public MerchantSettlementService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, HuifuIndividualOnboardingGateway individualOnboardingGateway, HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway, HuifuMerchantWechatConfigGateway merchantWechatConfigGateway, HuifuMerchantBusinessOpenGateway merchantBusinessOpenGateway, HuifuMerchantWithdrawalService withdrawalService, HuifuUserBusinessOpenGateway userBusinessOpenGateway, HuifuPaymentProperties huifuPaymentProperties, HuifuPictureUploadGateway pictureUploadGateway, MerchantPaymentReadinessService paymentReadinessService) {
        this.jdbc = jdbc;
        this.sensitiveDataCrypto = sensitiveDataCrypto;
        this.individualOnboardingGateway = individualOnboardingGateway;
@@ -63,6 +64,7 @@
        this.userBusinessOpenGateway = userBusinessOpenGateway;
        this.huifuPaymentProperties = huifuPaymentProperties;
        this.pictureUploadGateway = pictureUploadGateway;
        this.paymentReadinessService = paymentReadinessService;
    }
    @Transactional(readOnly = true)
@@ -305,6 +307,10 @@
        }
    }
    /**
     * Business-opening (card binding) callback. Only a card that is still pending may change state,
     * so a late or duplicated notification for an already approved or rejected card is ignored.
     */
    @Transactional
    @SuppressWarnings("unchecked")
    public boolean handleBankCardCallback(Map<String, Object> payload) {
@@ -318,15 +324,24 @@
        if (requestNo.isBlank()) return false;
        List<Map<String, Object>> cards = rows("select * from merchant_bank_card where provider_request_no=? limit 1 for update", requestNo);
        if (cards.isEmpty()) return false;
        Map<String, Object> card = cards.get(0);
        Object cardId = card.get("id");
        if (!"pending".equals(clean(card.get("status")))) return true;
        String tokenNo = clean(data.get("token_no"));
        String code = clean(data.get("sub_resp_code")); if (code.isBlank()) code = clean(data.get("resp_code"));
        String message = clean(data.get("sub_resp_desc")); if (message.isBlank()) message = clean(data.get("resp_desc"));
        String applyNo = clean(data.get("apply_no"));
        if (!tokenNo.isBlank() && providerSuccess(code)) jdbc.update("update merchant_bank_card set status='approved', provider_token_no=?, provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=?, reviewed_at=now(), reject_reason=null where id=? and status='pending'", tokenNo, applyNo, code, message, cards.get(0).get("id"));
        else if (!providerSuccess(code)) jdbc.update("update merchant_bank_card set status='rejected', provider_response_code=?, provider_response_message=?, reject_reason=?, reviewed_at=now() where id=? and status='pending'", code, message, providerReason(code, message), cards.get(0).get("id"));
        else jdbc.update("update merchant_bank_card set provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=? where id=? and status='pending'", applyNo, code, message, cards.get(0).get("id"));
        if (!tokenNo.isBlank() && providerSuccess(code)) jdbc.update("update merchant_bank_card set status='approved', provider_token_no=?, provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=?, reviewed_at=now(), reject_reason=null where id=? and status='pending'", tokenNo, applyNo, code, message, cardId);
        else if (!providerSuccess(code)) jdbc.update("update merchant_bank_card set status='rejected', provider_response_code=?, provider_response_message=?, reject_reason=?, reviewed_at=now() where id=? and status='pending'", code, message, providerReason(code, message), cardId);
        else jdbc.update("update merchant_bank_card set provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=? where id=? and status='pending'", applyNo, code, message, cardId);
        return true;
    }
    /**
     * Onboarding audit callback. The handler is keyed by the application number and only moves an
     * application that is not already decided, so duplicated notifications are no-ops and a late
     * notification cannot overwrite a decided application.
     */
    @Transactional
    @SuppressWarnings("unchecked")
    public boolean handleOnboardingCallback(Map<String, Object> data) {
@@ -339,14 +354,15 @@
        List<Map<String, Object>> applications = rows("select * from merchant_onboarding_application where application_no=? limit 1 for update", requestNo);
        if (applications.isEmpty()) return false;
        Map<String, Object> application = applications.get(0);
        if ("approved".equals(clean(application.get("status"))) || "rejected".equals(clean(application.get("status")))) return true;
        String code = clean(data.get("sub_resp_code")); if (code.isBlank()) code = clean(data.get("resp_code"));
        String message = clean(data.get("audit_desc")); if (message.isBlank()) message = clean(data.get("sub_resp_desc")); if (message.isBlank()) message = clean(data.get("resp_desc"));
        String auditStatus = clean(data.get("audit_status"));
        String huifuId = clean(data.get("huifu_id")); String applyNo = clean(data.get("apply_no")); String tokenNo = clean(data.get("token_no"));
        Long bankCardId = application.get("initialBankCardId") instanceof Number number ? number.longValue() : null;
        if ("Y".equalsIgnoreCase(auditStatus)) {
            jdbc.update("update merchant_onboarding_application set status='pending', provider_huifu_id=coalesce(nullif(?,''),provider_huifu_id), provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=?, reject_reason=null, reviewed_at=null where application_no=?", huifuId, applyNo, code, message, requestNo);
            if (bankCardId != null) jdbc.update("update merchant_bank_card set status=case when coalesce(nullif(?,''),provider_token_no) is null then status else 'approved' end, provider_token_no=coalesce(nullif(?,''),provider_token_no), provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=?, reject_reason=null where id=?", tokenNo, tokenNo, applyNo, code, message, bankCardId);
            jdbc.update("update merchant_onboarding_application set provider_huifu_id=coalesce(nullif(?,''),provider_huifu_id), provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=?, reject_reason=null, reviewed_at=null where application_no=? and status not in ('approved','rejected')", huifuId, applyNo, code, message, requestNo);
            if (bankCardId != null) jdbc.update("update merchant_bank_card set status=case when coalesce(nullif(?,''),provider_token_no) is null then status else 'approved' end, provider_token_no=coalesce(nullif(?,''),provider_token_no), provider_apply_no=coalesce(nullif(?,''),provider_apply_no), provider_response_code=?, provider_response_message=?, reject_reason=null where id=? and status='pending'", tokenNo, tokenNo, applyNo, code, message, bankCardId);
            continuePostOnboardingProvisioning(requestNo);
        } else if ("N".equalsIgnoreCase(auditStatus) || "F".equalsIgnoreCase(auditStatus) || !providerSuccess(code)) {
            String reason = providerReason(code, message);
@@ -465,7 +481,11 @@
    }
    private void completePostOnboardingProvisioning(Object applicationId) {
        jdbc.update("update merchant_onboarding_application set status='approved', reject_reason=null, reviewed_at=now() where id=? and wechat_config_status='approved' and business_open_status='approved'", applicationId);
        int completed = jdbc.update("update merchant_onboarding_application set status='approved', reject_reason=null, reviewed_at=now() where id=? and wechat_config_status='approved' and business_open_status='approved'", applicationId);
        if (completed > 0 && applicationId instanceof Number number) {
            // Approved sub-merchants need a confirmed settlement/encashment snapshot before they can collect money.
            paymentReadinessService.refreshQuietly(number.longValue());
        }
    }
    @SuppressWarnings("unchecked")
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantCapabilityGateway.java
New file
@@ -0,0 +1,146 @@
package com.talentcard.server.business.payment.huifu;
import com.alibaba.fastjson.JSON;
import com.huifu.bspay.sdk.opps.client.BasePayClient;
import com.huifu.bspay.sdk.opps.core.BasePay;
import com.huifu.bspay.sdk.opps.core.config.MerConfig;
import com.huifu.bspay.sdk.opps.core.request.V2MerchantBasicdataQueryRequest;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
 * Reads the provider-side merchant configuration that decides whether a sub-merchant may collect
 * payments: the sub-merchant standing, the automatic settlement switch and the encashment
 * (取现) capability.
 */
@Service
public class HuifuMerchantCapabilityGateway {
    private static final Object SDK_LOCK = new Object();
    private static final Logger log = LoggerFactory.getLogger(HuifuMerchantCapabilityGateway.class);
    private final HuifuPaymentProperties properties;
    public HuifuMerchantCapabilityGateway(HuifuPaymentProperties properties) { this.properties = properties; }
    public CapabilityResult query(HuifuMerchantCapabilityQueryCommand command) {
        ensureConfigured();
        V2MerchantBasicdataQueryRequest request = new V2MerchantBasicdataQueryRequest();
        request.setReqSeqId(command.requestNo());
        request.setReqDate(LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE));
        request.setHuifuId(command.huifuId());
        try {
            synchronized (SDK_LOCK) {
                initializeSdk();
                return CapabilityResult.from(BasePayClient.request(request, false));
            }
        } catch (BusinessException exception) {
            throw exception;
        } catch (Exception exception) {
            log.warn("Huifu merchant capability query failed: huifuId={}, requestNo={}, type={}, message={}",
                    command.huifuId(), command.requestNo(), exception.getClass().getSimpleName(), safe(exception.getMessage()));
            throw new HuifuPaymentGatewayException("Huifu merchant capability query failed", unknown(exception), exception);
        }
    }
    private void ensureConfigured() {
        if (!properties.isEnabled()) throw new BusinessException(ErrorCode.CONFLICT, "Huifu payment is disabled");
        if (blank(properties.getSystemId()) || blank(properties.getProductId()) || blank(properties.getMerchantId())
                || blank(properties.getPrivateKey()) || blank(properties.getPlatformPublicKey())) {
            throw new BusinessException(ErrorCode.CONFLICT, "Huifu merchant capability configuration is incomplete");
        }
    }
    private void initializeSdk() throws Exception {
        BasePay.debug = false;
        BasePay.prodMode = "prod".equalsIgnoreCase(properties.getEnvironment()) ? BasePay.MODE_PROD : BasePay.MODE_TEST;
        MerConfig config = new MerConfig();
        config.setSysId(properties.getSystemId().trim());
        config.setProductId(properties.getProductId().trim());
        config.setRsaPrivateKey(properties.getPrivateKey().trim());
        config.setRsaPublicKey(properties.getPlatformPublicKey().trim());
        BasePay.initWithMerConfig(config);
    }
    private boolean blank(String value) { return value == null || value.isBlank(); }
    private boolean unknown(Exception exception) {
        String value = safe(exception.getMessage()).toLowerCase();
        return value.contains("timeout") || value.contains("timed out") || value.contains("no response");
    }
    private String safe(String value) { return value == null ? "" : value.substring(0, Math.min(value.length(), 300)); }
    /** Provider answer for one sub-merchant configuration query. */
    public record CapabilityResult(
            boolean succeeded,
            String code,
            String message,
            String merchantStatus,
            String settleStatus,
            String settleCycle,
            String cashSwitchStatus,
            String cashType
    ) {
        public static CapabilityResult failed(String code, String message) {
            return new CapabilityResult(false, code == null ? "" : code, message == null ? "" : message, "", "", "", "", "");
        }
        @SuppressWarnings("unchecked")
        public static CapabilityResult from(Map<String, Object> response) {
            Object raw = response == null ? null : response.get("data");
            Map<String, Object> data = raw instanceof Map<?, ?> value ? (Map<String, Object>) value
                    : raw instanceof String json && !json.isBlank() ? JSON.parseObject(json, Map.class)
                    : response;
            if (data == null) return failed("", "");
            String code = value(data, "sub_resp_code", "resp_code", "code");
            String message = value(data, "sub_resp_desc", "resp_desc", "message");
            if (!providerSuccess(code)) return failed(code, message);
            String settleStatus = "", settleCycle = "";
            for (Map<String, Object> settle : list(data.get("qry_settle_config_list"))) {
                settleStatus = value(settle, "settle_status");
                settleCycle = value(settle, "settle_cycle");
                if (!settleStatus.isBlank()) break;
            }
            String cashSwitch = "", cashType = "";
            for (Map<String, Object> cash : list(data.get("qry_cash_config_list"))) {
                cashSwitch = value(cash, "switch_state");
                cashType = value(cash, "cash_type");
                if (!cashSwitch.isBlank()) break;
            }
            return new CapabilityResult(true, code, message, value(data, "mer_stat"), settleStatus, settleCycle, cashSwitch, cashType);
        }
        @SuppressWarnings("unchecked")
        private static List<Map<String, Object>> list(Object raw) {
            if (raw instanceof List<?> values) return values.stream().filter(Map.class::isInstance).map(value -> (Map<String, Object>) value).toList();
            if (raw instanceof Map<?, ?> value) return List.of((Map<String, Object>) value);
            if (raw instanceof String json && !json.isBlank()) {
                try {
                    Object parsed = JSON.parse(json);
                    if (parsed instanceof Map<?, ?> value) return List.of((Map<String, Object>) value);
                    if (parsed instanceof List<?> values) return values.stream().filter(Map.class::isInstance).map(value -> (Map<String, Object>) value).toList();
                } catch (Exception ignored) {
                    // An unparsable configuration list is treated as "not configured".
                }
            }
            return List.of();
        }
        private static boolean providerSuccess(String code) {
            return "00000000".equals(code) || "00000100".equals(code) || "10000".equals(code) || "0".equals(code) || "SUCCESS".equalsIgnoreCase(code);
        }
        private static String value(Map<String, Object> data, String... keys) {
            for (String key : keys) if (data.get(key) != null) return String.valueOf(data.get(key)).trim();
            return "";
        }
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantCapabilityQueryCommand.java
New file
@@ -0,0 +1,3 @@
package com.talentcard.server.business.payment.huifu;
public record HuifuMerchantCapabilityQueryCommand(String requestNo, String huifuId) {}
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPaymentProperties.java
@@ -27,6 +27,7 @@
    private String merchantAgreementUrl = "https://cloudpnrcdn.oss-cn-shanghai.aliyuncs.com/opps/api/prod/download_file/PaymentServiceAgreement.htm";
    private String withdrawalIntoAccountDateType = "DM";
    private boolean delayAccount;
    private Boolean merchantCapabilityRequired;
    public boolean isEnabled() { return enabled; }
    public void setEnabled(boolean enabled) { this.enabled = enabled; }
@@ -87,6 +88,11 @@
    public void setWithdrawalIntoAccountDateType(String withdrawalIntoAccountDateType) { this.withdrawalIntoAccountDateType = withdrawalIntoAccountDateType; }
    public boolean isDelayAccount() { return delayAccount; }
    public void setDelayAccount(boolean delayAccount) { this.delayAccount = delayAccount; }
    /** Provider-side capability confirmation is on whenever the provider integration is enabled. */
    public boolean isMerchantCapabilityRequired() {
        return merchantCapabilityRequired != null ? merchantCapabilityRequired : enabled;
    }
    public void setMerchantCapabilityRequired(boolean merchantCapabilityRequired) { this.merchantCapabilityRequired = merchantCapabilityRequired; }
    private String callbackUrl(String endpoint) {
        if (withdrawalNotifyUrl == null || withdrawalNotifyUrl.isBlank()) return "";
talent-card-server/src/main/java/com/talentcard/server/business/portal/AppOrderPortalService.java
@@ -12,6 +12,7 @@
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.MoneyUtils;
import com.talentcard.server.business.merchant.MerchantPaymentReadinessService;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.dao.DuplicateKeyException;
@@ -23,10 +24,12 @@
public class AppOrderPortalService {
    private final JdbcTemplate jdbc;
    private final PendingOrderPortalService pendingOrderPortalService;
    private final MerchantPaymentReadinessService merchantPaymentReadinessService;
    public AppOrderPortalService(JdbcTemplate jdbc, PendingOrderPortalService pendingOrderPortalService) {
    public AppOrderPortalService(JdbcTemplate jdbc, PendingOrderPortalService pendingOrderPortalService, MerchantPaymentReadinessService merchantPaymentReadinessService) {
        this.jdbc = jdbc;
        this.pendingOrderPortalService = pendingOrderPortalService;
        this.merchantPaymentReadinessService = merchantPaymentReadinessService;
    }
    @Transactional
@@ -52,6 +55,8 @@
        long payAmount = Math.max(0, basePayAmount - couponAmount);
        // 订单补贴金额需与待支付页保持一致:人才折扣与优惠券抵扣的合计。
        long subsidyAmount = Math.max(0, originalAmount - payAmount);
        // 需要支付的订单只有在商户收款能力就绪时才能创建,避免产生无法结算的资金。
        if (payAmount > 0) merchantPaymentReadinessService.requirePayable(request.merchantId(), null);
        String orderNo = BusinessIdGenerator.next("P");
        LocalDateTime expiresAt = payAmount == 0 ? null : LocalDateTime.now().plusMinutes(15);
talent-card-server/src/main/resources/application.yml
@@ -76,6 +76,13 @@
      merchant-online-media-name: ${HUIFU_MERCHANT_ONLINE_MEDIA_NAME:东区人才卡}
      merchant-agreement-url: ${HUIFU_MERCHANT_AGREEMENT_URL:https://cloudpnrcdn.oss-cn-shanghai.aliyuncs.com/opps/api/prod/download_file/PaymentServiceAgreement.htm}
      delay-account: ${HUIFU_DELAY_ACCOUNT:false}
      # A merchant may collect payments only after the provider confirms auto settlement is off and encashment is available.
      # Set false only for a deployment that deliberately runs without provider-side capability confirmation.
      merchant-capability-required: ${HUIFU_MERCHANT_CAPABILITY_REQUIRED:true}
      capability-refresh:
        enabled: ${HUIFU_CAPABILITY_REFRESH_ENABLED:true}
        stale-hours: ${HUIFU_CAPABILITY_STALE_HOURS:6}
        fixed-delay-ms: ${HUIFU_CAPABILITY_REFRESH_FIXED_DELAY_MS:1800000}
  wechat:
    mini-program:
      # The app id must be the same mini-program that invokes uni.login and uni.requestPayment.
talent-card-server/src/main/resources/db/migration/V130__merchant_payment_capability.sql
New file
@@ -0,0 +1,24 @@
-- Persist the Huifu sub-merchant capability snapshot that decides whether a merchant may collect payments.
DELIMITER $$
DROP PROCEDURE IF EXISTS add_merchant_onboarding_capability_columns$$
CREATE PROCEDURE add_merchant_onboarding_capability_columns()
BEGIN
    DECLARE col_exists INT DEFAULT 0;
    SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'merchant_stat';
    IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN merchant_stat VARCHAR(32) DEFAULT NULL AFTER business_open_response_message; END IF;
    SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'auto_settle_status';
    IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN auto_settle_status CHAR(1) DEFAULT NULL AFTER merchant_stat; END IF;
    SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'cash_switch_status';
    IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN cash_switch_status CHAR(1) DEFAULT NULL AFTER auto_settle_status; END IF;
    SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'cash_type';
    IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN cash_type VARCHAR(8) DEFAULT NULL AFTER cash_switch_status; END IF;
    SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'capability_response_code';
    IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN capability_response_code VARCHAR(32) DEFAULT NULL AFTER cash_type; END IF;
    SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'capability_response_message';
    IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN capability_response_message VARCHAR(512) DEFAULT NULL AFTER capability_response_code; END IF;
    SELECT COUNT(*) INTO col_exists FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'merchant_onboarding_application' AND COLUMN_NAME = 'capability_verified_at';
    IF col_exists = 0 THEN ALTER TABLE merchant_onboarding_application ADD COLUMN capability_verified_at DATETIME DEFAULT NULL AFTER capability_response_message; END IF;
END$$
CALL add_merchant_onboarding_capability_columns()$$
DROP PROCEDURE IF EXISTS add_merchant_onboarding_capability_columns$$
DELIMITER ;
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantPaymentReadinessServiceTests.java
New file
@@ -0,0 +1,188 @@
package com.talentcard.server.business.merchant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Map;
import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityGateway;
import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityQueryCommand;
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
import com.talentcard.server.common.BusinessException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
/**
 * Verifies the merchant payment readiness rules with the provider confirmation switched on and a
 * stubbed Huifu capability query.
 */
@SpringBootTest
@ActiveProfiles("test")
@TestPropertySource(properties = {
        "app.payment.huifu.enabled=true",
        "app.payment.huifu.merchant-capability-required=true"
})
class MerchantPaymentReadinessServiceTests {
    @Autowired
    private JdbcTemplate jdbc;
    @Autowired
    private MerchantPaymentReadinessService readinessService;
    @MockitoBean
    private HuifuMerchantCapabilityGateway capabilityGateway;
    private Long merchantId;
    private Long storeId;
    private Long applicationId;
    private Long bankCardId;
    private String suffix;
    @BeforeEach
    void prepareMerchant() {
        suffix = String.valueOf(System.nanoTime());
        jdbc.update("insert into merchant(merchant_name, status, deleted) values (?, 1, 0)", "Readiness merchant " + suffix);
        merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
        jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", merchantId, "Readiness store " + suffix);
        storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class);
        jdbc.update("""
                insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name,
                                                            contact_name, contact_mobile, idempotency_key, status, provider_huifu_id)
                values (?, ?, ?, 'individual', 'Readiness owner', 'Readiness owner', '13800000000', ?, 'approved', ?)
                """, merchantId, storeId, "MO-RDY-" + suffix, "readiness-onboarding-" + suffix, shortReference("H", suffix));
        applicationId = jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class);
        jdbc.update("""
                insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail, card_masked,
                                               card_hash, status, idempotency_key, provider_token_no)
                values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '6666', '**** **** **** 6666', ?, 'approved', ?, ?)
                """, merchantId, storeId, "hash-readiness-" + suffix, "readiness-card-" + suffix, shortReference("T", suffix));
        bankCardId = jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class);
        jdbc.update("update merchant_onboarding_application set initial_bank_card_id=? where id=?", bankCardId, applicationId);
    }
    @Test
    void allowsPaymentOnceAutoSettlementIsOffAndEncashmentIsConfirmed() {
        stubProvider("NORMAL", "0", "1");
        MerchantPaymentReadinessService.Readiness readiness = readinessService.readiness(merchantId, storeId);
        assertThat(readiness.ready()).isTrue();
        assertThat(readiness.reason()).isEmpty();
        assertThat(jdbc.queryForMap("select auto_settle_status as autoSettle, cash_switch_status as cashSwitch, cash_type as cashType, merchant_stat as merchantStat, capability_verified_at as verifiedAt from merchant_onboarding_application where id=?", applicationId))
                .containsEntry("AUTOSETTLE", "0")
                .containsEntry("CASHSWITCH", "1")
                .containsEntry("CASHTYPE", "DM");
        assertThat(jdbc.queryForObject("select capability_verified_at from merchant_onboarding_application where id=?", Object.class, applicationId)).isNotNull();
    }
    @Test
    void blocksPaymentWhileAutoSettlementIsStillOn() {
        stubProvider("NORMAL", "1", "1");
        assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("自动结算");
    }
    @Test
    void blocksPaymentWhenEncashmentCapabilityIsUnavailable() {
        stubProvider("NORMAL", "0", "0");
        assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("取现");
    }
    @Test
    void blocksPaymentWhenTheSubMerchantIsFrozen() {
        stubProvider("FREEZEWITHDRAWAL", "0", "1");
        assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("FREEZEWITHDRAWAL");
    }
    @Test
    void blocksPaymentWhenTheProviderRejectsTheCapabilityQuery() {
        when(capabilityGateway.query(any(HuifuMerchantCapabilityQueryCommand.class)))
                .thenReturn(HuifuMerchantCapabilityGateway.CapabilityResult.failed("99999999", "商户不存在"));
        assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("待确认");
    }
    @Test
    void blocksPaymentWhenTheProviderQueryTimesOut() {
        when(capabilityGateway.query(any(HuifuMerchantCapabilityQueryCommand.class)))
                .thenThrow(new HuifuPaymentGatewayException("timeout", true, null));
        assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId))
                .isInstanceOf(BusinessException.class);
    }
    @Test
    void blocksPaymentWhenOnboardingIsNotApproved() {
        jdbc.update("update merchant_onboarding_application set status='pending' where id=?", applicationId);
        assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("进件");
        verify(capabilityGateway, never()).query(any(HuifuMerchantCapabilityQueryCommand.class));
    }
    @Test
    void blocksPaymentWhenTheSettlementCardHasNoToken() {
        jdbc.update("update merchant_bank_card set provider_token_no=null where id=?", bankCardId);
        assertThatThrownBy(() -> readinessService.requirePayable(merchantId, storeId))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("银行卡");
        verify(capabilityGateway, never()).query(any(HuifuMerchantCapabilityQueryCommand.class));
    }
    @Test
    void reusesTheConfirmedSnapshotInsteadOfQueryingTheProviderAgain() {
        stubProvider("NORMAL", "0", "1");
        readinessService.requirePayable(merchantId, storeId);
        readinessService.requirePayable(merchantId, storeId);
        verify(capabilityGateway, times(1)).query(any(HuifuMerchantCapabilityQueryCommand.class));
    }
    @Test
    void refreshesTheSnapshotOnDemandForTheAdminView() {
        stubProvider("NORMAL", "1", "1");
        Map<String, Object> capability = readinessService.capability(applicationId);
        assertThat(capability).containsEntry("autoSettleDisabled", false).containsEntry("cashAvailable", true);
        stubProvider("NORMAL", "0", "1");
        Map<String, Object> refreshed = readinessService.refresh(applicationId);
        assertThat(refreshed).containsEntry("autoSettleDisabled", true).containsEntry("cashAvailable", true);
    }
    /** Provider identifiers are limited to 32 characters and huifu ids to 18, so tests keep them short. */
    private String shortReference(String prefix, String suffix) {
        return prefix + suffix.substring(Math.max(0, suffix.length() - 8));
    }
    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"));
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantSettlementCallbackIdempotencyTests.java
New file
@@ -0,0 +1,158 @@
package com.talentcard.server.business.merchant;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
/** Duplicated and out-of-order Huifu callbacks must not corrupt an already decided onboarding. */
@SpringBootTest
@ActiveProfiles("test")
class MerchantSettlementCallbackIdempotencyTests {
    @Autowired
    private JdbcTemplate jdbc;
    @Autowired
    private MerchantSettlementService settlementService;
    private Long merchantId;
    private Long storeId;
    private Long applicationId;
    private Long bankCardId;
    private String applicationNo;
    private String suffix;
    @BeforeEach
    void prepareOnboarding() {
        suffix = String.valueOf(System.nanoTime());
        applicationNo = "MO-CB-" + suffix;
        jdbc.update("insert into merchant(merchant_name, status, deleted) values (?, 1, 0)", "Callback merchant " + suffix);
        merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
        jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", merchantId, "Callback store " + suffix);
        storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class);
        jdbc.update("""
                insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name,
                                                            contact_name, contact_mobile, idempotency_key, status)
                values (?, ?, ?, 'individual', 'Callback owner', 'Callback owner', '13700000000', ?, 'pending')
                """, merchantId, storeId, applicationNo, "callback-onboarding-" + suffix);
        applicationId = jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class);
        jdbc.update("""
                insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail, card_masked,
                                               card_hash, status, idempotency_key, provider_request_no)
                values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '1234', '**** **** **** 1234', ?, 'pending', ?, ?)
                """, merchantId, storeId, "hash-callback-" + suffix, "callback-card-" + suffix, "BC-" + suffix);
        bankCardId = jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class);
        jdbc.update("update merchant_onboarding_application set initial_bank_card_id=? where id=?", bankCardId, applicationId);
    }
    @Test
    void repeatedAuditCallbacksKeepTheSameOnboardingState() {
        Map<String, Object> callback = new HashMap<>();
        callback.put("req_seq_id", applicationNo);
        callback.put("audit_status", "N");
        callback.put("sub_resp_code", "10000001");
        callback.put("audit_desc", "证照不清晰");
        assertThat(settlementService.handleOnboardingCallback(new HashMap<>(callback))).isTrue();
        Map<String, Object> first = onboardingState();
        assertThat(first).containsEntry("status", "rejected");
        assertThat(first.get("rejectReason")).isNotNull();
        // The provider retries the identical notification before the late approval arrives.
        assertThat(settlementService.handleOnboardingCallback(new HashMap<>(callback))).isTrue();
        assertThat(onboardingState()).isEqualTo(first);
    }
    @Test
    void aLateNotificationCannotOverwriteADecidedOnboarding() {
        jdbc.update("update merchant_onboarding_application set status='approved', provider_huifu_id='6666000000000001', reject_reason='' where id=?", applicationId);
        Map<String, Object> lateRejection = new HashMap<>();
        lateRejection.put("req_seq_id", applicationNo);
        lateRejection.put("audit_status", "N");
        lateRejection.put("sub_resp_code", "10000001");
        lateRejection.put("audit_desc", "证照不清晰");
        assertThat(settlementService.handleOnboardingCallback(lateRejection)).isTrue();
        assertThat(onboardingState())
                .containsEntry("status", "approved")
                .containsEntry("providerHuifuId", "6666000000000001")
                .containsEntry("rejectReason", "");
    }
    @Test
    void anApprovalCallbackBindsTheTokenOnlyOnce() {
        Map<String, Object> approval = new HashMap<>();
        approval.put("req_seq_id", applicationNo);
        approval.put("audit_status", "Y");
        approval.put("sub_resp_code", "00000000");
        approval.put("resp_desc", "成功");
        approval.put("huifu_id", "6666000000000002");
        approval.put("apply_no", "APPLY-0001");
        approval.put("token_no", "TOKEN-0001");
        assertThat(settlementService.handleOnboardingCallback(approval)).isTrue();
        assertThat(onboardingState())
                .containsEntry("providerHuifuId", "6666000000000002")
                .containsEntry("status", "pending");
        assertThat(cardState()).containsEntry("status", "approved").containsEntry("providerTokenNo", "TOKEN-0001");
        // A duplicated approval carrying a different token must not replace the stored token.
        Map<String, Object> repeated = new HashMap<>(approval);
        repeated.put("token_no", "TOKEN-9999");
        assertThat(settlementService.handleOnboardingCallback(repeated)).isTrue();
        assertThat(cardState()).containsEntry("status", "approved").containsEntry("providerTokenNo", "TOKEN-0001");
    }
    @Test
    void cardBindingCallbackOnlyMovesAPendingCardOnce() {
        String requestNo = jdbc.queryForObject("select provider_request_no from merchant_bank_card where id=?", String.class, bankCardId);
        Map<String, Object> callback = new HashMap<>();
        callback.put("req_seq_id", requestNo);
        callback.put("sub_resp_code", "00000000");
        callback.put("sub_resp_desc", "成功");
        callback.put("token_no", "TOKEN-BIND-1");
        assertThat(settlementService.handleBankCardCallback(callback)).isTrue();
        assertThat(cardState()).containsEntry("status", "approved").containsEntry("providerTokenNo", "TOKEN-BIND-1");
        Map<String, Object> rejectedAfterApproval = new HashMap<>();
        rejectedAfterApproval.put("req_seq_id", requestNo);
        rejectedAfterApproval.put("sub_resp_code", "10000002");
        rejectedAfterApproval.put("sub_resp_desc", "绑卡失败");
        assertThat(settlementService.handleBankCardCallback(rejectedAfterApproval)).isTrue();
        assertThat(cardState()).containsEntry("status", "approved").containsEntry("providerTokenNo", "TOKEN-BIND-1");
    }
    @Test
    void unknownCallbackReferencesAreIgnored() {
        Map<String, Object> unknown = new HashMap<>();
        unknown.put("req_seq_id", "MO-UNKNOWN-" + suffix);
        unknown.put("audit_status", "Y");
        assertThat(settlementService.handleOnboardingCallback(unknown)).isFalse();
        assertThat(settlementService.handleBankCardCallback(Map.of("req_seq_id", "BC-UNKNOWN-" + suffix))).isFalse();
    }
    private Map<String, Object> onboardingState() {
        return jdbc.queryForMap("""
                select status as status, provider_huifu_id as providerHuifuId, provider_apply_no as providerApplyNo,
                       reject_reason as rejectReason
                from merchant_onboarding_application where id = ?
                """, applicationId);
    }
    private Map<String, Object> cardState() {
        return jdbc.queryForMap("select status as status, provider_token_no as providerTokenNo from merchant_bank_card where id = ?", bankCardId);
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/portal/AppOrderPortalServiceTests.java
@@ -1,9 +1,11 @@
package com.talentcard.server.business.portal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Map;
import com.talentcard.server.common.BusinessException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -24,6 +26,7 @@
    private Long merchantId;
    private Long productId;
    private Long couponId;
    private Long onboardingId;
    @BeforeEach
    void prepareData() {
@@ -33,6 +36,8 @@
        jdbc.update("insert into talent_card(talent_id, card_no, card_type, card_level, status, deleted) values (?, ?, 'PERSONAL', ?, 1, 0)", talentId, "TC-APP-" + suffix, "卓越人才卡");
        jdbc.update("insert into merchant(merchant_name, contact_mobile, address, status, deleted) values (?, ?, ?, 1, 0)", "App order merchant " + suffix, "08120000000", "Test address");
        merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class);
        // A merchant may only take paid orders once onboarding is approved and settlement capabilities are confirmed.
        onboardingId = readyMerchant(merchantId, "app-order-" + suffix);
        jdbc.update("""
                insert into merchant_product(product_no, merchant_id, product_type, product_name, original_price, sale_price,
                                             subsidy_amount, stock, audit_status, status, deleted)
@@ -44,6 +49,32 @@
                values (?, 'Test coupon', 'NONE', 10.00, 'ALL', 1, 0)
                """, merchantId);
        couponId = jdbc.queryForObject("select max(id) from merchant_coupon", Long.class);
    }
    /** Creates a store, an approved onboarding and an approved settlement card for the merchant. */
    private Long readyMerchant(Long merchant, String suffix) {
        jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", merchant, "App order store " + suffix);
        Long storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class);
        jdbc.update("""
                insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name,
                                                            contact_name, contact_mobile, idempotency_key, status, provider_huifu_id,
                                                            auto_settle_status, cash_switch_status, merchant_stat, capability_verified_at)
                values (?, ?, ?, 'individual', 'App order owner', 'App order owner', '13900000000', ?, 'approved', ?, '0', '1', 'NORMAL', current_timestamp)
                """, merchant, storeId, "MO-APP-" + suffix, "app-order-onboarding-" + suffix, shortReference("H", suffix));
        Long applicationId = jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class);
        jdbc.update("""
                insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail, card_masked,
                                               card_hash, status, idempotency_key, provider_token_no, reviewed_at)
                values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '8888', '**** **** **** 8888', ?, 'approved', ?, ?, current_timestamp)
                """, merchant, storeId, "hash-app-order-" + suffix, "app-order-card-" + suffix, shortReference("T", suffix));
        Long cardId = jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class);
        jdbc.update("update merchant_onboarding_application set initial_bank_card_id=? where id=?", cardId, applicationId);
        return applicationId;
    }
    /** Provider identifiers are limited to 32 characters, so tests keep them short. */
    private String shortReference(String prefix, String suffix) {
        return prefix + suffix.substring(Math.max(0, suffix.length() - 8));
    }
    @Test
@@ -83,7 +114,7 @@
                talentId,
                new AppOrderCreateRequest(merchantId, productId, couponId),
                "app-order-expired-first-" + System.nanoTime());
        jdbc.update("update benefit_order set expires_at = dateadd('MINUTE', -1, current_timestamp) where order_no = ?", expired.get("orderNo"));
        jdbc.update("update benefit_order set expires_at = current_timestamp - interval '1' minute where order_no = ?", expired.get("orderNo"));
        Map<String, Object> replacement = appOrderPortalService.create(
                talentId,
@@ -96,6 +127,26 @@
    }
    @Test
    void refusesPaidOrderWhileMerchantSettlementCapabilityIsMissing() {
        jdbc.update("update merchant_onboarding_application set auto_settle_status='1' where id=?", onboardingId);
        assertThatThrownBy(() -> appOrderPortalService.create(talentId, new AppOrderCreateRequest(merchantId, productId, null), "app-order-blocked-" + System.nanoTime()))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("自动结算");
        assertThat(jdbc.queryForObject("select count(*) from benefit_order where merchant_id=?", Long.class, merchantId)).isZero();
    }
    @Test
    void refusesPaidOrderWhenMerchantOnboardingIsNotApproved() {
        jdbc.update("update merchant_onboarding_application set status='pending', capability_verified_at=null where id=?", onboardingId);
        jdbc.update("update merchant_bank_card set status='pending', provider_token_no=null where merchant_id=?", merchantId);
        assertThatThrownBy(() -> appOrderPortalService.create(talentId, new AppOrderCreateRequest(merchantId, productId, null), "app-order-pending-" + System.nanoTime()))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("进件");
    }
    @Test
    void usesAvailableFreeCountBeforeChargingTheProductPrice() {
        jdbc.update("""
                update merchant_product
talent-card-server/src/test/resources/application-test.yml
@@ -1,11 +1,21 @@
spring:
  datasource:
    url: jdbc:h2:mem:talent_card;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
    username: sa
    password:
    driver-class-name: org.h2.Driver
    # Defaults to in-memory H2. CI or a developer with MySQL can point TEST_DATASOURCE_* at a real
    # MySQL schema: several migrations use MySQL-only syntax (for example V77) that H2 cannot parse.
    url: ${TEST_DATASOURCE_URL:jdbc:h2:mem:talent_card;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE}
    username: ${TEST_DATASOURCE_USERNAME:sa}
    password: ${TEST_DATASOURCE_PASSWORD:}
    driver-class-name: ${TEST_DATASOURCE_DRIVER:org.h2.Driver}
  flyway:
    enabled: true
  jpa:
    hibernate:
      ddl-auto: validate
app:
  payment:
    huifu:
      # The provider integration stays off in tests: no outbound Huifu calls, and readiness only
      # enforces the local onboarding and bank-card preconditions.
      enabled: false
      merchant-capability-required: false