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 java.util.List; import java.util.Map; import com.talentcard.server.business.BusinessIdGenerator; import com.talentcard.server.business.DatabaseRows; import com.talentcard.server.business.merchant.auth.MerchantPrincipal; 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; @Service public class MerchantVerificationService { private static final String TALENT_CODE = "TALENT_CODE"; private static final String ORDER_CODE = "ORDER_CODE"; private static final String FREE_STAY_CODE = "FREE_STAY_CODE"; private final JdbcTemplate jdbc; private final ManualConsumptionService manualConsumptionService; public MerchantVerificationService(JdbcTemplate jdbc, ManualConsumptionService manualConsumptionService) { this.jdbc = jdbc; this.manualConsumptionService = manualConsumptionService; } /** * A QR token is opaque to the client. Its source table is the authority for its type and scope. */ @Transactional public Map scan(MerchantPrincipal principal, VerificationScanRequest request) { String token = request.scanResult().trim(); Map talentCode = findTalentCode(token); if (talentCode != null) { return createTalentAttempt(principal, talentCode); } Map orderCode = findOrderCode(token, principal); if (orderCode != null) { return createOrderAttempt(principal, orderCode); } Map freeStayCode = findFreeStayCode(token, principal); if (freeStayCode != null) { return createFreeStayAttempt(principal, freeStayCode); } throw new BusinessException(ErrorCode.CONFLICT, "二维码已失效、不可核销或不属于当前商户"); } @Transactional public Map confirm(MerchantPrincipal principal, VerificationConfirmRequest request, String idempotencyKey) { if (idempotencyKey == null || idempotencyKey.isBlank()) { throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键"); } List> attempts = rows(""" select id attempt_id, credential_type, dynamic_code_id, order_verification_code_id, free_stay_verification_code_id, status attempt_status, verify_no, expires_at from merchant_verification_attempt where verification_id = ? and merchant_id = ? and store_id = ? and operator_id = ? limit 1 for update """, request.verificationId().trim(), principal.merchantId(), principal.storeId(), principal.operatorId()); if (attempts.isEmpty()) { throw new BusinessException(ErrorCode.NOT_FOUND, "核销凭证不存在或无权操作"); } Map attempt = attempts.get(0); if (number(attempt.get("attemptStatus")) == 1) { return confirmed(attempt.get("verifyNo"), null); } String credentialType = String.valueOf(attempt.get("credentialType")); boolean codeExpiryRequired = !ORDER_CODE.equals(credentialType) && !FREE_STAY_CODE.equals(credentialType); if (number(attempt.get("attemptStatus")) != 0 || (codeExpiryRequired && expired(attempt.get("expiresAt")))) { jdbc.update("update merchant_verification_attempt set status = 2 where id = ? and status = 0", number(attempt.get("attemptId"))); throw new BusinessException(ErrorCode.CONFLICT, "核销凭证已失效,请重新扫码"); } 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, "未知核销凭证类型"); }; } private Map createTalentAttempt(MerchantPrincipal principal, Map code) { ensureActiveCard(code); Long expectedMerchantId = number(code.get("expectedMerchantId")); if (expectedMerchantId != null && !expectedMerchantId.equals(principal.merchantId())) { throw new BusinessException(ErrorCode.CONFLICT, "该人才码不可在当前商户核销"); } if (!manualConsumptionService.hasApplicableRule(principal.merchantId(), value(code.get("cardLevel"), ""), value(code.get("sourceAreaType"), ""))) { throw new BusinessException(ErrorCode.CONFLICT, "当前商户未配置适用于该人才卡的到店消费权益"); } 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"), 0L); } private Map createOrderAttempt(MerchantPrincipal principal, Map 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"), 0L); } private Map createFreeStayAttempt(MerchantPrincipal principal, Map 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")); Map 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 code, MerchantPrincipal principal) { try { List> 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) { String verificationId = BusinessIdGenerator.next("MV"); jdbc.update(""" insert into merchant_verification_attempt(verification_id, dynamic_code_id, credential_type, order_verification_code_id, free_stay_verification_code_id, merchant_id, store_id, operator_id, expires_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?) """, verificationId, dynamicCodeId, type, orderCodeId, freeStayCodeId, principal.merchantId(), principal.storeId(), principal.operatorId(), expiresAt); return verificationId; } private Map confirmTalentCode(MerchantPrincipal principal, Map attempt) { List> codes = rows(""" select d.id dynamic_code_id, d.card_id, d.talent_id, c.card_level, d.expires_at, c.status card_status, c.valid_end, t.status talent_status from talent_dynamic_code d join talent_card c on c.id = d.card_id and c.deleted = 0 join talent_user t on t.id = d.talent_id and t.deleted = 0 where d.id = ? and d.status = 0 limit 1 for update """, number(attempt.get("dynamicCodeId"))); if (codes.isEmpty()) { throw new BusinessException(ErrorCode.CONFLICT, "人才码已被使用或已失效"); } Map code = codes.get(0); ensureActiveCard(code); int updated = jdbc.update(""" update talent_dynamic_code set status = 1, merchant_id = ?, used_at = now() where id = ? and status = 0 and expires_at > now() """, principal.merchantId(), number(code.get("dynamicCodeId"))); if (updated == 0) { throw new BusinessException(ErrorCode.CONFLICT, "人才码已被使用或已失效"); } String verifyNo = insertRecord(principal, null, number(code.get("talentId")), number(code.get("cardId")), null, TALENT_CODE, 0L, 0L, null); finishAttempt(attempt, verifyNo); return confirmed(verifyNo, code.get("cardLevel")); } private Map confirmOrderCode(MerchantPrincipal principal, Map attempt) { List> 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 in (0, 2) limit 1 for update """, number(attempt.get("orderVerificationCodeId"))); if (codes.isEmpty()) { throw new BusinessException(ErrorCode.CONFLICT, "订单核销码已被使用或已失效"); } Map code = codes.get(0); if (!belongsToPrincipal(code, principal) || number(code.get("orderStatus")) != 1 || number(code.get("verifyStatus")) != 0) { throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可核销"); } int codeUpdated = jdbc.update(""" update order_dynamic_verification_code set status = 1 where id = ? and status in (0, 2) """, 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 confirmFreeStayCode(MerchantPrincipal principal, Map attempt) { List> codes = rows(""" select c.id free_stay_verification_code_id, c.expires_at, v.id voucher_id, v.talent_id, v.card_id, v.merchant_id, v.total_count, v.used_count, v.valid_end, v.status voucher_status, tc.card_level 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 in (0, 2) limit 1 for update """, number(attempt.get("freeStayVerificationCodeId"))); if (codes.isEmpty()) { throw new BusinessException(ErrorCode.CONFLICT, "免费入住核销码已被使用或已失效"); } Map 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"))) { throw new BusinessException(ErrorCode.CONFLICT, "免费入住券当前不可核销"); } int codeUpdated = jdbc.update(""" update free_stay_verification_code set status = 1 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 where id = ? and status = 1 and valid_end >= current_date and used_count < total_count """, number(code.get("voucherId"))); 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, subsidyAmount, "免费入住券(平台线下补贴)"); finishAttempt(attempt, verifyNo); return confirmed(verifyNo, code.get("cardLevel")); } private String insertRecord(MerchantPrincipal principal, Long orderId, Long talentId, Long cardId, Long benefitId, String verifyType, Long verifyAmount, Long subsidyAmount, String remark) { String verifyNo = BusinessIdGenerator.next("V"); jdbc.update(""" insert into verify_record(verify_no, order_id, talent_id, card_id, benefit_id, merchant_id, store_id, staff_id, verify_type, verify_amount, subsidy_amount, remark) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, verifyNo, orderId, talentId, cardId, benefitId, principal.merchantId(), principal.storeId(), principal.operatorId(), verifyType, verifyAmount, subsidyAmount, remark); return verifyNo; } private void finishAttempt(Map attempt, String verifyNo) { jdbc.update(""" update merchant_verification_attempt set status = 1, verify_no = ?, confirmed_at = now() where id = ? and status = 0 """, verifyNo, number(attempt.get("attemptId"))); } private Map findTalentCode(String token) { List> codes = rows(""" select d.id dynamic_code_id, d.expected_merchant_id, d.expires_at, c.id card_id, c.card_level, c.status card_status, c.valid_end, t.id talent_id, t.name talent_name, t.mobile talent_mobile, t.status talent_status, t.source_area_type from talent_dynamic_code d join talent_card c on c.id = d.card_id and c.deleted = 0 join talent_user t on t.id = d.talent_id and t.deleted = 0 where d.code_token = ? and d.status = 0 and d.expires_at > now() limit 1 """, token); return codes.isEmpty() ? null : codes.get(0); } private Map findOrderCode(String token, MerchantPrincipal principal) { List> 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, b.benefit_name, '订单权益') 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 benefit b on b.id = o.benefit_id 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.code_token = ? 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 1 """, token, principal.merchantId(), principal.storeId()); return codes.isEmpty() ? null : codes.get(0); } private Map findFreeStayCode(String token, MerchantPrincipal principal) { List> codes = rows(""" select c.id free_stay_verification_code_id, c.expires_at, v.total_count, v.used_count, v.valid_end, v.status voucher_status, v.merchant_id, t.name talent_name, t.mobile talent_mobile, tc.card_level from free_stay_verification_code c join talent_free_stay_voucher v on v.id = c.voucher_id join talent_user t on t.id = v.talent_id and t.deleted = 0 left join talent_card tc on tc.id = v.card_id and tc.deleted = 0 where c.code_token = ? and c.status = 0 and c.expires_at > now() and v.merchant_id = ? and v.status = 1 and v.valid_end >= current_date and v.used_count < v.total_count limit 1 """, token, principal.merchantId()); return codes.isEmpty() ? null : codes.get(0); } private Map scanResult(String verificationId, String credentialType, String message, Object talentName, Object talentMobile, Object cardLevel, Object orderNo, Object expiresAt, Long subsidyAmount) { Map result = new LinkedHashMap<>(); result.put("verificationId", verificationId); result.put("credentialType", credentialType); result.put("status", "pending_confirmation"); result.put("title", "核验通过"); result.put("message", message); result.put("canVerify", true); result.put("customerName", maskName(value(talentName, ""))); 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> 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 confirmed(Object verifyNo, Object cardLevel) { Map result = new LinkedHashMap<>(); result.put("verified", true); result.put("verifyNo", verifyNo == null ? "" : String.valueOf(verifyNo)); result.put("status", "verified"); result.put("message", "核销成功"); result.put("cardLevel", cardLevel == null ? "人才卡" : String.valueOf(cardLevel)); result.put("verifiedAt", LocalDateTime.now().toString()); return result; } private boolean belongsToPrincipal(Map order, MerchantPrincipal principal) { Long storeId = number(order.get("storeId")); return principal.merchantId().equals(number(order.get("merchantId"))) && (storeId == null || principal.storeId().equals(storeId)); } private void ensureActiveCard(Map code) { if (!Long.valueOf(1L).equals(number(code.get("talentStatus"))) || !Long.valueOf(1L).equals(number(code.get("cardStatus"))) || expiredDate(code.get("validEnd"))) { throw new BusinessException(ErrorCode.CONFLICT, "人才卡当前不可核销"); } } private boolean expired(Object value) { return value instanceof LocalDateTime dateTime && !dateTime.isAfter(LocalDateTime.now()); } private boolean expiredDate(Object value) { return value instanceof LocalDate date && date.isBefore(LocalDate.now()); } private Long number(Object value) { return value instanceof Number number ? number.longValue() : null; } private String value(Object value, String fallback) { return value == null ? fallback : String.valueOf(value); } private String maskName(String name) { if (name == null || name.isBlank()) return "*"; return name.length() == 1 ? name + "*" : name.substring(0, 1) + "*".repeat(name.length() - 1); } private String maskMobile(String mobile) { if (mobile == null || mobile.length() < 7) return ""; return mobile.substring(0, 3) + "****" + mobile.substring(mobile.length() - 4); } private List> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); } }