1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
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);
    }
}