| | |
| | | package com.talentcard.server.business.merchant; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.math.RoundingMode; |
| | | import java.time.LocalDate; |
| | | import java.time.LocalDateTime; |
| | | import java.util.LinkedHashMap; |
| | |
| | | import com.talentcard.server.business.BusinessIdGenerator; |
| | | import com.talentcard.server.business.DatabaseRows; |
| | | import com.talentcard.server.business.merchant.auth.MerchantPrincipal; |
| | | import com.talentcard.server.business.order.OrderVerificationService; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import com.talentcard.server.common.ErrorCode; |
| | | import org.springframework.dao.DataAccessException; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | |
| | | private static final String ORDER_CODE = "ORDER_CODE"; |
| | | private static final String FREE_STAY_CODE = "FREE_STAY_CODE"; |
| | | |
| | | /** |
| | | * 6 位数字核销码:顾客屏幕上的数字码,相机不好用或屏幕反光时手输兜底。 |
| | | * |
| | | * <p>它就是订单核销码的另一副面孔(同一张 {@code order_dynamic_verification_code} 的 |
| | | * {@code display_code}),因此有效期与动态码完全相同;但它只有 100 万空间,必须限次, |
| | | * 见 {@link MerchantNumericCodeAttemptGuard}。 |
| | | */ |
| | | private static final java.util.regex.Pattern NUMERIC_CODE = java.util.regex.Pattern.compile("\\d{6}"); |
| | | |
| | | private final JdbcTemplate jdbc; |
| | | private final ManualConsumptionService manualConsumptionService; |
| | | private final OrderVerificationService orderVerificationService; |
| | | private final MerchantNumericCodeAttemptGuard numericCodeGuard; |
| | | |
| | | public MerchantVerificationService(JdbcTemplate jdbc, ManualConsumptionService manualConsumptionService) { |
| | | public MerchantVerificationService(JdbcTemplate jdbc, ManualConsumptionService manualConsumptionService, |
| | | OrderVerificationService orderVerificationService, |
| | | MerchantNumericCodeAttemptGuard numericCodeGuard) { |
| | | this.jdbc = jdbc; |
| | | this.manualConsumptionService = manualConsumptionService; |
| | | this.orderVerificationService = orderVerificationService; |
| | | this.numericCodeGuard = numericCodeGuard; |
| | | } |
| | | |
| | | /** |
| | | * A QR token is opaque to the client. Its source table is the authority for its type and scope. |
| | | * |
| | | * <p>纯 6 位数字的输入走手输核销码那条路:码令牌是 32 位十六进制,不可能是 6 位数字。 |
| | | */ |
| | | @Transactional |
| | | public Map<String, Object> scan(MerchantPrincipal principal, VerificationScanRequest request) { |
| | | String token = request.scanResult().trim(); |
| | | if (NUMERIC_CODE.matcher(token).matches()) { |
| | | return scanNumericCode(principal, token); |
| | | } |
| | | Map<String, Object> talentCode = findTalentCode(token); |
| | | if (talentCode != null) { |
| | | return createTalentAttempt(principal, talentCode); |
| | |
| | | throw new BusinessException(ErrorCode.CONFLICT, "二维码已失效、不可核销或不属于当前商户"); |
| | | } |
| | | |
| | | /** |
| | | * 手输 6 位数字核销码。 |
| | | * |
| | | * <p>{@code display_code} 只有 100 万空间,同一时刻完全可能有两张活着的码撞在一起,所以按 |
| | | * {@code limit 2} 取行:拿不到**唯一**一张就拒绝。而「猜错(0 条)」与「撞码(2 条)」必须用同一句话 |
| | | * 回答——否则攻击者只要比较这两种答复,就知道「这个号确实有主」,那正是枚举要的探针。 |
| | | * |
| | | * <p>答复里**不带订单号**:客户就在眼前,商户不需要靠接口知道这个码属于哪笔订单;带回去却等于给 |
| | | * 枚举提供了一张「号码 → 订单」的映射表。确认核销时由服务端从扫码尝试里把订单找回来。 |
| | | */ |
| | | private Map<String, Object> scanNumericCode(MerchantPrincipal principal, String code) { |
| | | numericCodeGuard.requireUsable(principal); |
| | | List<Map<String, Object>> codes = rows(""" |
| | | select c.id order_verification_code_id, c.expires_at, o.order_no, o.order_status, o.verify_status, |
| | | o.merchant_id, o.store_id, coalesce(o.product_name_snapshot, '订单权益') product_name, |
| | | t.name talent_name, t.mobile talent_mobile, tc.card_level |
| | | from order_dynamic_verification_code c |
| | | join benefit_order o on o.id = c.order_id and o.deleted = 0 |
| | | left join talent_user t on t.id = o.talent_id and t.deleted = 0 |
| | | left join talent_card tc on tc.id = o.card_id and tc.deleted = 0 |
| | | where c.display_code = ? and c.status = 0 and c.expires_at > now() |
| | | and o.merchant_id = ? and (o.store_id is null or o.store_id = ?) |
| | | and o.order_status = 1 and o.verify_status = 0 |
| | | limit 2 |
| | | """, code, principal.merchantId(), principal.storeId()); |
| | | if (codes.size() != 1) { |
| | | numericCodeGuard.recordFailure(principal); |
| | | throw new BusinessException(ErrorCode.CONFLICT, "核销码无效或已过期,请核对后重新输入"); |
| | | } |
| | | numericCodeGuard.recordSuccess(principal); |
| | | Map<String, Object> found = codes.get(0); |
| | | String verificationId = createAttempt(principal, ORDER_CODE, null, |
| | | number(found.get("orderVerificationCodeId")), null, found.get("expiresAt")); |
| | | Map<String, Object> result = scanResult(verificationId, ORDER_CODE, "核销码有效,请确认后完成核销", |
| | | found.get("talentName"), found.get("talentMobile"), found.get("cardLevel"), null, found.get("expiresAt"), 0L); |
| | | // 连字段都不留:空字符串容易被客户端当成「没查到」,直接不给这个键更清楚。 |
| | | result.remove("orderNo"); |
| | | result.put("numericEntry", true); |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 免费入住券核销:与支付无关的独立业务——用户不付钱,核销只扣券次数,补贴由平台线下结算。 |
| | | * |
| | | * <p>订单核销不走这里:它必须写核销记录并确认延迟入账的资金,统一在 |
| | | * {@link OrderVerificationService#verifyByMerchant} 里实现,避免两处各写一遍导致漏改。 |
| | | */ |
| | | @Transactional |
| | | public Map<String, Object> confirm(MerchantPrincipal principal, VerificationConfirmRequest request, String idempotencyKey) { |
| | | public Map<String, Object> confirmFreeStay(MerchantPrincipal principal, VerificationConfirmRequest request, String idempotencyKey) { |
| | | if (idempotencyKey == null || idempotencyKey.isBlank()) { |
| | | throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键"); |
| | | } |
| | |
| | | if (number(attempt.get("attemptStatus")) == 1) { |
| | | return confirmed(attempt.get("verifyNo"), null); |
| | | } |
| | | if (number(attempt.get("attemptStatus")) != 0 || expired(attempt.get("expiresAt"))) { |
| | | String credentialType = String.valueOf(attempt.get("credentialType")); |
| | | if (!FREE_STAY_CODE.equals(credentialType)) { |
| | | // 订单核销码要确认资金,必须走订单核销接口;人才码要先发单。 |
| | | throw new BusinessException(ErrorCode.CONFLICT, "该凭证不能在这里核销,请使用对应的核销入口"); |
| | | } |
| | | // 免费入住券不看扫码码的有效期:码过期只影响扫码时机,券本身的 valid_end 才决定能不能核销 |
| | | // (confirmFreeStayCode 里会校验)。这是既有行为,不要收紧。 |
| | | if (number(attempt.get("attemptStatus")) != 0) { |
| | | jdbc.update("update merchant_verification_attempt set status = 2 where id = ? and status = 0", number(attempt.get("attemptId"))); |
| | | throw new BusinessException(ErrorCode.CONFLICT, "核销凭证已失效,请重新扫码"); |
| | | } |
| | | String credentialType = String.valueOf(attempt.get("credentialType")); |
| | | return switch (credentialType) { |
| | | case TALENT_CODE -> throw new BusinessException(ErrorCode.CONFLICT, "人才码需要先录入消费金额并发送订单"); |
| | | case ORDER_CODE -> confirmOrderCode(principal, attempt); |
| | | case FREE_STAY_CODE -> confirmFreeStayCode(principal, attempt); |
| | | default -> throw new BusinessException(ErrorCode.CONFLICT, "未知核销凭证类型"); |
| | | }; |
| | | return confirmFreeStayCode(principal, attempt); |
| | | } |
| | | |
| | | private Map<String, Object> createTalentAttempt(MerchantPrincipal principal, Map<String, Object> code) { |
| | |
| | | } |
| | | String verificationId = createAttempt(principal, TALENT_CODE, number(code.get("dynamicCodeId")), null, null, code.get("expiresAt")); |
| | | return scanResult(verificationId, TALENT_CODE, "人才码有效,请录入消费金额并发送待支付订单", code.get("talentName"), |
| | | code.get("talentMobile"), code.get("cardLevel"), null, code.get("expiresAt")); |
| | | code.get("talentMobile"), code.get("cardLevel"), null, code.get("expiresAt"), 0L); |
| | | } |
| | | |
| | | private Map<String, Object> createOrderAttempt(MerchantPrincipal principal, Map<String, Object> code) { |
| | | String verificationId = createAttempt(principal, ORDER_CODE, null, number(code.get("orderVerificationCodeId")), null, code.get("expiresAt")); |
| | | String title = value(code.get("productName"), "订单权益"); |
| | | return scanResult(verificationId, ORDER_CODE, "订单核销码有效,请确认后完成核销:" + title, |
| | | code.get("talentName"), code.get("talentMobile"), code.get("cardLevel"), code.get("orderNo"), code.get("expiresAt")); |
| | | code.get("talentName"), code.get("talentMobile"), code.get("cardLevel"), code.get("orderNo"), code.get("expiresAt"), 0L); |
| | | } |
| | | |
| | | private Map<String, Object> createFreeStayAttempt(MerchantPrincipal principal, Map<String, Object> code) { |
| | | Long remaining = number(code.get("totalCount")) - number(code.get("usedCount")); |
| | | String verificationId = createAttempt(principal, FREE_STAY_CODE, null, null, |
| | | number(code.get("freeStayVerificationCodeId")), code.get("expiresAt")); |
| | | return scanResult(verificationId, FREE_STAY_CODE, "免费入住券有效,剩余 " + remaining + " 次,请确认后核销", |
| | | code.get("talentName"), code.get("talentMobile"), code.get("cardLevel"), null, code.get("expiresAt")); |
| | | Map<String, Object> result = scanResult(verificationId, FREE_STAY_CODE, "免费入住券有效,剩余 " + remaining + " 次,请确认后核销", |
| | | code.get("talentName"), code.get("talentMobile"), code.get("cardLevel"), null, code.get("expiresAt"), |
| | | hotelSubsidyAmountCents()); |
| | | result.put("freeStayTotalCount", number(code.get("totalCount"))); |
| | | result.put("freeStayRemainingCount", Math.max(remaining, 0L)); |
| | | result.put("benefitText", freeStayBenefitText(code, principal)); |
| | | return result; |
| | | } |
| | | |
| | | private String freeStayBenefitText(Map<String, Object> code, MerchantPrincipal principal) { |
| | | try { |
| | | List<Map<String, Object>> benefits = rows(""" |
| | | select benefit_text |
| | | from merchant_card_benefit |
| | | where merchant_id = ? and card_level = ? and status = 1 |
| | | limit 1 |
| | | """, principal.merchantId(), value(code.get("cardLevel"), "")); |
| | | return benefits.isEmpty() ? "" : value(benefits.get(0).get("benefitText"), ""); |
| | | } catch (DataAccessException ignored) { |
| | | // Older installations may not have the optional display table yet. |
| | | return ""; |
| | | } |
| | | } |
| | | |
| | | private String createAttempt(MerchantPrincipal principal, String type, Long dynamicCodeId, Long orderCodeId, Long freeStayCodeId, Object expiresAt) { |
| | |
| | | return confirmed(verifyNo, code.get("cardLevel")); |
| | | } |
| | | |
| | | private Map<String, Object> confirmOrderCode(MerchantPrincipal principal, Map<String, Object> attempt) { |
| | | List<Map<String, Object>> codes = rows(""" |
| | | select c.id order_verification_code_id, c.expires_at, o.id order_id, o.talent_id, o.card_id, o.benefit_id, |
| | | o.order_no, o.order_amount, o.subsidy_amount, o.pay_amount, o.order_status, o.verify_status, |
| | | o.merchant_id, o.store_id, tc.card_level |
| | | from order_dynamic_verification_code c |
| | | join benefit_order o on o.id = c.order_id and o.deleted = 0 |
| | | left join talent_card tc on tc.id = o.card_id and tc.deleted = 0 |
| | | where c.id = ? and c.status = 0 |
| | | limit 1 for update |
| | | """, number(attempt.get("orderVerificationCodeId"))); |
| | | if (codes.isEmpty()) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单核销码已被使用或已失效"); |
| | | } |
| | | Map<String, Object> code = codes.get(0); |
| | | if (!belongsToPrincipal(code, principal) || number(code.get("orderStatus")) != 1 || number(code.get("verifyStatus")) != 0 || expired(code.get("expiresAt"))) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可核销"); |
| | | } |
| | | int codeUpdated = jdbc.update(""" |
| | | update order_dynamic_verification_code set status = 1 |
| | | where id = ? and status = 0 and expires_at > now() |
| | | """, number(code.get("orderVerificationCodeId"))); |
| | | int orderUpdated = jdbc.update(""" |
| | | update benefit_order set verify_status = 1 |
| | | where id = ? and order_status = 1 and verify_status = 0 |
| | | """, number(code.get("orderId"))); |
| | | if (codeUpdated == 0 || orderUpdated == 0) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已被核销或已失效"); |
| | | } |
| | | String verifyNo = insertRecord(principal, number(code.get("orderId")), number(code.get("talentId")), |
| | | number(code.get("cardId")), number(code.get("benefitId")), ORDER_CODE, |
| | | number(code.get("payAmount")), number(code.get("subsidyAmount")), "订单动态核销码"); |
| | | finishAttempt(attempt, verifyNo); |
| | | return confirmed(verifyNo, code.get("cardLevel")); |
| | | } |
| | | |
| | | private Map<String, Object> confirmFreeStayCode(MerchantPrincipal principal, Map<String, Object> attempt) { |
| | | List<Map<String, Object>> codes = rows(""" |
| | |
| | | from free_stay_verification_code c |
| | | join talent_free_stay_voucher v on v.id = c.voucher_id |
| | | left join talent_card tc on tc.id = v.card_id and tc.deleted = 0 |
| | | where c.id = ? and c.status = 0 |
| | | where c.id = ? and c.status in (0, 2) |
| | | limit 1 for update |
| | | """, number(attempt.get("freeStayVerificationCodeId"))); |
| | | if (codes.isEmpty()) { |
| | |
| | | } |
| | | Map<String, Object> code = codes.get(0); |
| | | if (!principal.merchantId().equals(number(code.get("merchantId"))) || number(code.get("voucherStatus")) != 1 |
| | | || expiredDate(code.get("validEnd")) || number(code.get("usedCount")) >= number(code.get("totalCount")) || expired(code.get("expiresAt"))) { |
| | | || expiredDate(code.get("validEnd")) || number(code.get("usedCount")) >= number(code.get("totalCount"))) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "免费入住券当前不可核销"); |
| | | } |
| | | int codeUpdated = jdbc.update(""" |
| | | update free_stay_verification_code set status = 1 |
| | | where id = ? and status = 0 and expires_at > now() |
| | | where id = ? and status in (0, 2) |
| | | """, number(code.get("freeStayVerificationCodeId"))); |
| | | int voucherUpdated = jdbc.update(""" |
| | | update talent_free_stay_voucher set used_count = used_count + 1 |
| | |
| | | if (codeUpdated == 0 || voucherUpdated == 0) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "免费入住券已被使用或已失效"); |
| | | } |
| | | Long subsidyAmount = hotelSubsidyAmountCents(); |
| | | String verifyNo = insertRecord(principal, null, number(code.get("talentId")), number(code.get("cardId")), null, |
| | | "HOTEL_FREE", 0L, 0L, "免费入住券"); |
| | | "HOTEL_FREE", 0L, subsidyAmount, "免费入住券(平台线下补贴)"); |
| | | finishAttempt(attempt, verifyNo); |
| | | return confirmed(verifyNo, code.get("cardLevel")); |
| | | } |
| | |
| | | } |
| | | |
| | | private Map<String, Object> scanResult(String verificationId, String credentialType, String message, |
| | | Object talentName, Object talentMobile, Object cardLevel, Object orderNo, Object expiresAt) { |
| | | Object talentName, Object talentMobile, Object cardLevel, Object orderNo, |
| | | Object expiresAt, Long subsidyAmount) { |
| | | Map<String, Object> result = new LinkedHashMap<>(); |
| | | result.put("verificationId", verificationId); |
| | | result.put("credentialType", credentialType); |
| | |
| | | result.put("customerMobile", maskMobile(value(talentMobile, ""))); |
| | | result.put("cardLevel", cardLevel == null ? "人才卡" : String.valueOf(cardLevel)); |
| | | result.put("orderNo", orderNo == null ? "" : String.valueOf(orderNo)); |
| | | result.put("subsidyAmount", subsidyAmount == null ? 0L : subsidyAmount); |
| | | result.put("expiresAt", expiresAt); |
| | | return result; |
| | | } |
| | | |
| | | private Long hotelSubsidyAmountCents() { |
| | | List<Map<String, Object>> settings = rows(""" |
| | | select setting_value |
| | | from admin_setting |
| | | where setting_key = 'hotelSubsidy' |
| | | limit 1 |
| | | """); |
| | | if (settings.isEmpty()) { |
| | | return 0L; |
| | | } |
| | | String configuredAmount = value(settings.get(0).get("settingValue"), "").trim(); |
| | | if (configuredAmount.isEmpty()) { |
| | | return 0L; |
| | | } |
| | | try { |
| | | BigDecimal yuan = new BigDecimal(configuredAmount).setScale(2, RoundingMode.UNNECESSARY); |
| | | if (yuan.signum() < 0) { |
| | | throw new ArithmeticException("negative subsidy"); |
| | | } |
| | | return yuan.movePointRight(2).longValueExact(); |
| | | } catch (ArithmeticException | NumberFormatException exception) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "酒店免费入住补贴金额配置不正确,请联系平台管理员"); |
| | | } |
| | | } |
| | | |
| | | private Map<String, Object> confirmed(Object verifyNo, Object cardLevel) { |
| | | Map<String, Object> result = new LinkedHashMap<>(); |
| | | result.put("verified", true); |