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
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
package com.talentcard.server.business.merchant;
 
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.Map;
import com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameCommand;
import com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameGateway;
import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway;
import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
import com.talentcard.server.business.payment.huifu.HuifuPictureUploadGateway;
import com.talentcard.server.common.BusinessException;
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;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
 
/**
 * 微信实名认证的发起、材料校验与状态闭环(法人路径)。
 *
 * <p>实名是微信支付权限的前置,也是「未完成实名不允许收款」这条决策的落点。这里钉住四件事:
 * 材料缺项要一次说清、微信业务未确认前不发起、未决申请不重复提交、授权是否完成只看
 * {@code authorize_stat}({@code FREEZED} 也可能已经授权成功)。
 */
@SpringBootTest
@ActiveProfiles("test")
class MerchantRealnameServiceTests {
    @Autowired MerchantOnboardingProgressService progressBean;
    @Autowired JdbcTemplate jdbc;
    @Autowired MerchantRealnameService service;
    @Autowired SensitiveDataCrypto crypto;
    @Autowired HuifuPaymentProperties huifuProperties;
    @MockitoBean HuifuMerchantRealnameGateway realnameGateway;
    @MockitoBean HuifuOnboardingProgressGateway progressGateway;
    @MockitoBean HuifuPictureUploadGateway pictureUploadGateway;
 
    @Test
    void missingLegalMobileIsReportedBeforeAnyProviderCall() {
        long applicationId = fixture(null);
        assertThatThrownBy(() -> service.submit(applicationId, false))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("法人手机号");
        verifyNoInteractions(realnameGateway);
    }
 
    /**
     * 材料校验里点明了「不能用联系人手机号顶替」:那张真实商户的联系人是经办人、法人是另一个人,
     * 混淆会把经办人的号码报给微信审核,而实名被驳回要换业务申请编号重来。
     */
    @Test
    void theLegalMobileIsNotTakenFromTheContactMobile() {
        long applicationId = fixture(null, "13700000000");
        assertThatThrownBy(() -> service.submit(applicationId, false))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("不能用联系人手机号顶替");
        verifyNoInteractions(realnameGateway);
    }
 
    @Test
    void realnameIsNotSubmittedBeforeTheWechatBusinessIsConfirmed() {
        long applicationId = fixture("13900000000", "13700000000", null);
        assertThatThrownBy(() -> service.submit(applicationId, false))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("微信支付业务开通尚未确认生效");
        verifyNoInteractions(realnameGateway);
    }
 
    @Test
    void anAcceptedApplicationIsStoredAsProcessingWithItsApplymentId() {
        long applicationId = fixture("13900000000");
        when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000",
                "beneficiary_info_list","[{\"bo_name\":\"张维\"}]"));
        when(realnameGateway.submit(anyString(),anyString(),any())).thenReturn(new HuifuMerchantRealnameGateway.Result(true,"2000002327252449","00000000","成功"));
 
        service.submit(applicationId, false);
 
        Map<String,Object> saved = jdbc.queryForMap("select realname_status,realname_request_no,realname_apply_no from merchant_onboarding_application where id=?", applicationId);
        assertThat(saved.get("realname_status")).isEqualTo("processing");
        assertThat(String.valueOf(saved.get("realname_apply_no"))).isEqualTo("2000002327252449");
        // 业务申请编号按申请单号派生并带随机后缀:驳回后重发起必须能换成新的,所以不能只按申请单号派生。
        String applicationNo = jdbc.queryForObject("select application_no from merchant_onboarding_application where id=?", String.class, applicationId);
        assertThat(String.valueOf(saved.get("realname_request_no"))).startsWith(applicationNo + "RN").hasSizeLessThanOrEqualTo(32);
        // 法人是受益人(受益人列表里有法人本人),owner 应取 Y。
        var command = org.mockito.ArgumentCaptor.forClass(com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameCommand.class);
        verify(realnameGateway).submit(anyString(),eq("6666000241335656"),command.capture());
        assertThat(command.getValue().legalIsBeneficiary()).isTrue();
        assertThat(command.getValue().legalName()).isEqualTo("张维");
        assertThat(command.getValue().legalMobile()).isEqualTo("13900000000");
    }
 
    /** 未决申请不重复提交:重复点击只会去查询通道,不会再报一份实名申请。 */
    @Test
    void aPendingApplicationIsNeverSubmittedTwice() {
        long applicationId = fixture("13900000000");
        when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000"));
        when(progressGateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(realnameGateway.submit(anyString(),anyString(),any())).thenReturn(new HuifuMerchantRealnameGateway.Result(true,"2000002327252449","00000000","成功"));
 
        service.submit(applicationId, false);
        service.submit(applicationId, false);
        service.submit(applicationId, true);
 
        verify(realnameGateway,times(1)).submit(anyString(),anyString(),any());
    }
 
    /** 通道明确拒绝才允许换新申请单号重来,且新号必须与旧号不同。 */
    @Test
    void aRejectedApplicationCanBeResubmittedWithANewRequestNumber() {
        long applicationId = fixture("13900000000");
        when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000"));
        when(progressGateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(realnameGateway.submit(anyString(),anyString(),any())).thenReturn(new HuifuMerchantRealnameGateway.Result(false,"","00000001","基础参数校验失败"));
 
        service.submit(applicationId, false);
        assertThat(jdbc.queryForObject("select realname_status from merchant_onboarding_application where id=?",String.class,applicationId)).isEqualTo("failed");
        assertThatThrownBy(() -> service.submit(applicationId, false)).hasMessageContaining("上次实名申请未通过");
 
        when(realnameGateway.submit(anyString(),anyString(),any())).thenReturn(new HuifuMerchantRealnameGateway.Result(true,"2000002327252450","00000000","成功"));
        service.submit(applicationId, true);
 
        var requests = org.mockito.ArgumentCaptor.forClass(String.class);
        verify(realnameGateway,times(2)).submit(requests.capture(),anyString(),any());
        assertThat(requests.getAllValues()).doesNotHaveDuplicates();
    }
 
    /** 结果未知时保持未决,不能回退成可重提交,否则会给同一商户报两份实名申请。 */
    @Test
    void anUnknownOutcomeStaysPendingAndBlocksResubmission() {
        long applicationId = fixture("13900000000");
        when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000"));
        when(progressGateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(realnameGateway.submit(anyString(),anyString(),any())).thenThrow(new com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException("timeout",true,null));
 
        service.submit(applicationId, false);
        assertThat(jdbc.queryForObject("select realname_status from merchant_onboarding_application where id=?",String.class,applicationId)).isEqualTo("processing");
 
        service.submit(applicationId, true);
        verify(realnameGateway,times(1)).submit(anyString(),anyString(),any());
    }
 
    /**
     * 个人(小微)主体的实名要带齐门店材料:官方在实名的小微路径上另收门店名称、门头照、
     * 店内环境照、门店地址与省市编码、以及身份证正反面。原先这一组一个都没发,
     * 而且这一条路径上缺材料是被通道驳回——驳回要换业务申请编号重来。
     */
    @Test
    void anIndividualMerchantCarriesItsStoreMaterial() {
        long applicationId = individualFixture();
        when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000"));
        when(pictureUploadGateway.uploadUrl(anyString(),anyString())).thenReturn("file-id");
        when(realnameGateway.submit(anyString(),anyString(),any())).thenReturn(new HuifuMerchantRealnameGateway.Result(true,"2000002327252449","00000000","成功"));
 
        service.submit(applicationId, false);
 
        var command = org.mockito.ArgumentCaptor.forClass(com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameCommand.class);
        verify(realnameGateway).submit(anyString(),eq("6666000241335656"),command.capture());
        HuifuMerchantRealnameCommand.MicroStore store = command.getValue().microStore();
        assertThat(store).isNotNull();
        assertThat(store.storeName()).isEqualTo("张维的餐饮店");
        assertThat(store.storeAddress()).isEqualTo("成都市成华区东丽街138号");
        // 门店省市编码取进件时选的经营市编码。
        assertThat(store.storeAddressCode()).isEqualTo("510100");
        assertThat(store.microBizType()).isEqualTo(huifuProperties.getMerchantMicroBusinessType().trim());
        // 四张图各按官方文件类型上报:F22 门头照、F24 店内环境照、F40/F41 身份证正反面。
        verify(pictureUploadGateway).uploadUrl(anyString(),eq("F22"));
        verify(pictureUploadGateway).uploadUrl(anyString(),eq("F24"));
        verify(pictureUploadGateway).uploadUrl(anyString(),eq("F40"));
        verify(pictureUploadGateway).uploadUrl(anyString(),eq("F41"));
        assertThat(store.storeHeaderFileId()).isEqualTo("file-id");
        assertThat(store.idBackFileId()).isEqualTo("file-id");
    }
 
    /** 企业主体的实名不带小微门店材料,也不该为它去传门店照片。 */
    @Test
    void anEnterpriseMerchantCarriesNoStoreMaterial() {
        long applicationId = fixture("13900000000");
        when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000"));
        when(realnameGateway.submit(anyString(),anyString(),any())).thenReturn(new HuifuMerchantRealnameGateway.Result(true,"2000002327252449","00000000","成功"));
 
        service.submit(applicationId, false);
 
        var command = org.mockito.ArgumentCaptor.forClass(com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameCommand.class);
        verify(realnameGateway).submit(anyString(),anyString(),command.capture());
        assertThat(command.getValue().microStore()).isNull();
        verifyNoInteractions(pictureUploadGateway);
    }
 
    /**
     * 个人主体缺门店材料时一次说清,不提交注定被驳回的申请——实名被驳回要换业务申请编号重来,
     * 而商户端下次还得从头把资料填一遍。
     */
    @Test
    void missingStoreMaterialIsReportedBeforeAnyProviderCall() {
        long applicationId = individualFixture();
        jdbc.update("update merchant_onboarding_application set store_indoor_url=null where id=?", applicationId);
 
        assertThatThrownBy(() -> service.submit(applicationId, false))
                .isInstanceOf(BusinessException.class)
                .hasMessageContaining("店内环境照");
        verifyNoInteractions(realnameGateway);
        verifyNoInteractions(pictureUploadGateway);
    }
 
    /**
     * 授权是否完成只看 {@code authorize_stat}:{@code PASSED} 单独不算完成,而 {@code FREEZED}
     * (「该主体已完成过入驻」)也可能已经授权成功——真实样本就是 FREEZED + authorize_stat=1,
     * 按申请单状态判会把它显示成待办理。
     */
    @Test
    void authorizationIsDecidedByAuthorizeStatNotByTheApplicationState() {
        MerchantOnboardingProgressService progress = progressBean;
        assertThat(stageOf(progress,"APPLYMENT_STATE_PASSED","1")).isEqualTo("confirmed");
        assertThat(stageOf(progress,"APPLYMENT_STATE_PASSED","0")).isEqualTo("action_required");
        assertThat(stageOf(progress,"APPLYMENT_STATE_FREEZED","1")).isEqualTo("confirmed");
        assertThat(stageOf(progress,"APPLYMENT_STATE_FREEZED","0")).isEqualTo("action_required");
        assertThat(stageOf(progress,"APPLYMENT_STATE_NOAPPLY","0")).isEqualTo("missing");
        assertThat(stageOf(progress,"APPLYMENT_STATE_REJECTED","0")).isEqualTo("failed");
        assertThat(stageOf(progress,"APPLYMENT_STATE_WAITTING_FOR_AUDIT","0")).isEqualTo("pending");
    }
 
    private String stageOf(MerchantOnboardingProgressService progress, String applymentStat, String authorizeStat) {
        long applicationId = fixture("13900000000");
        when(progressGateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"));
        when(progressGateway.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat",applymentStat,"authorize_stat",authorizeStat));
        Map<String,Object> view = progress.refresh(applicationId, false);
        return stageStatus(view,"realname");
    }
 
    /**
     * 授权二维码要能直接当图片用:通道回的是**裸 base64**,且是 JPEG 还是 PNG 并不固定
     * ——实测小微实名的码就是 JPEG({@code /9j/4AAQSkZJRg…})。原先这里写死了 PNG 的 magic
     * 并统一标成 {@code image/png},于是 JPEG 的码被判非法、返回空串,两端页面都是空白,
     * 而"本人扫码授权"是完成实名的唯一途径(2026-09-16 实测:申请单已受理、通道已回码,页面却空)。
     */
    @Test
    void theAuthorizationQrCodeIsExposedAsADataUrlWhicheverFormatTheChannelReturns() {
        assertThat(qrCodeOf("iVBORw0KGgoAAAANSUhEUg==")).startsWith("data:image/png;base64,");
        assertThat(qrCodeOf("/9j/4AAQSkZJRgABAQAAAQABAAD")).startsWith("data:image/jpeg;base64,");
        // 已经带前缀的也认,不重复叠加。
        assertThat(qrCodeOf("data:image/jpeg;base64,/9j/4AAQSkZJRgAB")).isEqualTo("data:image/jpeg;base64,/9j/4AAQSkZJRgAB");
        // 不是图片的 base64 一律不拼进 img src。
        assertThat(qrCodeOf("QUJDREVG")).isEmpty();
        assertThat(qrCodeOf("")).isEmpty();
    }
 
    /** 走一遍刷新,取 realname 阶段实际暴露给前端的 qrCode。 */
    @SuppressWarnings("unchecked")
    private String qrCodeOf(String qrcodeData) {
        long applicationId = fixture("13900000000");
        when(progressGateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N"));
        when(progressGateway.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat","APPLYMENT_STATE_WAITTING_FOR_CONFIRM_CONTACT","authorize_stat","0","qrcode_data",qrcodeData));
        Map<String,Object> view = progressBean.refresh(applicationId,false);
        for (Object item : (java.util.List<Object>) view.get("stages")) {
            Map<String,Object> stage = (Map<String,Object>) item;
            if ("realname".equals(stage.get("key"))) return String.valueOf(stage.get("qrCode"));
        }
        throw new IllegalStateException("realname stage not found");
    }
 
    @SuppressWarnings("unchecked")
    private static String stageStatus(Map<String,Object> view, String key) {
        for (Object item : (java.util.List<Object>) view.get("stages")) {
            Map<String,Object> stage = (Map<String,Object>) item;
            if (key.equals(stage.get("key"))) return String.valueOf(stage.get("status"));
        }
        throw new IllegalStateException("stage not found: " + key);
    }
 
    private long fixture(String legalMobile) { return fixture(legalMobile, "13700000000", WECHAT_CONFIRMED); }
 
    private long fixture(String legalMobile, String contactMobile) { return fixture(legalMobile, contactMobile, WECHAT_CONFIRMED); }
 
    /** 进度里「微信支付」已确认;传 null 表示还没有进度记录,微信业务开通未确认。 */
    private static final String WECHAT_CONFIRMED = "{\"wechat\":\"confirmed\"}";
 
    /**
     * 建一张走分阶段模型的进件申请:结算卡已关联、法人资料按实测那张商户的形状填
     * ——法人与联系人**不是同一个人**(联系人阳野、法人张维)。
     */
    private long fixture(String legalMobile, String contactMobile, String stagesJson) {
        String key = "RN-" + System.nanoTime();
        jdbc.update("insert into merchant(merchant_name,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);
        jdbc.update("insert into merchant_bank_card(merchant_id,store_id,bank_name,branch_name,city_code,card_tail,card_masked,card_hash,status,idempotency_key) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)", merchant, store, key, key);
        Long card = jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?", Long.class, key);
        jdbc.update("insert into merchant_onboarding_application(merchant_id,store_id,application_no,subject_type,subject_name,subject_id_no_masked,subject_id_no_encrypted,legal_certificate_address,legal_mobile,contact_name,contact_mobile,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id) values (?,?,?,'enterprise','张维','513********4667',?,'成都市成华区东丽街138号9栋2单元16楼5号',?,'阳野',?,?,'pending','6666000241335656','original-apply',?)",
                merchant, store, key, crypto.encrypt("513021198109304667"), legalMobile, contactMobile, key, card);
        Long applicationId = jdbc.queryForObject("select id from merchant_onboarding_application where application_no=?", Long.class, key);
        jdbc.update("insert into merchant_onboarding_progress(application_id,stages_json) values (?,?)", applicationId, stagesJson == null ? "{}" : stagesJson);
        return applicationId;
    }
 
    /**
     * 建一张个人(小微)主体的进件申请:门店照片、身份证照片、经营市编码都已落库,
     * 商家档案上也有门店名称与地址,进度里微信支付已确认(实名的前置)。
     */
    private long individualFixture() {
        String key = "RNI-" + System.nanoTime();
        jdbc.update("insert into merchant(merchant_name,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,address,status,deleted) values (?,?,?,1,0)", merchant, "张维的餐饮店", "成都市成华区东丽街138号");
        Long store = jdbc.queryForObject("select id from merchant_store where merchant_id=?", Long.class, merchant);
        jdbc.update("insert into merchant_bank_card(merchant_id,store_id,bank_name,branch_name,city_code,card_tail,card_masked,card_hash,status,idempotency_key) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)", merchant, store, key, key);
        Long card = jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?", Long.class, key);
        jdbc.update("insert into merchant_onboarding_application(merchant_id,store_id,application_no,subject_type,subject_name,subject_id_no_masked,subject_id_no_encrypted,registered_city_code,registered_address,legal_mobile,contact_name,contact_mobile,store_header_url,store_indoor_url,legal_id_front_url,legal_id_back_url,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id) values (?,?,?,'individual',?,?,?,?,?,?,?,?,?,?,?,?,?,'pending','6666000241335656','original-apply',?)",
                merchant, store, key, "张维", "513********4667", crypto.encrypt("513021198109304667"), "510100", "成都市成华区东丽街138号9栋2单元16楼5号",
                "13900000000", "张维", "13700000000", "/uploads/store-header.jpg", "/uploads/store-indoor.jpg",
                "/uploads/id-front.jpg", "/uploads/id-back.jpg", key, card);
        Long applicationId = jdbc.queryForObject("select id from merchant_onboarding_application where application_no=?", Long.class, key);
        jdbc.update("insert into merchant_onboarding_progress(application_id,stages_json) values (?,?)", applicationId, WECHAT_CONFIRMED);
        return applicationId;
    }
}