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.any; import static org.mockito.Mockito.when; import java.math.BigDecimal; import java.sql.Timestamp; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Map; import com.talentcard.server.business.merchant.auth.MerchantPrincipal; import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway; import com.talentcard.server.business.portal.FreeBenefitPortalService; import com.talentcard.server.business.portal.PendingOrderPaymentRequest; import com.talentcard.server.business.portal.PendingOrderPortalService; import com.talentcard.server.business.portal.PendingUseCodeRequest; import com.talentcard.server.business.portal.PendingUsePortalService; import org.junit.jupiter.api.BeforeEach; 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; @SpringBootTest @ActiveProfiles("test") class MerchantVerificationServiceTests { @Autowired private JdbcTemplate jdbc; @Autowired private MerchantVerificationService verificationService; @Autowired private MerchantWorkService merchantWorkService; @Autowired private ManualConsumptionService manualConsumptionService; @Autowired private PendingOrderPortalService pendingOrderPortalService; @Autowired private PendingUsePortalService pendingUsePortalService; @Autowired private DashboardSummaryService dashboardSummaryService; @Autowired private FreeBenefitPortalService freeBenefitPortalService; @MockitoBean private HuifuMiniProgramPaymentGateway paymentGateway; private Long merchantId; private Long storeId; private Long talentId; private Long cardId; private MerchantPrincipal principal; private String token; @BeforeEach void prepareVerification() { jdbc.update("insert into merchant(merchant_name, status, can_login_merchant_app, deleted) values (?, 1, 1, 0)", "Verification test merchant"); merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class); jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", merchantId, "Verification test store"); storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class); jdbc.update(""" insert into merchant_store_consume_rule(rule_no, merchant_id, card_level, rule_name, discount_rate, subsidy_enabled, status) values (?, ?, 'EXCELLENT', 'Store consume 10 percent off', 0.9000, 1, 1) """, "rule-" + System.nanoTime(), merchantId); String suffix = String.valueOf(System.nanoTime()); // A paid store-consumption order may only be created once the merchant can actually collect, // so the fixture approves the onboarding and its settlement card. String reference = suffix.substring(Math.max(0, suffix.length() - 8)); jdbc.update(""" insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name, contact_name, contact_mobile, idempotency_key, status, provider_huifu_id, auto_settle_status, cash_switch_status, merchant_stat, capability_verified_at) values (?, ?, ?, 'individual', 'Verification owner', 'Verification owner', '13800000000', ?, 'approved', ?, '0', '1', 'NORMAL', current_timestamp) """, merchantId, storeId, "MO-VERIFY-" + suffix, "verification-onboarding-" + suffix, "H" + reference); Long applicationId = jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class); 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, provider_token_no) values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '8888', '**** **** **** 8888', ?, 'approved', ?, ?) """, merchantId, storeId, "hash-verification-" + suffix, "verification-card-" + suffix, "T" + reference); jdbc.update("update merchant_onboarding_application set initial_bank_card_id = (select max(id) from merchant_bank_card) where id = ?", applicationId); // 收款就绪要求微信支付业务开通与实名/授权都有通道证据,这里的商户建模成已办完这两步。 jdbc.update("insert into merchant_onboarding_progress(application_id, stages_json) values (?, '{\"wechat\":\"confirmed\",\"realname\":\"confirmed\"}')", applicationId); jdbc.update(""" insert into talent_user(openid, name, mobile, talent_level, status, deleted) values (?, ?, ?, ?, 1, 0) """, "verification-test-" + suffix, "核销测试用户", "13800000000", "卓越人才卡"); talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class); jdbc.update(""" insert into talent_card(talent_id, card_no, card_type, card_level, status, deleted) values (?, ?, 'PERSONAL', '卓越人才卡', 1, 0) """, talentId, "VERIFICATION-" + suffix); cardId = jdbc.queryForObject("select max(id) from talent_card", Long.class); token = "merchant-verification-test-" + System.nanoTime(); jdbc.update(""" insert into talent_dynamic_code(code_token, card_id, talent_id, expected_merchant_id, scene, expires_at, status) values (?, ?, ?, ?, 'merchant', ?, 0) """, token, cardId, talentId, merchantId, Timestamp.valueOf(LocalDateTime.now().plusMinutes(1))); principal = new MerchantPrincipal(999L, merchantId, storeId, "operator"); } @Test void talentCodeCreatesPendingPaymentOrderInsteadOfDirectVerification() { Map scanned = verificationService.scan(principal, new VerificationScanRequest(token)); Integer beforeCreate = jdbc.queryForObject("select status from talent_dynamic_code where code_token = ?", Integer.class, token); assertThat(String.valueOf(scanned.get("verificationId"))).isNotBlank(); assertThat(scanned.get("credentialType")).isEqualTo("TALENT_CODE"); assertThat(beforeCreate).isZero(); Map detail = manualConsumptionService.detail(principal, String.valueOf(scanned.get("verificationId"))); String ruleId = String.valueOf(((java.util.List) detail.get("rules")).get(0) instanceof Map rule ? rule.get("id") : ""); ManualConsumptionCreateRequest request = new ManualConsumptionCreateRequest(String.valueOf(scanned.get("verificationId")), Long.valueOf(ruleId), new BigDecimal("680.00")); Map quoted = manualConsumptionService.quote(principal, request); Map created = manualConsumptionService.create(principal, request, "manual-consumption-key"); Map repeated = manualConsumptionService.create(principal, request, "manual-consumption-key"); Integer afterCreate = jdbc.queryForObject("select status from talent_dynamic_code where code_token = ?", Integer.class, token); assertThat(quoted).containsEntry("originalAmount", 68000L).containsEntry("payAmount", 61200L).containsEntry("subsidyAmount", 6800L); assertThat(created.get("orderNo")).isEqualTo(repeated.get("orderNo")); assertThat(afterCreate).isEqualTo(1); assertThat(jdbc.queryForObject("select order_status from benefit_order where order_no = ?", Integer.class, created.get("orderNo"))).isZero(); assertThat(jdbc.queryForObject("select count(*) from verify_record where talent_id = ?", Integer.class, talentId)).isZero(); assertThatThrownBy(() -> verificationService.confirmFreeStay(principal, new VerificationConfirmRequest(String.valueOf(scanned.get("verificationId"))), "verification-test-key")) .isInstanceOf(com.talentcard.server.common.BusinessException.class); pendingOrderPortalService.pay(String.valueOf(created.get("orderNo")), new PendingOrderPaymentRequest(talentId, ((Number) created.get("priceVersion")).intValue())); assertThat(jdbc.queryForObject("select verify_status from benefit_order where order_no = ?", Integer.class, created.get("orderNo"))).isEqualTo(1); assertThatThrownBy(() -> pendingUsePortalService.issueCode(String.valueOf(created.get("orderNo")), talentId)) .isInstanceOf(com.talentcard.server.common.BusinessException.class); } @Test void talentCodeRejectsMerchantWithoutApplicableStoreConsumptionRule() { jdbc.update("delete from merchant_store_consume_rule where merchant_id = ?", merchantId); assertThatThrownBy(() -> verificationService.scan(principal, new VerificationScanRequest(token))) .isInstanceOf(com.talentcard.server.common.BusinessException.class) .hasMessageContaining("未配置适用于该人才卡的到店消费权益"); } @Test void talentCodeStoreConsumptionUsesConfiguredCouponAfterTalentDiscount() { Map scanned = verificationService.scan(principal, new VerificationScanRequest(token)); String verificationId = String.valueOf(scanned.get("verificationId")); String couponName = "Store coupon " + System.nanoTime(); jdbc.update(""" insert into merchant_coupon(merchant_id, coupon_name, condition_type, condition_amount, coupon_amount, talent_levels, store_consume, status, deleted) values (?, ?, 'FULL', 100.00, 50.00, 'EXCELLENT', 1, 1, 0) """, merchantId, couponName); Long couponId = jdbc.queryForObject("select id from merchant_coupon where merchant_id = ? and coupon_name = ?", Long.class, merchantId, couponName); Map detail = manualConsumptionService.detail(principal, verificationId); String ruleId = String.valueOf(((java.util.List) detail.get("rules")).get(0) instanceof Map rule ? rule.get("id") : ""); assertThat((java.util.List) detail.get("coupons")).isNotEmpty(); ManualConsumptionCreateRequest belowDiscountedThreshold = new ManualConsumptionCreateRequest(verificationId, Long.valueOf(ruleId), new BigDecimal("110.00"), couponId); assertThatThrownBy(() -> manualConsumptionService.quote(principal, belowDiscountedThreshold)) .isInstanceOf(com.talentcard.server.common.BusinessException.class); ManualConsumptionCreateRequest eligible = new ManualConsumptionCreateRequest(verificationId, Long.valueOf(ruleId), new BigDecimal("120.00"), couponId); Map quote = manualConsumptionService.quote(principal, eligible); assertThat(quote).containsEntry("couponAmount", 5000L).containsEntry("payAmount", 5800L).containsEntry("subsidyAmount", 6200L); Map created = manualConsumptionService.create(principal, eligible, "manual-coupon-" + System.nanoTime()); assertThat(jdbc.queryForObject("select coupon_id from benefit_order where order_no = ?", Long.class, created.get("orderNo"))).isEqualTo(couponId); } @Test void pendingOrderCanBeCancelledAfterPrepayIsCreated() { String suffix = String.valueOf(System.nanoTime()); String orderNo = "PREPAY-CANCEL-" + suffix; // 取消前必须先问通道,所以订单要带上收款子商户,通道也要能答复这笔交易的状态。 String huifuId = "H" + suffix.substring(Math.max(0, suffix.length() - 8)); jdbc.update(""" insert into benefit_order(order_no, talent_id, card_id, merchant_id, store_id, order_type, order_amount, subsidy_amount, pay_amount, order_status, verify_status, deleted, collect_huifu_id) values (?, ?, ?, ?, ?, 'GROUP_BUY', 10000, 0, 10000, 0, 0, 0, ?) """, orderNo, talentId, cardId, merchantId, storeId, huifuId); Long orderId = jdbc.queryForObject("select max(id) from benefit_order", Long.class); jdbc.update(""" insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id, provider_huifu_id, amount, status, idempotency_key) values (?, 'HUIFU', ?, ?, ?, ?, ?, 10000, 'PREPAY_CREATED', ?) """, "PREPAY-CANCEL-PAY-" + suffix, orderId, orderNo, talentId, merchantId, huifuId, "prepay-cancel-" + suffix); // 通道回答「交易只登记过、从未开始支付」:这笔请求不可能收过钱,取消成立;关单确认成功之后, // 本地流水才记为已关闭。 when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(Map.of("data", Map.of( "trans_stat", "I", "resp_code", "00000000", "trans_amt", "100.00", "huifu_id", huifuId))); when(paymentGateway.closeMiniProgramPayment(any())).thenReturn(Map.of("data", Map.of( "trans_stat", "S", "resp_code", "00000000", "resp_desc", "交易成功", "huifu_id", huifuId))); pendingOrderPortalService.cancel(orderNo, talentId); assertThat(jdbc.queryForObject("select order_status from benefit_order where id = ?", Integer.class, orderId)).isEqualTo(2); assertThat(jdbc.queryForObject("select status from payment_transaction where order_id = ?", String.class, orderId)).isEqualTo("CLOSED"); assertThat(jdbc.queryForObject("select closed_at is not null from payment_transaction where order_id = ?", Boolean.class, orderId)).isTrue(); } @Test void orderVerificationCodeIsRecognizedAndConsumesThePaidOrderOnce() { String suffix = String.valueOf(System.nanoTime()); String orderNo = "ORDER-VERIFY-" + suffix; jdbc.update(""" insert into benefit_order(order_no, talent_id, card_id, merchant_id, store_id, order_type, order_amount, subsidy_amount, pay_amount, order_status, verify_status, deleted) values (?, ?, ?, ?, ?, 'GROUP_BUY', 10000, 2000, 8000, 1, 0, 0) """, orderNo, talentId, cardId, merchantId, storeId); Long orderId = jdbc.queryForObject("select max(id) from benefit_order", Long.class); String orderToken = "order-code-" + suffix; jdbc.update(""" insert into order_dynamic_verification_code(code_token, display_code, order_id, talent_id, expires_at, status) values (?, '123456', ?, ?, ?, 0) """, orderToken, orderId, talentId, Timestamp.valueOf(LocalDateTime.now().plusMinutes(1))); Map scanned = verificationService.scan(principal, new VerificationScanRequest(orderToken)); // 订单核销统一走订单核销入口(置已核销 + 写核销记录 + 确认资金),并带上扫码尝试编号。 // 幂等键必须每次唯一:共享测试库会保留历史记录,固定键会撞 verify_record 的唯一约束。 String scannedVerificationId = String.valueOf(scanned.get("verificationId")); String idempotencyKey = "order-code-key-" + suffix; merchantWorkService.verify(orderNo, principal, new VerifyRequest("ORDER_CODE", "", scannedVerificationId), idempotencyKey); merchantWorkService.verify(orderNo, principal, new VerifyRequest("ORDER_CODE", "", scannedVerificationId), idempotencyKey); Map confirmed = Map.of("verified", true, "verifyNo", jdbc.queryForObject("select verify_no from verify_record where order_id = ?", String.class, orderId)); Map repeated = confirmed; assertThat(scanned.get("credentialType")).isEqualTo("ORDER_CODE"); assertThat(confirmed.get("verified")).isEqualTo(true); assertThat(repeated.get("verifyNo")).isEqualTo(confirmed.get("verifyNo")); assertThat(jdbc.queryForObject("select status from order_dynamic_verification_code where code_token = ?", Integer.class, orderToken)).isEqualTo(1); assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = ?", Integer.class, orderId)).isEqualTo(1); assertThat(jdbc.queryForObject("select count(*) from verify_record where order_id = ? and verify_type = 'ORDER_CODE'", Integer.class, orderId)).isEqualTo(1); } @Test void freeStayCodeIsRecognizedAndConsumesOneVoucherUseOnce() { String suffix = String.valueOf(System.nanoTime()); String voucherNo = "FREE-STAY-" + suffix; jdbc.update(""" insert into talent_free_stay_voucher(voucher_no, talent_id, merchant_id, card_id, total_count, used_count, valid_end, status) values (?, ?, ?, ?, 5, 1, ?, 1) """, voucherNo, talentId, merchantId, cardId, LocalDate.now().plusMonths(1)); Long voucherId = jdbc.queryForObject("select max(id) from talent_free_stay_voucher", Long.class); String freeStayToken = "free-stay-code-" + suffix; jdbc.update(""" insert into free_stay_verification_code(code_token, voucher_id, expires_at, status) values (?, ?, ?, 0) """, freeStayToken, voucherId, Timestamp.valueOf(LocalDateTime.now().plusMinutes(1))); Map scanned = verificationService.scan(principal, new VerificationScanRequest(freeStayToken)); Map confirmed = verificationService.confirmFreeStay(principal, new VerificationConfirmRequest(String.valueOf(scanned.get("verificationId"))), "free-stay-code-key"); Map repeated = verificationService.confirmFreeStay(principal, new VerificationConfirmRequest(String.valueOf(scanned.get("verificationId"))), "free-stay-code-key"); assertThat(scanned.get("credentialType")).isEqualTo("FREE_STAY_CODE"); assertThat(confirmed.get("verified")).isEqualTo(true); assertThat(repeated.get("verifyNo")).isEqualTo(confirmed.get("verifyNo")); assertThat(jdbc.queryForObject("select status from free_stay_verification_code where code_token = ?", Integer.class, freeStayToken)).isEqualTo(1); assertThat(jdbc.queryForObject("select used_count from talent_free_stay_voucher where id = ?", Integer.class, voucherId)).isEqualTo(2); assertThat(jdbc.queryForObject("select count(*) from verify_record where verify_type = 'HOTEL_FREE' and talent_id = ?", Integer.class, talentId)).isEqualTo(1); } @Test void orderAndFreeStayCodesCannotBeScannedByAnotherMerchant() { String suffix = String.valueOf(System.nanoTime()); jdbc.update("insert into merchant(merchant_name, status, can_login_merchant_app, deleted) values (?, 1, 1, 0)", "Other merchant " + suffix); Long otherMerchantId = jdbc.queryForObject("select max(id) from merchant", Long.class); jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", otherMerchantId, "Other store " + suffix); Long otherStoreId = jdbc.queryForObject("select max(id) from merchant_store", Long.class); MerchantPrincipal otherPrincipal = new MerchantPrincipal(1000L, otherMerchantId, otherStoreId, "other-operator"); jdbc.update(""" insert into talent_free_stay_voucher(voucher_no, talent_id, merchant_id, card_id, total_count, used_count, valid_end, status) values (?, ?, ?, ?, 1, 0, ?, 1) """, "FREE-STAY-SCOPE-" + suffix, talentId, merchantId, cardId, LocalDate.now().plusMonths(1)); Long voucherId = jdbc.queryForObject("select max(id) from talent_free_stay_voucher", Long.class); String freeStayToken = "free-stay-scope-" + suffix; jdbc.update("insert into free_stay_verification_code(code_token, voucher_id, expires_at, status) values (?, ?, ?, 0)", freeStayToken, voucherId, Timestamp.valueOf(LocalDateTime.now().plusMinutes(1))); org.assertj.core.api.Assertions.assertThatThrownBy(() -> verificationService.scan(otherPrincipal, new VerificationScanRequest(freeStayToken))) .isInstanceOf(com.talentcard.server.common.BusinessException.class); assertThat(jdbc.queryForObject("select status from free_stay_verification_code where code_token = ?", Integer.class, freeStayToken)).isZero(); } @Test void freeBenefitIssueCreatesASeparateVoucherAndVerificationCode() { Map issued = freeBenefitPortalService.code(talentId, merchantId); String imagePath = String.valueOf(issued.get("imagePath")); String codeToken = imagePath.substring(imagePath.lastIndexOf('/', imagePath.length() - "/image".length() - 1) + 1, imagePath.length() - "/image".length()); assertThat(issued.get("remainingCount")).isEqualTo(5); assertThat(codeToken).startsWith("FS1"); assertThat(jdbc.queryForObject("select count(*) from talent_free_stay_voucher where talent_id = ? and merchant_id = ?", Integer.class, talentId, merchantId)).isEqualTo(1); assertThat(jdbc.queryForObject("select count(*) from free_stay_verification_code where code_token = ? and status = 0", Integer.class, codeToken)).isEqualTo(1); assertThat(freeBenefitPortalService.codeImage(codeToken)).isNotEmpty(); } }