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?". * *

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; private final TransactionTemplate providerTransaction; 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); // The provider is an HTTP call: never make payment or onboarding hold a database transaction // (and its row locks) while waiting for it. this.providerTransaction = new TransactionTemplate(refreshTransaction.getTransactionManager()); this.providerTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_NOT_SUPPORTED); } /** 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); } /** * 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 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. */ public Map refresh(Long applicationId) { // The snapshot is only updated inside providerTransaction, which needs no row lock. queryProviderOutsideTransaction(requireApprovedApplication(applicationId)); 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. */ public Map capability(Long applicationId) { Application application = requireApprovedApplication(applicationId); if (application.capabilityVerifiedAt().isBlank()) { queryProviderOutsideTransaction(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 one failing * merchant never affects the others or the snapshots already stored. */ public int refreshStaleCapabilities(int staleHours, int limit) { if (!providerVerificationRequired() || !huifuPaymentProperties.isEnabled()) return 0; List> 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 row : rows) { if (refreshQuietly(((Number) row.get("id")).longValue())) 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 { return Boolean.TRUE.equals(refreshTransaction.execute(status -> { refresh(applicationId); return Boolean.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 (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(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(new CollectingMerchant(application.id(), application.storeId(), application.huifuId())); } /** * Runs the provider query outside any surrounding transaction so a slow Huifu call never holds * the caller's row locks. Returns whether the snapshot was refreshed. */ private boolean queryProviderOutsideTransaction(Application application) { return Boolean.TRUE.equals(providerTransaction.execute(status -> queryProvider(application))); } /** 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> 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> 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 capabilityView(Application application) { Map 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> 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 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> 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. * 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(); } } }