package com.talentcard.server.business.merchant;
|
|
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.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<String, Object> scan(MerchantPrincipal principal, VerificationScanRequest request) {
|
String token = request.scanResult().trim();
|
Map<String, Object> talentCode = findTalentCode(token);
|
if (talentCode != null) {
|
return createTalentAttempt(principal, talentCode);
|
}
|
Map<String, Object> orderCode = findOrderCode(token, principal);
|
if (orderCode != null) {
|
return createOrderAttempt(principal, orderCode);
|
}
|
Map<String, Object> freeStayCode = findFreeStayCode(token, principal);
|
if (freeStayCode != null) {
|
return createFreeStayAttempt(principal, freeStayCode);
|
}
|
throw new BusinessException(ErrorCode.CONFLICT, "二维码已失效、不可核销或不属于当前商户");
|
}
|
|
@Transactional
|
public Map<String, Object> confirm(MerchantPrincipal principal, VerificationConfirmRequest request, String idempotencyKey) {
|
if (idempotencyKey == null || idempotencyKey.isBlank()) {
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键");
|
}
|
List<Map<String, Object>> 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<String, Object> 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<String, Object> createTalentAttempt(MerchantPrincipal principal, Map<String, Object> 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"));
|
}
|
|
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"));
|
}
|
|
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"));
|
}
|
|
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<String, Object> confirmTalentCode(MerchantPrincipal principal, Map<String, Object> attempt) {
|
List<Map<String, Object>> 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<String, Object> 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<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 in (0, 2)
|
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) {
|
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<String, Object> confirmFreeStayCode(MerchantPrincipal principal, Map<String, Object> attempt) {
|
List<Map<String, Object>> 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<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"))) {
|
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, "免费入住券已被使用或已失效");
|
}
|
String verifyNo = insertRecord(principal, null, number(code.get("talentId")), number(code.get("cardId")), null,
|
"HOTEL_FREE", 0L, 0L, "免费入住券");
|
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<String, Object> 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<String, Object> findTalentCode(String token) {
|
List<Map<String, Object>> 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<String, Object> findOrderCode(String token, MerchantPrincipal 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, 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<String, Object> findFreeStayCode(String token, MerchantPrincipal principal) {
|
List<Map<String, Object>> 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<String, Object> scanResult(String verificationId, String credentialType, String message,
|
Object talentName, Object talentMobile, Object cardLevel, Object orderNo, Object expiresAt) {
|
Map<String, Object> 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("expiresAt", expiresAt);
|
return result;
|
}
|
|
private Map<String, Object> confirmed(Object verifyNo, Object cardLevel) {
|
Map<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> rows(String sql, Object... args) {
|
return DatabaseRows.normalize(jdbc.queryForList(sql, args));
|
}
|
}
|