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;
|
|
/**
|
* 「商户进件」与「银行卡申请」两个列表、以及进件详情里几个「显示不对 / 显示不全」的回归。
|
*
|
* <p>这几处原来都靠同名列撞车后的偶然结果、或一份写死的映射兜着:改一行 SQL、换一个 switch
|
* 分支就会悄悄退回去,而页面上只是少显示一个值,不会报错。所以断言都盯「运营最终看到的那一列
|
* 是什么值」,而不是盯 SQL 或实现长什么样。
|
*/
|
@SpringBootTest
|
@ActiveProfiles("test")
|
class MerchantSettlementAdminListTests {
|
|
@Autowired
|
private JdbcTemplate jdbc;
|
|
@Autowired
|
private MerchantSettlementAdminService settlementAdminService;
|
|
@Autowired
|
private SensitiveDataCrypto crypto;
|
|
/**
|
* 列表上的「商家名称 / 商家类型 / 联系方式」必须和筛选框用的是同一份值。
|
*
|
* <p>进件表和商户表都有 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<Map<String, Object>> 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、密文和卡哈希都不该出现在响应里。
|
*
|
* <p>库里的 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<Map<String, Object>> 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));
|
});
|
}
|
|
/**
|
* 开户城市要按全国行政区划解析,不是只认四川那一张表。
|
*
|
* <p>原来的实现只列了四川省的市州,别的省一律把 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<Map<String, Object>> page = settlementAdminService.bankCards(1, 20, merchantName, null, null, null, null, null);
|
|
assertThat(page.records()).singleElement().satisfies(row -> assertThat(row).containsEntry("cityName", "广东省|汕尾市"));
|
}
|
|
/**
|
* 进件详情要能看到这条进件绑的结算卡(原来只有一个永远为空的卡号面图片位),
|
* 同时同样不能带出明文卡号。
|
*
|
* <p>详情是进件当时的存档:名称和联系方式取的是申请时填的那一份,不是商户表的当前值
|
* (列表的「商家名称 / 联系电话」是筛选条件,才必须跟商户表走)。
|
*/
|
@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<String, Object> 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));
|
}
|
|
/**
|
* 商家类型筛选项直接来自后端目录。
|
*
|
* <p>前端原来自己抄了一份 8 项清单:标签写的是目录里只当别名的「住宿服务 / 购房服务 / 体检服务」
|
* (库里存的是「酒店服务 / 购房优惠 / 免费体检」,所以这三个选项一个都筛不出来),
|
* 后来又新增的「文化服务 / 数码服务 / 验证商户」则根本没得选。
|
*/
|
@Test
|
void theMerchantTypeOptionsComeFromTheBackendCatalog() {
|
assertThat(settlementAdminService.merchantTypes())
|
.contains("餐饮服务", "景区服务", "酒店服务", "购房优惠", "免费体检", "文化服务", "数码服务")
|
.doesNotContain("住宿服务", "购房服务", "体检服务");
|
}
|
|
/**
|
* 详情必须把「结算凭证图」原样带出来,并说清这张图是银行卡卡号面还是开户许可证。
|
*
|
* <p>工单建议的断言有两句:库里要有值、接口要返回该字段。前者由进件侧的用例覆盖,
|
* 这里补后者——否则「存了但详情不带出来」这类断链照样漏过去。
|
*
|
* <p>这一列一列两义(对私=卡号面、对公=开户许可证),所以标签由后端给的主体标志决定:
|
* 前端只按标志换名字,不自己再判一遍。
|
*/
|
@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<String, Object> privateDetail = settlementAdminService.onboarding(privateId);
|
assertThat(privateDetail)
|
.containsEntry("settlementCardFrontUrl", proofUrl)
|
.as("个人主体的这张图是银行卡卡号面")
|
.containsEntry("privateSettlement", true);
|
|
Map<String, Object> 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);
|
}
|
}
|