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<String, Object> saveRules(Long merchantId, CarWashBenefitRuleRequest request) {
|
requireMerchant(merchantId);
|
List<Map<String, Object>> rules = new ArrayList<>();
|
for (CarWashBenefitRuleRequest.Rule item : request.rules()) {
|
String cycle = normalizeCycle(item.resetCycle());
|
Map<String, Object> 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<TransportCarWashService>()
|
.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<TransportCarWashService>()
|
.set("benefit_rules", json).eq("id", config.getId()).eq("deleted", 0)) != 1) {
|
throw new BusinessException(ErrorCode.CONFLICT, "洗车权益配置已变化,请刷新后重试");
|
}
|
Map<String, Object> result = new LinkedHashMap<>();
|
result.put("merchantId", merchantId);
|
result.put("rules", rules);
|
return result;
|
}
|
|
@Transactional(readOnly = true)
|
public Map<String, Object> rules(Long merchantId) {
|
requireMerchant(merchantId);
|
TransportCarWashService config = configMapper.selectOne(new QueryWrapper<TransportCarWashService>()
|
.eq("merchant_id", merchantId).eq("deleted", 0).last("limit 1"));
|
Map<String, Object> result = new LinkedHashMap<>();
|
result.put("merchantId", merchantId);
|
result.put("rules", config == null ? List.of() : parseRules(config.getBenefitRules()));
|
return result;
|
}
|
|
@Transactional
|
public Map<String, Object> 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<TransportCarWashCredential>()
|
.eq("idempotency_key", key).eq("deleted", 0).last("limit 1"));
|
if (duplicate != null) return detail(duplicate.getId());
|
Map<String, Object> 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<String, Object> 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<String> 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<TransportCarWashCredential>()
|
.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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> detail(Long id) {
|
Map<String, Object> row = credentialMapper.selectDetail(id);
|
if (row == null || row.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "洗车凭证记录不存在");
|
return view(DatabaseRows.normalize(row));
|
}
|
|
@Transactional
|
public Map<String, Object> 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<String, Object> 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<BenefitVerifyRecord>()
|
.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<TransportCarWashService>()
|
.eq("merchant_id", merchantId).eq("deleted", 0).eq("status", 1).last("limit 1"));
|
if (config == null) throw new BusinessException(ErrorCode.CONFLICT, "洗车服务未启用");
|
for (Map<String, Object> 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<Map<String, Object>> parseRules(String json) {
|
if (json == null || json.isBlank()) return List.of();
|
try { return objectMapper.readValue(json, new TypeReference<List<Map<String, Object>>>() {}); }
|
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<String, Object> view(Map<String, Object> row) {
|
Map<String, Object> result = new LinkedHashMap<>(row);
|
Object files = row.get("proofFiles");
|
if (files != null) {
|
try { result.put("proofFiles", objectMapper.readValue(String.valueOf(files), new TypeReference<List<String>>() {})); }
|
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<String, Object> row, String key) { Object value = row.get(key); return value instanceof Number n ? n.longValue() : null; }
|
private String text(Map<String, Object> 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) {}
|
}
|