package com.talentcard.server.service; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.talentcard.server.business.BusinessIdGenerator; import com.talentcard.server.business.DatabaseRows; import com.talentcard.server.business.PageResult; import com.talentcard.server.common.BusinessException; import com.talentcard.server.common.ErrorCode; import com.talentcard.server.entity.BenefitVerifyRecord; import com.talentcard.server.entity.TransportCarWashCredential; import com.talentcard.server.entity.TransportCarWashService; import com.talentcard.server.mapper.BenefitVerifyRecordMapper; import com.talentcard.server.mapper.TransportCarWashCredentialMapper; import com.talentcard.server.mapper.TransportCarWashServiceMapper; import com.talentcard.server.request.CarWashBenefitRuleRequest; import com.talentcard.server.request.CarWashCredentialAuditRequest; import com.talentcard.server.request.CarWashCredentialRollbackRequest; import com.talentcard.server.request.CarWashCredentialSubmitRequest; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import tools.jackson.core.type.TypeReference; import tools.jackson.databind.ObjectMapper; import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @Service public class CarWashBenefitManagementService { private final TransportCarWashCredentialMapper credentialMapper; private final TransportCarWashServiceMapper configMapper; private final BenefitVerifyRecordMapper verifyRecordMapper; private final ObjectMapper objectMapper; public CarWashBenefitManagementService(TransportCarWashCredentialMapper credentialMapper, TransportCarWashServiceMapper configMapper, BenefitVerifyRecordMapper verifyRecordMapper, ObjectMapper objectMapper) { this.credentialMapper = credentialMapper; this.configMapper = configMapper; this.verifyRecordMapper = verifyRecordMapper; this.objectMapper = objectMapper; } @Transactional public Map saveRules(Long merchantId, CarWashBenefitRuleRequest request) { requireMerchant(merchantId); List> rules = new ArrayList<>(); for (CarWashBenefitRuleRequest.Rule item : request.rules()) { String cycle = normalizeCycle(item.resetCycle()); Map rule = new LinkedHashMap<>(); rule.put("cardLevel", item.cardLevel().trim()); rule.put("totalCount", item.totalCount()); rule.put("resetCycle", cycle); rule.put("familyCardShared", Boolean.TRUE.equals(item.familyCardShared())); rules.add(rule); } String json; try { json = objectMapper.writeValueAsString(rules); } catch (Exception exception) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "洗车权益次数配置保存失败"); } TransportCarWashService config = configMapper.selectOne(new QueryWrapper() .eq("merchant_id", merchantId).eq("deleted", 0).last("limit 1 for update")); if (config == null) { config = new TransportCarWashService(); config.setMerchantId(merchantId); config.setBenefitRules(json); config.setStatus(1); config.setDeleted(0); if (configMapper.insert(config) != 1) throw new BusinessException(ErrorCode.INTERNAL_ERROR, "洗车权益次数配置保存失败"); } else if (configMapper.update(null, new UpdateWrapper() .set("benefit_rules", json).eq("id", config.getId()).eq("deleted", 0)) != 1) { throw new BusinessException(ErrorCode.CONFLICT, "洗车权益配置已变化,请刷新后重试"); } Map result = new LinkedHashMap<>(); result.put("merchantId", merchantId); result.put("rules", rules); return result; } @Transactional(readOnly = true) public Map rules(Long merchantId) { requireMerchant(merchantId); TransportCarWashService config = configMapper.selectOne(new QueryWrapper() .eq("merchant_id", merchantId).eq("deleted", 0).last("limit 1")); Map result = new LinkedHashMap<>(); result.put("merchantId", merchantId); result.put("rules", config == null ? List.of() : parseRules(config.getBenefitRules())); return result; } @Transactional public Map submit(Long merchantId, CarWashCredentialSubmitRequest request, String headerIdempotencyKey) { requireMerchant(merchantId); String key = first(request.idempotencyKey(), headerIdempotencyKey); if (key.isBlank() || key.length() > 128) throw new BusinessException(ErrorCode.BAD_REQUEST, "提交凭证不能为空且长度不能超过128"); TransportCarWashCredential duplicate = credentialMapper.selectOne(new QueryWrapper() .eq("idempotency_key", key).eq("deleted", 0).last("limit 1")); if (duplicate != null) return detail(duplicate.getId()); Map source = DatabaseRows.normalize(credentialMapper.selectActiveCard(request.talentId(), request.cardId())); if (source.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "人才卡不存在或当前不可用"); Long primaryTalentId = request.talentId(); Long primaryCardId = request.cardId(); String cardLevel = text(source, "cardLevel"); String cardType = text(source, "cardType"); if (!"PERSONAL".equalsIgnoreCase(cardType)) { Map primary = DatabaseRows.normalize(credentialMapper.selectPrimaryCard(request.cardId())); if (primary.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "亲属附属卡未找到有效主卡"); primaryTalentId = longValue(primary, "primaryTalentId"); primaryCardId = longValue(primary, "primaryCardId"); cardLevel = text(primary, "cardLevel"); } List files = request.proofFiles() == null ? List.of() : request.proofFiles().stream().map(this::clean).filter(v -> !v.isBlank()).distinct().toList(); for (String file : files) { if (!(file.startsWith("/uploads/") || file.startsWith("https://"))) { throw new BusinessException(ErrorCode.BAD_REQUEST, "凭证文件地址不正确"); } } TransportCarWashCredential credential = new TransportCarWashCredential(); credential.setCredentialNo(BusinessIdGenerator.next("CWC")); credential.setMerchantId(merchantId); credential.setTalentId(request.talentId()); credential.setCardId(request.cardId()); credential.setPrimaryTalentId(primaryTalentId); credential.setPrimaryCardId(primaryCardId); credential.setCardLevel(cardLevel); credential.setProofFiles(writeJson(files)); credential.setDescription(trimToNull(request.description())); credential.setConsumeTime(request.consumeTime()); credential.setStatus(0); credential.setDeductedCount(0); credential.setIdempotencyKey(key); credential.setSubmittedAt(LocalDateTime.now()); credential.setDeleted(0); try { if (credentialMapper.insert(credential) != 1) throw new BusinessException(ErrorCode.INTERNAL_ERROR, "洗车凭证提交失败"); } catch (DuplicateKeyException exception) { TransportCarWashCredential existing = credentialMapper.selectOne(new QueryWrapper() .eq("idempotency_key", key).eq("deleted", 0).last("limit 1")); if (existing != null) return detail(existing.getId()); throw exception; } return detail(credential.getId()); } @Transactional(readOnly = true) public PageResult> page(Long merchantId, Integer status, String keyword, LocalDate startDate, LocalDate endDate, int page, int size) { validateStatus(status); if (startDate != null && endDate != null && startDate.isAfter(endDate)) throw new BusinessException(ErrorCode.BAD_REQUEST, "开始日期不能晚于结束日期"); int safePage = Math.max(page, 1); int safeSize = Math.min(Math.max(size, 1), 100); LocalDateTime start = startDate == null ? null : startDate.atStartOfDay(); LocalDateTime end = endDate == null ? null : endDate.plusDays(1).atStartOfDay(); List> records = DatabaseRows.normalize(credentialMapper.selectPage( merchantId, status, clean(keyword), start, end, safeSize, (safePage - 1) * safeSize)).stream() .map(this::view).toList(); Long total = credentialMapper.countPage(merchantId, status, clean(keyword), start, end); return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total); } @Transactional(readOnly = true) public Map detail(Long id) { Map row = credentialMapper.selectDetail(id); if (row == null || row.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "洗车凭证记录不存在"); return view(DatabaseRows.normalize(row)); } @Transactional public Map audit(Long id, CarWashCredentialAuditRequest request, Long operatorId) { if (request.status() == null || (request.status() != 1 && request.status() != 2)) throw new BusinessException(ErrorCode.BAD_REQUEST, "审核状态只能为1通过或2驳回"); String remark = trimToNull(request.auditRemark()); if (request.status() == 2 && remark == null) throw new BusinessException(ErrorCode.BAD_REQUEST, "驳回时必须填写审核备注"); TransportCarWashCredential credential = credentialMapper.selectByIdForUpdate(id); if (credential == null) throw new BusinessException(ErrorCode.NOT_FOUND, "洗车凭证记录不存在"); if (!Integer.valueOf(0).equals(credential.getStatus())) throw new BusinessException(ErrorCode.CONFLICT, "该洗车凭证已经审核"); int quantity = request.quantity() == null ? 1 : request.quantity(); if (quantity < 1 || quantity > 100) throw new BusinessException(ErrorCode.BAD_REQUEST, "扣减次数必须在1到100之间"); Long verifyId = null; if (request.status() == 1) { Rule rule = rule(credential.getMerchantId(), credential.getCardLevel(), !credential.getCardId().equals(credential.getPrimaryCardId())); LocalDateTime windowStart = windowStart(rule.resetCycle()); long used = number(credentialMapper.countUsedQuota(credential.getPrimaryCardId(), windowStart)).longValue(); if (used + quantity > rule.totalCount()) throw new BusinessException(ErrorCode.CONFLICT, "洗车权益剩余次数不足"); BenefitVerifyRecord record = new BenefitVerifyRecord(); record.setVerifyNo(BusinessIdGenerator.next("CWV")); record.setTalentId(credential.getPrimaryTalentId()); record.setCardId(credential.getPrimaryCardId()); record.setMerchantId(credential.getMerchantId()); record.setStaffId(operatorId); record.setVerifyType("CAR_WASH"); record.setVerifyAmount((long) quantity); record.setSubsidyAmount(0L); record.setVerifyTime(LocalDateTime.now()); record.setRemark("洗车凭证人工审核通过:" + credential.getCredentialNo()); record.setStatus(1); record.setIdempotencyKey("CAR_WASH_CREDENTIAL:" + credential.getId()); if (verifyRecordMapper.insert(record) != 1) throw new BusinessException(ErrorCode.INTERNAL_ERROR, "洗车权益扣减流水保存失败"); verifyId = record.getId(); } else { quantity = 0; } credential.setStatus(request.status()); credential.setAuditBy(operatorId); credential.setAuditTime(LocalDateTime.now()); credential.setAuditRemark(remark); credential.setVerifyRecordId(verifyId); credential.setDeductedCount(quantity); if (credentialMapper.updateById(credential) != 1) throw new BusinessException(ErrorCode.CONFLICT, "洗车凭证状态已变化,请刷新后重试"); return detail(id); } @Transactional public Map rollback(Long id, CarWashCredentialRollbackRequest request, Long operatorId) { TransportCarWashCredential credential = credentialMapper.selectByIdForUpdate(id); if (credential == null) throw new BusinessException(ErrorCode.NOT_FOUND, "洗车凭证记录不存在"); if (!Integer.valueOf(1).equals(credential.getStatus()) || credential.getVerifyRecordId() == null) throw new BusinessException(ErrorCode.CONFLICT, "仅审核通过且已扣减的凭证可以回滚"); int updated = verifyRecordMapper.update(null, new UpdateWrapper() .set("status", 0).set("revoked_at", LocalDateTime.now()).set("revoked_by", operatorId) .set("revoke_reason", request.reason().trim()) .eq("id", credential.getVerifyRecordId()).eq("status", 1)); if (updated != 1) throw new BusinessException(ErrorCode.CONFLICT, "洗车权益流水已回滚,请刷新后重试"); credential.setStatus(3); credential.setRollbackBy(operatorId); credential.setRollbackTime(LocalDateTime.now()); credential.setRollbackReason(request.reason().trim()); if (credentialMapper.updateById(credential) != 1) throw new BusinessException(ErrorCode.CONFLICT, "洗车凭证状态已变化,请刷新后重试"); return detail(id); } private Rule rule(Long merchantId, String cardLevel, boolean familyCard) { TransportCarWashService config = configMapper.selectOne(new QueryWrapper() .eq("merchant_id", merchantId).eq("deleted", 0).eq("status", 1).last("limit 1")); if (config == null) throw new BusinessException(ErrorCode.CONFLICT, "洗车服务未启用"); for (Map item : parseRules(config.getBenefitRules())) { if (levelMatches(text(item, "cardLevel"), cardLevel)) { Rule rule = new Rule(number(item.get("totalCount")).intValue(), normalizeCycle(text(item, "resetCycle")), Boolean.TRUE.equals(item.get("familyCardShared"))); if (familyCard && !rule.familyCardShared()) throw new BusinessException(ErrorCode.CONFLICT, "亲属附属卡不可共享该洗车权益"); return rule; } } throw new BusinessException(ErrorCode.CONFLICT, "当前人才卡等级未配置洗车权益次数"); } private List> parseRules(String json) { if (json == null || json.isBlank()) return List.of(); try { return objectMapper.readValue(json, new TypeReference>>() {}); } catch (Exception exception) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "洗车权益次数配置格式不正确"); } } private String writeJson(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception exception) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "洗车凭证文件保存失败"); } } private Map view(Map row) { Map result = new LinkedHashMap<>(row); Object files = row.get("proofFiles"); if (files != null) { try { result.put("proofFiles", objectMapper.readValue(String.valueOf(files), new TypeReference>() {})); } catch (Exception ignored) { result.put("proofFiles", List.of()); } } int status = number(row.get("status")).intValue(); result.put("statusText", switch (status) { case 0 -> "待审核"; case 1 -> "已通过"; case 2 -> "已驳回"; case 3 -> "已回滚"; default -> "未知"; }); return result; } private LocalDateTime windowStart(String cycle) { LocalDate today = LocalDate.now(); return switch (normalizeCycle(cycle)) { case "DAY" -> today.atStartOfDay(); case "WEEK" -> today.with(DayOfWeek.MONDAY).atStartOfDay(); case "MONTH" -> today.withDayOfMonth(1).atStartOfDay(); case "YEAR" -> today.with(TemporalAdjusters.firstDayOfYear()).atStartOfDay(); default -> LocalDate.of(1970, 1, 1).atStartOfDay(); }; } private String normalizeCycle(String raw) { String value = clean(raw).toUpperCase(Locale.ROOT); return switch (value) { case "DAY", "DAILY", "每天", "每日" -> "DAY"; case "WEEK", "WEEKLY", "每周" -> "WEEK"; case "MONTH", "MONTHLY", "每月" -> "MONTH"; case "YEAR", "YEARLY", "ANNUAL", "每年" -> "YEAR"; case "NONE", "不重置" -> "NONE"; default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "洗车权益重置周期不正确"); }; } private boolean levelMatches(String configured, String actual) { String a = clean(configured).replace("人才卡", ""); String b = clean(actual).replace("人才卡", ""); return !a.isBlank() && (a.equalsIgnoreCase(b) || a.contains(b) || b.contains(a)); } private void requireMerchant(Long merchantId) { if (merchantId == null || merchantId <= 0 || number(configMapper.countCarWashMerchant(merchantId)).longValue() == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "洗车商家不存在"); } private void validateStatus(Integer status) { if (status != null && (status < 0 || status > 3)) throw new BusinessException(ErrorCode.BAD_REQUEST, "洗车凭证状态不正确"); } private Number number(Object value) { return value instanceof Number number ? number : 0L; } private Long longValue(Map row, String key) { Object value = row.get(key); return value instanceof Number n ? n.longValue() : null; } private String text(Map row, String key) { Object value = row.get(key); return value == null ? "" : String.valueOf(value).trim(); } private String first(String... values) { for (String value : values) if (!clean(value).isBlank()) return clean(value); return ""; } private String clean(String value) { return value == null ? "" : value.trim(); } private String trimToNull(String value) { return clean(value).isBlank() ? null : clean(value); } private record Rule(int totalCount, String resetCycle, boolean familyCardShared) {} }