package com.talentcard.server.business.admin; import static org.assertj.core.api.Assertions.assertThat; import java.util.Map; import java.util.UUID; import com.talentcard.server.business.PageResult; import com.talentcard.server.common.SensitiveDataCrypto; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; /** * 「商户进件」与「银行卡申请」两个列表、以及进件详情里几个「显示不对 / 显示不全」的回归。 * *

这几处原来都靠同名列撞车后的偶然结果、或一份写死的映射兜着:改一行 SQL、换一个 switch * 分支就会悄悄退回去,而页面上只是少显示一个值,不会报错。所以断言都盯「运营最终看到的那一列 * 是什么值」,而不是盯 SQL 或实现长什么样。 */ @SpringBootTest @ActiveProfiles("test") class MerchantSettlementAdminListTests { @Autowired private JdbcTemplate jdbc; @Autowired private MerchantSettlementAdminService settlementAdminService; @Autowired private SensitiveDataCrypto crypto; /** * 列表上的「商家名称 / 商家类型 / 联系方式」必须和筛选框用的是同一份值。 * *

进件表和商户表都有 merchant_name、merchant_type、contact_mobile,而查询里带了 a.*, * 同名列会有两份。原来列表取到的是进件表那份(申请时填的,个人主体通常为空),筛选却走商户表, * 于是出现「输入商家名能筛出记录、商家名称列却整列空白」。申请时填的名称不该被丢掉, * 但要用另一个键名带出来。 */ @Test void theOnboardingListShowsTheMerchantTableValuesThatTheFiltersMatchOn() { String suffix = uniqueSuffix(); String merchantName = "进件列表商户 " + suffix; String enteredName = "申请人填的商户名称 " + suffix; Long merchantId = insertMerchant(merchantName, "餐饮服务"); Long storeId = insertStore(merchantId, "进件列表门店 " + suffix); insertOnboarding(merchantId, storeId, "MO-LIST-" + suffix, enteredName, null); PageResult> page = settlementAdminService.onboardings(1, 20, merchantName, null, null, null, null, null); assertThat(page.records()).singleElement().satisfies(row -> assertThat(row) .containsEntry("merchantName", merchantName) .containsEntry("merchantType", "餐饮服务") .containsEntry("contactMobile", "13800000000") .containsEntry("onboardingMerchantName", enteredName)); } /** * 银行卡列表只出脱敏卡号;明文 PAN、密文和卡哈希都不该出现在响应里。 * *

库里的 card_no_encrypted 是可解密的,解密出来就是完整卡号——列表接口没有理由把它发出去, * 前端要展示的本来就是 card_masked。 */ @Test void theBankCardListKeepsTheNumberMaskedAndDropsTheEncryptedValue() { String suffix = uniqueSuffix(); String merchantName = "银行卡列表商户 " + suffix; String fullCardNumber = "6217857000072971573"; Long merchantId = insertMerchant(merchantName, "餐饮服务"); Long storeId = insertStore(merchantId, "银行卡列表门店 " + suffix); insertBankCard(merchantId, storeId, fullCardNumber, "510100"); PageResult> page = settlementAdminService.bankCards(1, 20, merchantName, null, null, null, null, null); assertThat(page.records()).singleElement().satisfies(row -> { assertThat(row).containsEntry("cardMasked", masked(fullCardNumber)); assertThat(row).doesNotContainKeys("cardNo", "cardNoEncrypted", "cardHash"); assertThat(row.values()).noneMatch(value -> String.valueOf(value).contains(fullCardNumber)); }); } /** * 开户城市要按全国行政区划解析,不是只认四川那一张表。 * *

原来的实现只列了四川省的市州,别的省一律把 6 位代码原样显示,运营看到的是「441500」。 */ @Test void theBankCardCityNameIsResolvedOutsideSichuan() { String suffix = uniqueSuffix(); String merchantName = "开户城市商户 " + suffix; Long merchantId = insertMerchant(merchantName, "餐饮服务"); Long storeId = insertStore(merchantId, "开户城市门店 " + suffix); insertBankCard(merchantId, storeId, "6217857000072971573", "441500"); PageResult> page = settlementAdminService.bankCards(1, 20, merchantName, null, null, null, null, null); assertThat(page.records()).singleElement().satisfies(row -> assertThat(row).containsEntry("cityName", "广东省|汕尾市")); } /** * 进件详情要能看到这条进件绑的结算卡(原来只有一个永远为空的卡号面图片位), * 同时同样不能带出明文卡号。 * *

详情是进件当时的存档:名称和联系方式取的是申请时填的那一份,不是商户表的当前值 * (列表的「商家名称 / 联系电话」是筛选条件,才必须跟商户表走)。 */ @Test void theOnboardingDetailCarriesTheBoundCardWithoutTheFullNumber() { String suffix = uniqueSuffix(); String merchantName = "进件详情商户 " + suffix; String enteredName = "申请人填的商户名称 " + suffix; String fullCardNumber = "6217857000072971573"; Long merchantId = insertMerchant(merchantName, "餐饮服务"); Long storeId = insertStore(merchantId, "进件详情门店 " + suffix); Long cardId = insertBankCard(merchantId, storeId, fullCardNumber, "441500"); Long onboardingId = insertOnboarding(merchantId, storeId, "MO-DETAIL-" + suffix, enteredName, cardId); Map detail = settlementAdminService.onboarding(onboardingId); assertThat(detail) .containsEntry("settlementBankName", "中国银行") .containsEntry("settlementBranchName", "中国银行股份有限公司汕尾海丰支行") .containsEntry("settlementCityName", "广东省|汕尾市") .containsEntry("settlementCardMasked", masked(fullCardNumber)) .containsEntry("settlementCardStatus", "approved") .containsEntry("merchantName", enteredName) .containsEntry("onboardingMerchantName", enteredName) .containsEntry("contactMobile", "13900000000") .containsEntry("merchantType", "餐饮服务"); assertThat(String.valueOf(detail.get("initialBankCardId"))).isEqualTo(String.valueOf(cardId)); assertThat(detail).doesNotContainKeys("cardNo", "cardNoEncrypted", "cardHash"); assertThat(detail.values()).noneMatch(value -> String.valueOf(value).contains(fullCardNumber)); } /** * 商家类型筛选项直接来自后端目录。 * *

前端原来自己抄了一份 8 项清单:标签写的是目录里只当别名的「住宿服务 / 购房服务 / 体检服务」 * (库里存的是「酒店服务 / 购房优惠 / 免费体检」,所以这三个选项一个都筛不出来), * 后来又新增的「文化服务 / 数码服务 / 验证商户」则根本没得选。 */ @Test void theMerchantTypeOptionsComeFromTheBackendCatalog() { assertThat(settlementAdminService.merchantTypes()) .contains("餐饮服务", "景区服务", "酒店服务", "购房优惠", "免费体检", "文化服务", "数码服务") .doesNotContain("住宿服务", "购房服务", "体检服务"); } /** * 详情必须把「结算凭证图」原样带出来,并说清这张图是银行卡卡号面还是开户许可证。 * *

工单建议的断言有两句:库里要有值、接口要返回该字段。前者由进件侧的用例覆盖, * 这里补后者——否则「存了但详情不带出来」这类断链照样漏过去。 * *

这一列一列两义(对私=卡号面、对公=开户许可证),所以标签由后端给的主体标志决定: * 前端只按标志换名字,不自己再判一遍。 */ @Test void theOnboardingDetailReturnsTheStoredSettlementProofWithItsSubjectSpecificKind() { String proofUrl = "https://cdn.example.com/settlement-proof.jpg"; String suffix = uniqueSuffix(); Long merchantId = insertMerchant("结算凭证图商户 " + suffix, "餐饮服务"); Long storeId = insertStore(merchantId, "结算凭证图门店 " + suffix); Long privateId = insertOnboarding(merchantId, storeId, "MO-PROOF-I-" + suffix, null, null, "individual", null); Long publicId = insertOnboarding(merchantId, storeId, "MO-PROOF-E-" + suffix, null, null, "enterprise", "私营企业"); jdbc.update("update merchant_onboarding_application set settlement_card_front_url=? where id in (?, ?)", proofUrl, privateId, publicId); Map privateDetail = settlementAdminService.onboarding(privateId); assertThat(privateDetail) .containsEntry("settlementCardFrontUrl", proofUrl) .as("个人主体的这张图是银行卡卡号面") .containsEntry("privateSettlement", true); Map publicDetail = settlementAdminService.onboarding(publicId); assertThat(publicDetail) .containsEntry("settlementCardFrontUrl", proofUrl) .as("企业(非个体工商户)主体的这张图是开户许可证") .containsEntry("privateSettlement", false); } /** 个体工商户走的是企业进件接口,但结算凭证图仍是银行卡卡号面,不能按 subjectType 一刀切。 */ @Test void aSoleProprietorKeepsThePrivateSettlementProof() { String suffix = uniqueSuffix(); String merchantName = "个体工商户商户 " + suffix; Long merchantId = insertMerchant(merchantName, "餐饮服务"); Long storeId = insertStore(merchantId, "个体工商户门店 " + suffix); Long id = insertOnboarding(merchantId, storeId, "MO-PROOF-S-" + suffix, null, null, "enterprise", "个体工商户"); assertThat(settlementAdminService.onboarding(id)).containsEntry("privateSettlement", true); } private String uniqueSuffix() { return String.valueOf(System.nanoTime()); } private Long insertMerchant(String merchantName, String merchantType) { jdbc.update("insert into merchant(merchant_name, merchant_type, contact_mobile, status, deleted) values (?, ?, '13800000000', 1, 0)", merchantName, merchantType); return jdbc.queryForObject("select max(id) from merchant", Long.class); } private Long insertStore(Long merchantId, String storeName) { jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", merchantId, storeName); return jdbc.queryForObject("select max(id) from merchant_store", Long.class); } /** 进件表这一侧刻意填上与商户表不同的名称和手机号,用来验证列上显示的是商户表那份。 */ private Long insertOnboarding(Long merchantId, Long storeId, String applicationNo, String onboardingMerchantName, Long bankCardId) { return insertOnboarding(merchantId, storeId, applicationNo, onboardingMerchantName, bankCardId, "individual", null); } /** 主体类型与公司类型决定「结算凭证图」是银行卡卡号面还是开户许可证,按需覆盖。 */ private Long insertOnboarding(Long merchantId, Long storeId, String applicationNo, String onboardingMerchantName, Long bankCardId, String subjectType, String companyType) { jdbc.update(""" insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name, contact_name, contact_mobile, idempotency_key, status, merchant_name, company_type, initial_bank_card_id) values (?, ?, ?, ?, 'List owner', 'List owner', '13900000000', ?, 'pending', ?, ?, ?) """, merchantId, storeId, applicationNo, subjectType, "settlement-list-" + applicationNo, onboardingMerchantName, companyType, bankCardId); return jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class); } /** 卡号按生产路径落库:明文只以密文形式保存,另存一份脱敏串和卡哈希。 */ private Long insertBankCard(Long merchantId, Long storeId, String fullCardNumber, String cityCode) { jdbc.update(""" insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail, card_masked, card_hash, card_no_encrypted, status, idempotency_key) values (?, ?, '中国银行', '中国银行股份有限公司汕尾海丰支行', ?, ?, ?, ?, ?, 'approved', ?) """, merchantId, storeId, cityCode, tail(fullCardNumber), masked(fullCardNumber), "0".repeat(64), crypto.encrypt(fullCardNumber), UUID.randomUUID().toString()); return jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class); } private static String tail(String fullCardNumber) { return fullCardNumber.substring(fullCardNumber.length() - 4); } private static String masked(String fullCardNumber) { return "**** **** **** " + tail(fullCardNumber); } }