talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -154,6 +154,12 @@
        requireCertificatePeriod(request.subjectCertificateValidityType(), request.subjectCertificateBeginDate(), request.subjectCertificateEndDate(), "Identity document");
        if (blank(request.legalCertificateAddress())) throw new BusinessException(ErrorCode.BAD_REQUEST, enterprise ? "请输入法人证件地址" : "请输入负责人证件地址");
        if (blank(request.administratorEmail()) || !request.administratorEmail().trim().matches("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")) throw new BusinessException(ErrorCode.BAD_REQUEST, "请输入正确的联系人邮箱");
        // 线下报备的经营场所材料是一组**三张**:门头照(F22)、内景照(F24)、收银台(F105)。
        // 通道的「商户材料校验服务」在进件这一步就按 F-code 逐张核对,缺一张整笔驳回——不是等到
        // 业务开通或实名才要,所以必须在这里拦住。**企业(含个体工商户)主体同样要交**:线下按
        // 「有实体经营场所」报备,通道不区分主体类型向门店索要这三张(2026-09-29 实测驳回原文:
        // 「缺失图片:F22-店铺门头照图片;F24-店铺内景图片;F105-店铺收银台」)。
        if (blank(request.storeHeaderUrl()) || blank(request.storeIndoorUrl()) || blank(request.storeCashierUrl())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请上传店铺门头照、店内环境照与收银台照片,线下报备需要这三张");
        if (enterprise) {
            requireCertificatePeriod(request.licenseValidityType(), request.licenseBeginDate(), request.licenseEndDate(), "Business licence");
            if (blank(request.registeredProvinceCode()) || blank(request.registeredCityCode()) || blank(request.registeredDistrictCode())) throw new BusinessException(ErrorCode.BAD_REQUEST, "Enterprise registration address codes are required");
@@ -161,10 +167,6 @@
            normalizeRegisteredCapital(request.companyType(), request.registeredCapital());
        } else {
            if (blank(request.registeredDistrictCode()) || blank(request.registeredAddress()) || blank(request.legalIdFrontUrl()) || blank(request.legalIdBackUrl())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请补全个人商户进件资料");
            // 汇付对个人(小微)商户的微信业务开通与微信实名认证都要门店照片(门头照 F22 + 内景照 F24)。
            // 放在提交时校验:这两张图是拿到微信收款能力的硬条件,缺了也能把进件提上去,
            // 但后面微信开通和实名一定被通道驳回,那时商户得把整份进件重走一遍。
            if (blank(request.storeHeaderUrl()) || blank(request.storeIndoorUrl())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请上传店铺照片与店内环境照,微信支付开通与实名认证都需要");
            // 经营详细地址(进件的 detail_addr)。线下按「有实体经营场所」口径报备后它是通道必填项,
            // 而证件地址(legal_addr)语义不同、不能顶替——两者原先共用同一个值,这里必须分开采集。
            if (blank(request.businessAddress())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请填写经营详细地址");
@@ -187,7 +189,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, settlement_card_front_url, legal_certificate_address, legal_mobile, administrator_email, contact_name, contact_mobile, store_header_url, store_indoor_url, business_address, 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(settlementProofUrl), clean(request.legalCertificateAddress()), clean(request.legalMobile()), clean(request.administratorEmail()), request.contactName().trim(), request.contactMobile().trim(), clean(request.storeHeaderUrl()), clean(request.storeIndoorUrl()), clean(request.businessAddress()), 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, store_cashier_url, business_address, 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(settlementProofUrl), clean(request.legalCertificateAddress()), clean(request.legalMobile()), clean(request.administratorEmail()), request.contactName().trim(), request.contactMobile().trim(), clean(request.storeHeaderUrl()), clean(request.storeIndoorUrl()), clean(request.storeCashierUrl()), clean(request.businessAddress()), key.trim());
        String cardNumber = request.bankCardNumber().trim();
        String cardKey = key.trim() + ":initial";
        if (cardKey.length() > 128) cardKey = cardKey.substring(0, 128);
@@ -215,11 +217,12 @@
    }
    /**
     * 存量个人进件缺门店照片、或缺结算银行卡卡号面时,允许商户在重新提交里补上。
     * 存量进件缺门店照片、或缺结算银行卡卡号面时,允许商户在重新提交里补上。
     *
     * <p>门店照片是个人(小微)主体开通微信支付与发起微信实名的硬材料,而分阶段模型下重复提交
     * <p>门店照片是开通微信支付与发起微信实名的硬材料,而分阶段模型下重复提交
     * **不会新建进件**(也就不会在通道上多出一家商户),商户端因此没有别的补图入口——真实开发库里
     * 已经有拿到汇付商户号的个人进件,就是这个形态。
     * 已经有拿到汇付商户号的进件,就是这个形态。三张图(门头照/内景照/收银台)**不分主体类型**都补:
     * 线下报备下企业主体同样被通道索要这三张。
     *
     * <p>结算卡卡号面在这一列补进 INSERT 之前**从来没落过库**(2026-09-18 工单),历史记录全是空值,
     * 库里也没有别的地方能还原它。所以这里一并补上——这是已提交进件唯一的补救入口。
@@ -229,14 +232,14 @@
    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("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("storeCashierUrl")).isBlank() && !blank(request.storeCashierUrl())) {
            jdbc.update("update merchant_onboarding_application set store_cashier_url=? where id=?", request.storeCashierUrl().trim(), id);
        }
        // 结算凭证图一列两义,补齐时同样要按主体挑对那一张:对私取卡号面、对公取开户许可证。
        // 主体按**库里那条进件**判,不按本次请求——进件已经定性了,不能因为重填了表单就换一张图。
@@ -256,6 +259,11 @@
            String settlementCardPictureId = privateSettlement ? pictureUploadGateway.uploadUrl(request.settlementCardFrontUrl(), "F13") : "";
            String settlementCertFrontPictureId = privateSettlement ? pictureUploadGateway.uploadUrl(request.legalIdFrontUrl(), "F55") : "";
            String settlementCertBackPictureId = privateSettlement ? pictureUploadGateway.uploadUrl(request.legalIdBackUrl(), "F56") : "";
            // 线下经营的门店材料三张(门头照 F22、内景照 F24、收银台 F105):进件这一步就要带上,
            // 通道的材料校验服务逐张按 F-code 核对,缺一张整笔驳回。企业主体原先一张都不发。
            String storeHeaderPictureId = pictureUploadGateway.uploadUrl(request.storeHeaderUrl(), "F22");
            String storeIndoorPictureId = pictureUploadGateway.uploadUrl(request.storeIndoorUrl(), "F24");
            String storeCashierPictureId = pictureUploadGateway.uploadUrl(request.storeCashierUrl(), "F105");
            String shortName = blank(request.merchantShortName()) ? request.merchantName().trim() : request.merchantShortName().trim();
            HuifuEnterpriseOnboardingGateway.Result result = enterpriseOnboardingGateway.create(new HuifuEnterpriseOnboardingCommand(
                    applicationNo, clean(request.merchantName()), shortName, request.creditCode().trim(),
@@ -267,7 +275,8 @@
                    privateSettlement ? "1" : "0", privateSettlement ? request.subjectName().trim() : request.merchantName().trim(), request.bankCardNumber().trim(), request.bankProvinceCode().trim(), request.bankCityCode().trim(), clean(branch.get("headBankCode")), clean(branch.get("clearingCode")), clean(branch.get("branchName")),
                    privateSettlement ? request.subjectIdNumber().trim().toUpperCase() : "", privateSettlement ? certificateValidityType(request.subjectCertificateValidityType()) : "", privateSettlement ? certificateBeginDate(request.subjectCertificateBeginDate()) : "", privateSettlement ? certificateEndDate(request.subjectCertificateEndDate()) : "", privateSettlement ? request.contactMobile().trim() : "",
                    registrationAccountPictureId, settlementCardPictureId, settlementCertFrontPictureId, settlementCertBackPictureId,
                    certificateBeginDate(request.establishedDate()), normalizeRegisteredCapital(request.companyType(), request.registeredCapital())));
                    certificateBeginDate(request.establishedDate()), normalizeRegisteredCapital(request.companyType(), request.registeredCapital()),
                    storeHeaderPictureId, storeIndoorPictureId, storeCashierPictureId));
            applyOnboardingResult(applicationNo, bankCardId, result.accepted(), result.huifuId(), result.applyNo(), result.tokenNo(), result.code(), result.message());
        } catch (HuifuPaymentGatewayException exception) {
            jdbc.update("update merchant_onboarding_application set reject_reason=? where application_no=? and status='pending'", "Huifu submission outcome is pending confirmation", applicationNo);
@@ -281,10 +290,14 @@
            String settlementCardPictureId = pictureUploadGateway.uploadUrl(request.settlementCardFrontUrl(), "F13");
            // 店铺照片:个人(小微)商户进件的必填项,三种经营类型都要传,文件类型固定 F22。
            String storeHeaderPictureId = pictureUploadGateway.uploadUrl(request.storeHeaderUrl(), "F22");
            // 线下经营的门店材料是**一组三张**,与门头照一起在进件这一步交上去(见企业路径的注释)。
            String storeIndoorPictureId = pictureUploadGateway.uploadUrl(request.storeIndoorUrl(), "F24");
            String storeCashierPictureId = pictureUploadGateway.uploadUrl(request.storeCashierUrl(), "F105");
            HuifuIndividualOnboardingGateway.Result result = individualOnboardingGateway.create(new HuifuIndividualOnboardingCommand(
                    applicationNo, request.subjectName().trim(), request.subjectIdNumber().trim().toUpperCase(), certificateValidityType(request.subjectCertificateValidityType()), certificateBeginDate(request.subjectCertificateBeginDate()), certificateEndDate(request.subjectCertificateEndDate()), request.contactMobile().trim(), clean(request.registeredAddress()), request.administratorEmail().trim(),
                    mcc(principal.merchantId(), "individual", request.companyType()), clean(request.registeredProvinceCode()), clean(request.registeredCityCode()), clean(request.registeredDistrictCode()), legalFrontPictureId, legalBackPictureId,
                    request.bankCardNumber().trim(), request.bankProvinceCode().trim(), request.bankCityCode().trim(), clean(branch.get("headBankCode")), settlementCardPictureId, storeHeaderPictureId, clean(request.businessAddress())));
                    request.bankCardNumber().trim(), request.bankProvinceCode().trim(), request.bankCityCode().trim(), clean(branch.get("headBankCode")), settlementCardPictureId, storeHeaderPictureId, clean(request.businessAddress()),
                    storeIndoorPictureId, storeCashierPictureId));
            applyOnboardingResult(applicationNo, bankCardId, result.accepted(), result.huifuId(), result.applyNo(), result.tokenNo(), result.code(), result.message());
        } catch (HuifuPaymentGatewayException exception) {
            jdbc.update("update merchant_onboarding_application set reject_reason=? where application_no=? and status='pending'", "Huifu submission outcome is pending confirmation", applicationNo);
@@ -628,29 +641,31 @@
            // 个体工商户走的是企业进件接口,所以不是 individual,但同样要带 mcc。
            // 注意:个人主体的 company_type 存的是进件时选的经营行业,不会等于「个体工商户」。
            boolean mccRequired = individual || "个体工商户".equals(clean(application.get("companyType")));
            // 个人(小微)商户的门店照片必须随业务开通一起交上去。缺图就直接收口成失败、
            // 不发这次请求:发出去也只会被通道驳回,还会在通道侧留下一条缺材料的申请。
            // 线下经营的门店材料三张(门头照 F22、内景照 F24、收银台 F105)必须随业务开通一起交上去,
            // **与主体类型无关**:线下按「有实体经营场所」报备,通道对企业和个人都索要这一组。
            // 缺图就直接收口成失败、不发这次请求:发出去也只会被通道驳回,还会在通道侧留下一条缺材料的申请。
            // 商户在进件页补上照片重新提交即可(见 supplyMissingOnboardingMaterials),下次刷新会带材料重发。
            String storeHeaderFileId = "";
            String storeIndoorFileId = "";
            if (individual) {
                String storeHeaderUrl = clean(application.get("storeHeaderUrl"));
                String storeIndoorUrl = clean(application.get("storeIndoorUrl"));
                if (storeHeaderUrl.isBlank() || storeIndoorUrl.isBlank()) {
                    jdbc.update("update merchant_onboarding_application set business_open_status='failed', reject_reason=? where id=?", "商户业务开通失败:个人商户开通微信支付需要店铺照片与店内环境照,请在进件页补上这两张图后重新提交", application.get("id"));
                    return;
                }
                try {
                    storeHeaderFileId = pictureUploadGateway.uploadUrl(storeHeaderUrl, "F22");
                    storeIndoorFileId = pictureUploadGateway.uploadUrl(storeIndoorUrl, "F24");
                } catch (BusinessException | HuifuPaymentGatewayException exception) {
                    jdbc.update("update merchant_onboarding_application set business_open_status='failed', reject_reason=? where id=?", "门店照片上传失败:" + exception.getMessage(), application.get("id"));
                    return;
                }
            String storeHeaderFileId;
            String storeIndoorFileId;
            String storeCashierFileId;
            String storeHeaderUrl = clean(application.get("storeHeaderUrl"));
            String storeIndoorUrl = clean(application.get("storeIndoorUrl"));
            String storeCashierUrl = clean(application.get("storeCashierUrl"));
            if (storeHeaderUrl.isBlank() || storeIndoorUrl.isBlank() || storeCashierUrl.isBlank()) {
                jdbc.update("update merchant_onboarding_application set business_open_status='failed', reject_reason=? where id=?", "商户业务开通失败:线下报备需要店铺门头照、店内环境照与收银台照片,请在进件页补上这三张图后重新提交", application.get("id"));
                return;
            }
            try {
                storeHeaderFileId = pictureUploadGateway.uploadUrl(storeHeaderUrl, "F22");
                storeIndoorFileId = pictureUploadGateway.uploadUrl(storeIndoorUrl, "F24");
                storeCashierFileId = pictureUploadGateway.uploadUrl(storeCashierUrl, "F105");
            } catch (BusinessException | HuifuPaymentGatewayException exception) {
                jdbc.update("update merchant_onboarding_application set business_open_status='failed', reject_reason=? where id=?", "门店照片上传失败:" + exception.getMessage(), application.get("id"));
                return;
            }
            jdbc.update("update merchant_onboarding_application set status='pending', business_open_status='processing', business_open_request_no=?, reject_reason=null, reviewed_at=null where id=?", requestNo, application.get("id"));
            try {
                HuifuMerchantBusinessOpenGateway.Result result = merchantBusinessOpenGateway.open(requestNo, huifuId, shortName, mcc(merchantId, subjectType, clean(application.get("companyType"))), individual, mccRequired, storeHeaderFileId, storeIndoorFileId);
                HuifuMerchantBusinessOpenGateway.Result result = merchantBusinessOpenGateway.open(requestNo, huifuId, shortName, mcc(merchantId, subjectType, clean(application.get("companyType"))), individual, mccRequired, storeHeaderFileId, storeIndoorFileId, storeCashierFileId);
                if (!result.accepted()) {
                    jdbc.update("update merchant_onboarding_application set business_open_status='failed', business_open_apply_no=nullif(?,''), business_open_response_code=?, business_open_response_message=?, reject_reason=? where id=?", clean(result.applyNo()), result.code(), result.message(), "商户业务开通失败:" + providerReason(result.code(), result.message()), application.get("id"));
                    return;