fix(merchant): 通道侧配置失败的进件允许重新提交,不再永久卡在 pending

商户进件在通道侧被明确拒绝后,会永远停在 pending,而且**连重新提交的机会都没有**:
- 进件页 onShow 见到 pending/approved 就 redirectTo 到"审核中"页,商户进不到表单;
- submitOnboarding 按门店查到 active 进件就直接返回旧记录,不再向通道提交;
- 银行卡校验会拦"该银行卡已有审核中的进件",同一张卡也用不了。

本地开发没有公网 IP、收不到汇付异步通知时尤其容易触发:这次个人进件(application 450)
微信小程序支付配置返回 20002,wechat_config_status=failed,记录就停在 pending 出不来了。

改动:
- retryable(row):微信小程序支付配置或商户业务开通任一为 failed,即视为"通道侧已明确失败、
可以重新提交"。进行中的申请(not_started/processing)不受影响。
- submitOnboarding:active 进件为 retryable 时不再直接返回旧记录;提交前把旧进件收口成
rejected,从而解开按门店查 active 的判断,以及银行卡"已有审核中/已通过进件"的占用,
商户可以用同一张卡重新提交。
- onboarding() 响应增加 retryable 字段;settlement.uts 的 SettlementOnboarding 同步。
- 进件页与等待页:retryable 的 pending/approved 不再跳转/停留,回到表单让商户重填。

新增 MerchantOnboardingRetryTests 4 项(两种 failed 可重试、进行中不可重试、缺失状态按不可重试),
连同支付相关测试共 34 项通过。
1个文件已添加
4个文件已修改
108 ■■■■■ 已修改文件
rencaika_merchant/pages/merchant-onboarding-pending/merchant-onboarding-pending.uvue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/settlement.uts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantOnboardingRetryTests.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/merchant-onboarding-pending/merchant-onboarding-pending.uvue
@@ -46,6 +46,11 @@
                uni.redirectTo({ url: '/pages/merchant-onboarding/merchant-onboarding' })
                return
            }
            // 通道侧配置已明确失败的进件应该回到表单重填,而不是停在这里显示"审核中"。
            if (item.retryable) {
                uni.redirectTo({ url: '/pages/merchant-onboarding/merchant-onboarding' })
                return
            }
            onboardingStatus.value = item.status
            // 只清当前账号自己的草稿:草稿 key 按操作员分命名空间,不能误删别的账号的。
            if (item.status == 'approved') uni.removeStorageSync(getMerchantOnboardingDraftStorageKey())
rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue
@@ -270,7 +270,9 @@
    onShow(() => {
        if (!draftRestored) { draftRestored = true; restoreDraft() }
        loadOnboardingStatus((item: SettlementOnboarding) => {
            if (item.status == 'pending' || item.status == 'approved') {
            // retryable:通道侧配置已明确失败(例如微信支付配置被拒),这条进件不会自己再往前走,
            // 停在页面上让商户重新提交,而不是跳去"审核中"把人挡在门外。
            if ((item.status == 'pending' || item.status == 'approved') && !item.retryable) {
                uni.redirectTo({ url: '/pages/merchant-onboarding-pending/merchant-onboarding-pending' })
            }
        }, (_error: any) => {})
rencaika_merchant/services/settlement.uts
@@ -1,13 +1,14 @@
import { getApiEndpoint } from '../config/api.uts'
import { HttpError, request } from './http.uts'
export type SettlementOnboarding = { id: string, applicationNo: string, status: string, reason: string, submittedAt: string }
export type SettlementOnboarding = { id: string, applicationNo: string, status: string, reason: string, retryable: boolean, submittedAt: string }
export type SettlementBankCard = { id: string, bankName: string, branchName: string, cardMasked: string, status: string, reason: string, createdAt: string }
export type SettlementPage<T> = { records: Array<T>, page: number, size: number, total: number }
const stringValue = (value: any): string => value == null ? '' : '' + value
const numberValue = (value: any): number => value == null ? 0 : Number(value)
const onboarding = (value: any): SettlementOnboarding => ({ id: stringValue(value != null ? value.id : ''), applicationNo: stringValue(value != null ? value.applicationNo : ''), status: stringValue(value != null ? value.status : 'unsubmitted'), reason: stringValue(value != null ? value.reason : ''), submittedAt: stringValue(value != null ? value.submittedAt : '') })
/** retryable:通道侧配置已明确失败,商户可以重新提交;缺省按 false,宁可保守。 */
const onboarding = (value: any): SettlementOnboarding => ({ id: stringValue(value != null ? value.id : ''), applicationNo: stringValue(value != null ? value.applicationNo : ''), status: stringValue(value != null ? value.status : 'unsubmitted'), reason: stringValue(value != null ? value.reason : ''), retryable: value != null && value.retryable == true, submittedAt: stringValue(value != null ? value.submittedAt : '') })
const bankCard = (value: any): SettlementBankCard => ({ id: stringValue(value != null ? value.id : ''), bankName: stringValue(value != null ? value.bankName : ''), branchName: stringValue(value != null ? value.branchName : ''), cardMasked: stringValue(value != null ? value.cardMasked : ''), status: stringValue(value != null ? value.status : 'pending'), reason: stringValue(value != null ? value.reason : ''), createdAt: stringValue(value != null ? value.createdAt : '') })
export const loadOnboardingStatus = (success: (value: SettlementOnboarding) => void, error: (value: HttpError) => void): (() => void) => request<any>({ path: getApiEndpoint('merchantOnboardingStatus') }, (data: any) => success(onboarding(data)), error)
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -80,7 +80,7 @@
        List<Map<String, Object>> repeated = rows("select * from merchant_onboarding_application where merchant_id = ? and idempotency_key = ? limit 1", principal.merchantId(), key.trim());
        if (!repeated.isEmpty()) return onboarding(repeated.get(0));
        List<Map<String, Object>> active = rows("select * from merchant_onboarding_application where merchant_id = ? and store_id = ? and initial_bank_card_id is not null and status in ('pending','approved') order by id desc limit 1", principal.merchantId(), principal.storeId());
        if (!active.isEmpty()) return onboarding(active.get(0));
        if (!active.isEmpty() && !retryable(active.get(0))) return onboarding(active.get(0));
        boolean enterprise = "enterprise".equals(request.subjectType());
        if (enterprise && (request.creditCode() == null || !request.creditCode().trim().matches("[0-9A-Z]{18}"))) throw new BusinessException(ErrorCode.BAD_REQUEST, "请输入正确的18位统一社会信用代码");
        if (request.subjectIdNumber() == null || !request.subjectIdNumber().trim().matches("(?:\\d{15}|\\d{17}[0-9X])")) throw new BusinessException(ErrorCode.BAD_REQUEST, enterprise ? "请输入正确的法人身份证号" : "请输入正确的负责人身份证号");
@@ -102,6 +102,11 @@
        if (branchRows.isEmpty()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择真实开户行");
        Map<String, Object> branch = branchRows.get(0);
        if (clean(branch.get("headBankCode")).isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "所选开户行缺少汇付银行编码");
        // 上一次进件在通道侧已经明确失败(典型:本地开发没有公网 IP,收不到回调,商户配置报权限错),
        // 这条记录会永远停在 pending。此时必须让商户能用同一张卡重新提交:
        // 把旧进件标成 rejected,既解开下面「银行卡已有审核中/已通过进件」的占用,
        // 也让按 store 查 active 进件的逻辑不再把它当成进行中的申请。
        closeRetryableApplication(principal);
        String no = BusinessIdGenerator.next("MO");
        String certificateValidityType = certificateValidityType(request.subjectCertificateValidityType());
        String certificateBeginDate = certificateBeginDate(request.subjectCertificateBeginDate());
@@ -529,7 +534,38 @@
    private String provisioningRequestNo(String applicationNo, String suffix) { String value = applicationNo + suffix; return value.length() <= 32 ? value : value.substring(0, 32); }
    private void requireKey(String key) { if (key == null || key.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键"); }
    private void store(MerchantPrincipal principal) { if (rows("select id from merchant_store where id = ? and merchant_id = ? and status = 1 and deleted = 0", principal.storeId(), principal.merchantId()).isEmpty()) throw new BusinessException(ErrorCode.FORBIDDEN, "当前门店不可操作"); }
    private Map<String, Object> onboarding(Map<String, Object> row) { return Map.of("id", String.valueOf(row.get("id")), "applicationNo", row.get("applicationNo"), "status", row.get("status"), "subjectType", clean(row.get("subjectType")), "reason", clean(row.get("rejectReason")), "submittedAt", String.valueOf(row.get("createdAt"))); }
    private Map<String, Object> onboarding(Map<String, Object> row) { return Map.of("id", String.valueOf(row.get("id")), "applicationNo", row.get("applicationNo"), "status", row.get("status"), "subjectType", clean(row.get("subjectType")), "reason", clean(row.get("rejectReason")), "retryable", retryable(row), "submittedAt", String.valueOf(row.get("createdAt"))); }
    /**
     * 这笔进件是不是「通道侧已经明确失败、可以重新提交」。
     *
     * <p>进件成功后还有两步通道侧配置:微信小程序支付配置、商户业务开通。任何一步被通道
     * 明确拒绝(`failed`),后续流程就不会再自己往前走,而记录会一直停在 pending——
     * 本地开发收不到回调时尤其明显。不把它判成可重试,商户就永远卡在自己的 pending 里:
     * 进件页会跳转到"审核中",后端也会直接返回旧记录。
     */
    private boolean retryable(Map<String, Object> row) { return isRetryable(row); }
    /** 取值为静态形式,便于直接断言这条规则本身(见 MerchantOnboardingRetryTests)。 */
    static boolean isRetryable(Map<String, Object> row) {
        return "failed".equals(cleanStatus(row.get("wechatConfigStatus"))) || "failed".equals(cleanStatus(row.get("businessOpenStatus")));
    }
    private static String cleanStatus(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
    /**
     * 商户重新提交时,把上一次「通道侧已明确失败」的进件收口成 rejected。
     *
     * <p>目的是解开两处占用:按门店查 active 进件的判断,以及银行卡「已有审核中/已通过的申请」的校验,
     * 否则商户连同一张卡都不能用。只处理可重试的那一条,真正的进行中申请不受影响。
     */
    private void closeRetryableApplication(MerchantPrincipal principal) {
        List<Map<String, Object>> applications = rows("select * from merchant_onboarding_application where merchant_id = ? and store_id = ? and initial_bank_card_id is not null and status in ('pending','approved') order by id desc limit 1", principal.merchantId(), principal.storeId());
        if (applications.isEmpty()) return;
        Map<String, Object> application = applications.get(0);
        if (!retryable(application)) return;
        jdbc.update("update merchant_onboarding_application set status='rejected', reject_reason=?, reviewed_at=now() where id=?", "通道侧配置失败,商户重新提交进件", application.get("id"));
    }
    private Map<String, Object> bankCard(Map<String, Object> row) { return Map.of("id", String.valueOf(row.get("id")), "bankName", row.get("bankName"), "branchName", row.get("branchName"), "cardMasked", row.get("cardMasked"), "status", row.get("status"), "reason", clean(row.get("rejectReason")), "createdAt", String.valueOf(row.get("createdAt"))); }
    private Map<String, Object> branch(String id, String branchName, String bankName) { return Map.of("id", id, "branchName", branchName, "bankName", bankName); }
    private Object[] appendPaging(List<Object> params, int size, int offset) { Object[] values = new Object[params.size() + 2]; for (int index = 0; index < params.size(); index++) values[index] = params.get(index); values[params.size()] = size; values[params.size() + 1] = offset; return values; }
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantOnboardingRetryTests.java
New file
@@ -0,0 +1,54 @@
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.Test;
/**
 * 「这笔进件能不能重新提交」的判定。
 *
 * <p>进件成功后还有两步通道侧配置:微信小程序支付配置、商户业务开通。任何一步被通道明确拒绝,
 * 后续流程就不会再自己往前走,而本地进件记录会**永远停在 pending**——尤其在没有公网 IP、
 * 收不到汇付异步通知的开发环境。
 *
 * <p>没有这个判定,商户会被自己的 pending 永久卡死:进件页直接跳去"审核中"、
 * 后端 submitOnboarding 也直接返回旧记录,商户连重新填一次的机会都没有。
 * 已经出现过这个真实场景(微信配置返回 20002,记录停在 pending)。
 */
class MerchantOnboardingRetryTests {
    @Test
    void anApplicationWhoseWechatConfigFailedCanBeResubmitted() {
        assertThat(MerchantSettlementService.isRetryable(application("failed", "not_started"))).isTrue();
    }
    @Test
    void anApplicationWhoseBusinessOpenFailedCanBeResubmitted() {
        assertThat(MerchantSettlementService.isRetryable(application("approved", "failed"))).isTrue();
    }
    @Test
    void anApplicationStillMovingThroughProvisioningIsNotRetryable() {
        // 配置还在跑,或压根还没开始:这是"真的在进行中",不能放行重复提交。
        assertThat(MerchantSettlementService.isRetryable(application("not_started", "not_started"))).isFalse();
        assertThat(MerchantSettlementService.isRetryable(application("processing", "not_started"))).isFalse();
        assertThat(MerchantSettlementService.isRetryable(application("approved", "processing"))).isFalse();
        assertThat(MerchantSettlementService.isRetryable(application("approved", "approved"))).isFalse();
    }
    @Test
    void aMissingStatusIsNotRetryable() {
        Map<String, Object> row = new HashMap<>();
        assertThat(MerchantSettlementService.isRetryable(row)).isFalse();
    }
    private static Map<String, Object> application(String wechatConfigStatus, String businessOpenStatus) {
        Map<String, Object> row = new HashMap<>();
        row.put("wechatConfigStatus", wechatConfigStatus);
        row.put("businessOpenStatus", businessOpenStatus);
        return row;
    }
}