yeg
2026-09-18 a058f6109834e29435f5ce4aa19a43d67d07c060
fix(onboarding): 结算银行卡卡号面从未落库,历史进件重提时补齐

商家端进件表单里的「结算银行卡卡号面」从来没有落过库。写库那条 INSERT
(MerchantSettlementService:184)的列清单里没有 settlement_card_front_url:商户端提交时是带了
这个值的,后端也拿它做个人主体的必填校验、并作为汇付图片 F13 上传,但值在写库时被静默丢弃,
列恒为 NULL。后台进件详情因此永远显示「-」——不是前端没展示,是后端没存。库里 18 条进件该列
全是 NULL。工单见 .scratch/merchant-individual-onboarding/issues/01-settlement-card-front-not-persisted.md。

INSERT 补上该列(列与占位符 35 → 36)。

另外,只补 INSERT 补不了历史记录:分阶段模型下重复提交走的是 supplyMissingStorePhotos 那条
早返回路径(不新建进件、不动汇付商户号与银行卡),压根不会碰到 INSERT。所以把该方法扩成
supplyMissingOnboardingMaterials,空着的卡号面同样在重提时补上——这是已提交进件唯一的补救
入口,否则这一列永远补不回来。已有值不覆盖,与门店照片的处理保持一致。

回归测试 3 条,均先在旧实现下确认变红、且只红对应那一条,再修回绿:新提交落库、重提补齐、
不覆盖已有值。历史数据仍是 NULL,需要商户在进件页重提一次来补。
3个文件已修改
137 ■■■■■ 已修改文件
.scratch/merchant-individual-onboarding/issues/01-settlement-card-front-not-persisted.md 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/merchant-individual-onboarding/issues/01-settlement-card-front-not-persisted.md
@@ -1,6 +1,6 @@
# 商户进件详情:结算银行卡卡号面图片显示为空
Status: ready-for-agent
Status: resolved
领域: 支付链路(汇付进件/开通)
来源: 用户工单「【结算记录】商户进件:上传的银行卡图片没有显示出来」(截图记录 id=454,商户 644 景区服务-卡)
日期: 2026-09-18
@@ -64,3 +64,16 @@
## Comments
- 2026-09-18:由「其余业务」侧的会话只读排查后归档(按仓库约定,支付链路的代码未改动)。待转交支付链路负责人。
- 2026-09-18(支付链路):已按建议改法修复。
  1. `MerchantSettlementService:184` 的 INSERT 补上 `settlement_card_front_url` 列与
     `clean(request.settlementCardFrontUrl())` 参数(列与占位符从 35 增至 36)。
  2. 另有一步是原报告没覆盖的:**历史记录靠重提也补不回来**。分阶段模型下重复提交走的是
     `supplyMissingStorePhotos` 那条早返回路径(不会新建进件),所以把该方法扩充为
     `supplyMissingOnboardingMaterials`,空着的卡号面同样在重提时补上——这是已提交进件唯一的
     补救入口,否则这一列永远补不回来。已有值不覆盖,与原门店照片的处理一致。
  3. 回归测试(`MerchantExistingOnboardingTests`,3 条):
     - `aNewIndividualSubmissionPersistsTheSettlementCardFront` —— 直接断言库里那一列,覆盖 INSERT;
     - `resubmittingSuppliesTheMissingSettlementCardFront` —— 覆盖重提补齐;
     - `resubmittingDoesNotOverwriteAnExistingSettlementCardFront` —— 覆盖"不覆盖已有值"。
     三条都先在旧实现下确认变红(且只红对应的那一条),再修回变绿。
  4. 历史数据仍是 NULL,需要商户在进件页重新提交一次来补(重提不新建进件、不动汇付商户号与银行卡)。
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -144,7 +144,7 @@
        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() && onboardingProgressService.supports(active.get(0))) {
            supplyMissingStorePhotos(active.get(0), request);
            supplyMissingOnboardingMaterials(active.get(0), request);
            return onboarding(active.get(0));
        }
        if (!active.isEmpty() && !retryable(active.get(0))) return onboarding(active.get(0));
@@ -181,7 +181,7 @@
        String no = BusinessIdGenerator.next("MO");
        String certificateValidityType = certificateValidityType(request.subjectCertificateValidityType());
        String certificateBeginDate = certificateBeginDate(request.subjectCertificateBeginDate());
        jdbc.update("insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name, credit_code, license_validity_type, license_begin_date, license_end_date, subject_id_no_masked, subject_id_no_encrypted, subject_cert_validity_type, subject_cert_begin_date, subject_cert_end_date, registered_region, registered_province_code, registered_city_code, registered_district_code, registered_address, merchant_name, merchant_short_name, company_type, business_license_url, established_date, registered_capital, legal_id_front_url, legal_id_back_url, legal_certificate_address, legal_mobile, administrator_email, contact_name, contact_mobile, store_header_url, store_indoor_url, idempotency_key) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", principal.merchantId(), principal.storeId(), no, request.subjectType(), request.subjectName().trim(), clean(request.creditCode()), certificateValidityType(request.licenseValidityType()), certificateBeginDate(request.licenseBeginDate()), certificateEndDate(request.licenseEndDate()), maskId(request.subjectIdNumber()), sensitiveDataCrypto.encrypt(clean(request.subjectIdNumber()).toUpperCase()), certificateValidityType, certificateBeginDate, certificateEndDate(request.subjectCertificateEndDate()), clean(request.registeredRegion()), clean(request.registeredProvinceCode()), clean(request.registeredCityCode()), clean(request.registeredDistrictCode()), clean(request.registeredAddress()), clean(request.merchantName()), clean(request.merchantShortName()), clean(request.companyType()), clean(request.businessLicenseUrl()), clean(request.establishedDate()), clean(request.registeredCapital()), clean(request.legalIdFrontUrl()), clean(request.legalIdBackUrl()), clean(request.legalCertificateAddress()), clean(request.legalMobile()), clean(request.administratorEmail()), request.contactName().trim(), request.contactMobile().trim(), clean(request.storeHeaderUrl()), clean(request.storeIndoorUrl()), key.trim());
        jdbc.update("insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name, credit_code, license_validity_type, license_begin_date, license_end_date, subject_id_no_masked, subject_id_no_encrypted, subject_cert_validity_type, subject_cert_begin_date, subject_cert_end_date, registered_region, registered_province_code, registered_city_code, registered_district_code, registered_address, merchant_name, merchant_short_name, company_type, business_license_url, established_date, registered_capital, legal_id_front_url, legal_id_back_url, settlement_card_front_url, legal_certificate_address, legal_mobile, administrator_email, contact_name, contact_mobile, store_header_url, store_indoor_url, idempotency_key) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", principal.merchantId(), principal.storeId(), no, request.subjectType(), request.subjectName().trim(), clean(request.creditCode()), certificateValidityType(request.licenseValidityType()), certificateBeginDate(request.licenseBeginDate()), certificateEndDate(request.licenseEndDate()), maskId(request.subjectIdNumber()), sensitiveDataCrypto.encrypt(clean(request.subjectIdNumber()).toUpperCase()), certificateValidityType, certificateBeginDate, certificateEndDate(request.subjectCertificateEndDate()), clean(request.registeredRegion()), clean(request.registeredProvinceCode()), clean(request.registeredCityCode()), clean(request.registeredDistrictCode()), clean(request.registeredAddress()), clean(request.merchantName()), clean(request.merchantShortName()), clean(request.companyType()), clean(request.businessLicenseUrl()), clean(request.establishedDate()), clean(request.registeredCapital()), clean(request.legalIdFrontUrl()), clean(request.legalIdBackUrl()), clean(request.settlementCardFrontUrl()), clean(request.legalCertificateAddress()), clean(request.legalMobile()), clean(request.administratorEmail()), request.contactName().trim(), request.contactMobile().trim(), clean(request.storeHeaderUrl()), clean(request.storeIndoorUrl()), key.trim());
        String cardNumber = request.bankCardNumber().trim();
        String cardKey = key.trim() + ":initial";
        if (cardKey.length() > 128) cardKey = cardKey.substring(0, 128);
@@ -209,21 +209,32 @@
    }
    /**
     * 存量个人进件缺门店照片时,允许商户在重新提交里补上。
     * 存量个人进件缺门店照片、或缺结算银行卡卡号面时,允许商户在重新提交里补上。
     *
     * <p>门店照片是个人(小微)主体开通微信支付与发起微信实名的硬材料,而分阶段模型下重复提交
     * **不会新建进件**(也就不会在通道上多出一家商户),商户端因此没有别的补图入口——真实开发库里
     * 已经有拿到汇付商户号的个人进件,就是这个形态。
     *
     * <p>只补空着的那两项,已有值不覆盖;进件、银行卡与通道申请一律不动。
     * <p>结算卡卡号面在这一列补进 INSERT 之前**从来没落过库**(2026-09-18 工单),历史记录全是空值,
     * 库里也没有别的地方能还原它。所以这里一并补上——这是已提交进件唯一的补救入口。
     *
     * <p>只补空着的那几项,已有值不覆盖;进件、银行卡与通道申请一律不动。
     */
    private void supplyMissingStorePhotos(Map<String, Object> application, MerchantOnboardingRequest request) {
        if (request == null || !"individual".equalsIgnoreCase(clean(application.get("subjectType")))) return;
        if (clean(application.get("storeHeaderUrl")).isBlank() && !blank(request.storeHeaderUrl())) {
            jdbc.update("update merchant_onboarding_application set store_header_url=? where id=?", request.storeHeaderUrl().trim(), application.get("id"));
    private void supplyMissingOnboardingMaterials(Map<String, Object> application, MerchantOnboardingRequest request) {
        if (request == null) return;
        Object id = application.get("id");
        // 门店照片只有个人主体才有,企业主体不提交这两项,沿用原来的判定。
        if ("individual".equalsIgnoreCase(clean(application.get("subjectType")))) {
            if (clean(application.get("storeHeaderUrl")).isBlank() && !blank(request.storeHeaderUrl())) {
                jdbc.update("update merchant_onboarding_application set store_header_url=? where id=?", request.storeHeaderUrl().trim(), id);
            }
            if (clean(application.get("storeIndoorUrl")).isBlank() && !blank(request.storeIndoorUrl())) {
                jdbc.update("update merchant_onboarding_application set store_indoor_url=? where id=?", request.storeIndoorUrl().trim(), id);
            }
        }
        if (clean(application.get("storeIndoorUrl")).isBlank() && !blank(request.storeIndoorUrl())) {
            jdbc.update("update merchant_onboarding_application set store_indoor_url=? where id=?", request.storeIndoorUrl().trim(), application.get("id"));
        // 这里不另判主体类型:企业主体(非个体工商户)客户端本来就把这一项传空串,blank 会挡住。
        if (clean(application.get("settlementCardFrontUrl")).isBlank() && !blank(request.settlementCardFrontUrl())) {
            jdbc.update("update merchant_onboarding_application set settlement_card_front_url=? where id=?", request.settlementCardFrontUrl().trim(), id);
        }
    }
@@ -610,7 +621,7 @@
            boolean mccRequired = individual || "个体工商户".equals(clean(application.get("companyType")));
            // 个人(小微)商户的门店照片必须随业务开通一起交上去。缺图就直接收口成失败、
            // 不发这次请求:发出去也只会被通道驳回,还会在通道侧留下一条缺材料的申请。
            // 商户在进件页补上照片重新提交即可(见 supplyMissingStorePhotos),下次刷新会带材料重发。
            // 商户在进件页补上照片重新提交即可(见 supplyMissingOnboardingMaterials),下次刷新会带材料重发。
            String storeHeaderFileId = "";
            String storeIndoorFileId = "";
            if (individual) {
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java
@@ -23,6 +23,9 @@
    // 否则测试会真的打到汇付。
    @MockitoBean com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway wechatConfigGateway;
    @MockitoBean com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway businessOpenGateway;
    // 提交进件会真的上传图片、并向汇付递交申请,这两个也一并挡住——测试不该打网络。
    @MockitoBean com.talentcard.server.business.payment.huifu.HuifuPictureUploadGateway pictureUploadGateway;
    @MockitoBean com.talentcard.server.business.payment.huifu.HuifuIndividualOnboardingGateway individualOnboardingGateway;
    @Autowired org.springframework.web.context.WebApplicationContext context;
    @Autowired com.talentcard.server.business.admin.auth.AdminAuthService adminAuth;
@@ -501,6 +504,45 @@
        assertThat(statusOf("store_indoor_url",applicationNo)).isEqualTo("https://cdn.example.com/store-indoor.jpg");
    }
    /**
     * 存量进件缺「结算银行卡卡号面」时,重新提交要能补上。
     *
     * <p>这一列过去从来没落过库(2026-09-18 工单),库里也没有别的地方能还原它;而分阶段模型下
     * 重复提交不会新建进件,重提就是已提交进件唯一的补救入口,所以这条必须补得住。
     */
    @Test
    void resubmittingSuppliesTheMissingSettlementCardFront() {
        Map<String,Object> fixture = individualProvisioningFixture();
        MerchantPrincipal principal = (MerchantPrincipal) fixture.get("principal");
        String applicationNo = String.valueOf(fixture.get("applicationNo"));
        assertThat(statusOf("settlement_card_front_url",applicationNo)).isNull();
        service.submitOnboarding(principal, request(
                "https://cdn.example.com/store-header.jpg",
                "https://cdn.example.com/store-indoor.jpg",
                "https://cdn.example.com/card-front.jpg"), "T02-card-"+System.nanoTime());
        assertThat(statusOf("settlement_card_front_url",applicationNo)).isEqualTo("https://cdn.example.com/card-front.jpg");
        // 没有重新进件,原汇付商户号与银行卡关系都保持。
        assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where application_no=?",Integer.class,applicationNo)).isEqualTo(1);
    }
    /** 已经有值的卡号面不会被重新提交覆盖——存档的那张才是当时提交的。 */
    @Test
    void resubmittingDoesNotOverwriteAnExistingSettlementCardFront() {
        Map<String,Object> fixture = individualProvisioningFixture();
        MerchantPrincipal principal = (MerchantPrincipal) fixture.get("principal");
        String applicationNo = String.valueOf(fixture.get("applicationNo"));
        jdbc.update("update merchant_onboarding_application set settlement_card_front_url='https://cdn.example.com/original-card.jpg' where application_no=?", applicationNo);
        service.submitOnboarding(principal, request(
                "https://cdn.example.com/store-header.jpg",
                "https://cdn.example.com/store-indoor.jpg",
                "https://cdn.example.com/other-card.jpg"), "T02-card-"+System.nanoTime());
        assertThat(statusOf("settlement_card_front_url",applicationNo)).isEqualTo("https://cdn.example.com/original-card.jpg");
    }
    private Map<String,Object> individualProvisioningFixture() {
        String key="T02I-"+System.nanoTime();
        jdbc.update("insert into merchant(merchant_name,status,deleted) values (?,1,0)",key);
@@ -515,9 +557,54 @@
    /** 补齐门店照片这一步只读这两项,其余字段留空即可。 */
    private static MerchantOnboardingRequest request(String storeHeaderUrl,String storeIndoorUrl) {
        return request(storeHeaderUrl, storeIndoorUrl, null);
    }
    private static MerchantOnboardingRequest request(String storeHeaderUrl,String storeIndoorUrl,String settlementCardFrontUrl) {
        return new MerchantOnboardingRequest("individual","Owner",null,null,null,null,null,null,null,null,
                null,null,"510100",null,null,null,null,null,null,null,null,null,null,null,null,null,
                "Owner","13700000000",null,null,"510100",null,null,null,storeHeaderUrl,storeIndoorUrl);
                "Owner","13700000000",null,null,"510100",null,settlementCardFrontUrl,null,storeHeaderUrl,storeIndoorUrl);
    }
    /**
     * 新提交的个人进件要把「结算银行卡卡号面」真的写进库。
     *
     * <p>在这之前那条 INSERT 的列清单里没有 {@code settlement_card_front_url}:商户端传了值、后端也
     * 拿它上传了汇付图片(F13),但写库时被静默丢掉,于是后台详情里这一格永远是 {@code -}。
     * 断言直接盯数据库那一列,不看接口返回值——返回值在修复前也是「成功」的。
     */
    @Test
    void aNewIndividualSubmissionPersistsTheSettlementCardFront() {
        String key = "T02C-" + System.nanoTime();
        jdbc.update("insert into merchant(merchant_name,merchant_type,status,deleted) values (?, '餐饮服务', 1, 0)", key);
        Long merchant = jdbc.queryForObject("select id from merchant where merchant_name=?", Long.class, key);
        jdbc.update("insert into merchant_store(merchant_id,store_name,status,deleted) values (?,?,1,0)", merchant, key);
        Long store = jdbc.queryForObject("select id from merchant_store where merchant_id=?", Long.class, merchant);
        String clearingCode = String.format("%012d", Math.abs(System.nanoTime() % 1_000_000_000_000L));
        jdbc.update("insert into bank_branch_directory(clearing_code,branch_name,bank_name,head_bank_code) values (?,?,?,?)",
                clearingCode, "测试支行", "测试银行", "HB" + clearingCode);
        when(pictureUploadGateway.uploadUrl(anyString(),anyString())).thenReturn("pic-id");
        // 通道递交刻意回「驳回」:写库发生在递交**之前**,而驳回会让流程就此收口,
        // 不再触发后面的开通链路——这条测试只关心那一列有没有落库,其余都是噪音。
        when(individualOnboardingGateway.create(any())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuIndividualOnboardingGateway.Result(false,"","","","00000001","参数非法"));
        service.submitOnboarding(new MerchantPrincipal(1L,merchant,store,"owner"), individualRequest(clearingCode), "T02-card-"+System.nanoTime());
        assertThat(jdbc.queryForObject("select settlement_card_front_url from merchant_onboarding_application where merchant_id=?",String.class,merchant))
                .as("上传了结算卡卡号面就必须落库,否则后台详情只能显示 -")
                .isEqualTo("https://cdn.example.com/card-front.jpg");
    }
    /** 个人进件的一次完整提交(除门店照片与结算卡卡号面外的必填项也都给上)。 */
    private static MerchantOnboardingRequest individualRequest(String clearingCode) {
        return new MerchantOnboardingRequest(
                "individual","测试负责人",null,"511623200211245724","fixed","2022-02-14","2032-02-14",
                null,null,null,"四川省 成都市 武侯区","510000","510100","510107","四川省邻水县丰禾镇9组108号",
                null,null,"其它",null,null,null,
                "https://cdn.example.com/id-front.jpg","https://cdn.example.com/id-back.jpg","四川省邻水县丰禾镇9组108号","13700000000","owner@example.com",
                "测试负责人","13700000000","6222000012345678","510000","510100",clearingCode,
                "https://cdn.example.com/card-front.jpg",null,
                "https://cdn.example.com/store-header.jpg","https://cdn.example.com/store-indoor.jpg");
    }
    /** 建一张还停在 not_started 的进件,用来观察刷新会不会去推进开通步骤。 */