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 项通过。
| | |
| | | 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()) |
| | |
| | | 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) => {}) |
| | |
| | | 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) |
| | |
| | | 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 ? "请输入正确的法人身份证号" : "请输入正确的负责人身份证号"); |
| | |
| | | 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()); |
| | |
| | | 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; } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |