TicketDurationRepairServiceImpl.java 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. package com.cyksj.service.relation.impl;
  2. import cn.hutool.core.date.DateUtil;
  3. import com.cyksj.common.exception.BusinessRuntimeException;
  4. import com.cyksj.mapper.TicketDurationRepairMapper;
  5. import com.cyksj.mapper.manage.cms.CmsUserMapper;
  6. import com.cyksj.mapper.manage.statistics.IncrExpiryTimeRecordMapper;
  7. import com.cyksj.model.dto.TicketDurationRepairOrder;
  8. import com.cyksj.model.dto.TicketDurationRepairRow;
  9. import com.cyksj.model.entity.CmsUser;
  10. import com.cyksj.model.entity.GroupsRelation;
  11. import com.cyksj.model.entity.IncrExpiryTimeRecord;
  12. import com.cyksj.model.request.TicketDurationRepairReq;
  13. import com.cyksj.model.response.TicketDurationRepairItemResp;
  14. import com.cyksj.model.response.TicketDurationRepairPreviewResp;
  15. import com.cyksj.service.relation.TicketDurationRepairService;
  16. import lombok.RequiredArgsConstructor;
  17. import org.springframework.stereotype.Service;
  18. import org.springframework.transaction.annotation.Isolation;
  19. import org.springframework.transaction.annotation.Transactional;
  20. import java.time.temporal.ChronoUnit;
  21. import java.util.ArrayList;
  22. import java.util.Collections;
  23. import java.util.Comparator;
  24. import java.util.Date;
  25. import java.util.HashMap;
  26. import java.util.HashSet;
  27. import java.util.List;
  28. import java.util.Map;
  29. import java.util.Objects;
  30. import java.util.Set;
  31. import java.util.stream.Collectors;
  32. @Service
  33. @RequiredArgsConstructor
  34. public class TicketDurationRepairServiceImpl implements TicketDurationRepairService {
  35. private final TicketDurationRepairMapper mapper;
  36. private final IncrExpiryTimeRecordMapper recordMapper;
  37. private final CmsUserMapper cmsUserMapper;
  38. @Override
  39. @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
  40. public TicketDurationRepairPreviewResp preview(Long afterId, Integer limit, Long userId) {
  41. if (afterId == null || afterId < 0 || limit == null || limit < 1 || limit > 200) {
  42. throw BusinessRuntimeException.getInstance("afterId必须大于等于0,limit必须在1至200之间");
  43. }
  44. if (userId != null && userId <= 0) {
  45. throw BusinessRuntimeException.getInstance("userId必须大于0");
  46. }
  47. List<TicketDurationRepairRow> fetched = mapper.selectPage(afterId, limit + 1, userId);
  48. boolean hasMore = fetched.size() > limit;
  49. List<TicketDurationRepairRow> rows = hasMore ? fetched.subList(0, limit) : fetched;
  50. TicketDurationRepairPreviewResp response = new TicketDurationRepairPreviewResp();
  51. response.setUserId(userId);
  52. response.setAfterId(afterId);
  53. response.setNextAfterId(rows.isEmpty() ? afterId : rows.get(rows.size() - 1).getRelationId());
  54. response.setHasMore(hasMore);
  55. response.setItems(classifyAll(rows));
  56. return response;
  57. }
  58. @Override
  59. @Transactional(rollbackFor = Exception.class)
  60. public List<TicketDurationRepairItemResp> execute(TicketDurationRepairReq req, Long adminId) {
  61. validateRequest(req, adminId);
  62. // Lock in a stable order. The entire batch is validated before the first mutation.
  63. List<TicketDurationRepairReq.Snapshot> snapshots = new ArrayList<>(req.getItems());
  64. snapshots.sort(Comparator.comparing(TicketDurationRepairReq.Snapshot::getRelationId));
  65. List<TicketDurationRepairRow> rows = new ArrayList<>();
  66. for (TicketDurationRepairReq.Snapshot snapshot : snapshots) {
  67. TicketDurationRepairRow row = mapper.selectForUpdate(snapshot.getRelationId());
  68. if (row == null || !matches(row, snapshot)) {
  69. throw BusinessRuntimeException.getInstance("车票" + snapshot.getRelationId() + "已变化或不属于目标规格,请重新预览");
  70. }
  71. rows.add(row);
  72. }
  73. List<TicketDurationRepairItemResp> results = classifyAll(rows);
  74. for (TicketDurationRepairItemResp result : results) {
  75. if (!"candidate".equals(result.getDecision())) {
  76. throw BusinessRuntimeException.getInstance("车票" + result.getRelationId() + "不能自动修复:" + result.getReason());
  77. }
  78. }
  79. CmsUser admin = cmsUserMapper.selectById(adminId);
  80. String operator = admin != null && admin.getNickname() != null
  81. ? admin.getNickname() : String.valueOf(adminId);
  82. for (int i = 0; i < rows.size(); i++) {
  83. TicketDurationRepairRow row = rows.get(i);
  84. TicketDurationRepairItemResp result = results.get(i);
  85. Date expiryTime = new Date(result.getNewExpiryTime());
  86. if (mapper.updateExpiry(row, expiryTime) != 1) {
  87. throw BusinessRuntimeException.getInstance("车票" + row.getRelationId() + "更新失败,整批修复已回滚");
  88. }
  89. IncrExpiryTimeRecord record = new IncrExpiryTimeRecord();
  90. record.setRelationId(row.getRelationId());
  91. record.setUserId(row.getUserId());
  92. record.setExpiryTimeInitial(row.getExpiryTime());
  93. // Existing audit schema records days; calculate the actual calendar-day difference.
  94. int days = (int) ChronoUnit.DAYS.between(DateUtil.toLocalDateTime(row.getExpiryTime()).toLocalDate(),
  95. DateUtil.toLocalDateTime(expiryTime).toLocalDate());
  96. record.setIncrExpiryDays(days);
  97. record.setOperator(operator);
  98. record.setRemark(req.getRemark() != null && !req.getRemark().isBlank()
  99. ? req.getRemark()
  100. : "规格772/774半年付时长修复:原到期时间减6个自然月;原到期="
  101. + DateUtil.formatDateTime(row.getExpiryTime()) + ";新到期=" + DateUtil.formatDateTime(expiryTime));
  102. if (recordMapper.insert(record) != 1) {
  103. throw BusinessRuntimeException.getInstance("车票" + row.getRelationId() + "修复记录保存失败,整批修复已回滚");
  104. }
  105. result.setDecision("updated");
  106. result.setReason("已从原到期时间扣减6个自然月");
  107. }
  108. return results;
  109. }
  110. private List<TicketDurationRepairItemResp> classifyAll(List<TicketDurationRepairRow> rows) {
  111. if (rows.isEmpty()) {
  112. return Collections.emptyList();
  113. }
  114. List<Long> ids = rows.stream().map(TicketDurationRepairRow::getRelationId).collect(Collectors.toList());
  115. Map<Long, Map<Long, TicketDurationRepairOrder>> orders = new HashMap<>();
  116. for (TicketDurationRepairOrder order : mapper.selectOrders(ids)) {
  117. Map<Long, TicketDurationRepairOrder> byId = orders.computeIfAbsent(order.getRelationId(), key -> new HashMap<>());
  118. TicketDurationRepairOrder previous = byId.putIfAbsent(order.getOrderId(), order);
  119. if (previous != null && Boolean.TRUE.equals(order.getSlot())) {
  120. previous.setSlot(true);
  121. }
  122. }
  123. Set<Long> adjusted = new HashSet<>(mapper.selectAdjustedRelationIds(ids));
  124. List<TicketDurationRepairItemResp> results = new ArrayList<>();
  125. for (TicketDurationRepairRow row : rows) {
  126. results.add(classify(row, new ArrayList<>(orders.getOrDefault(row.getRelationId(), Collections.emptyMap()).values()),
  127. adjusted.contains(row.getRelationId())));
  128. }
  129. return results;
  130. }
  131. private TicketDurationRepairItemResp classify(TicketDurationRepairRow row,
  132. List<TicketDurationRepairOrder> orders, boolean adjusted) {
  133. TicketDurationRepairItemResp result = new TicketDurationRepairItemResp();
  134. result.setRelationId(row.getRelationId());
  135. result.setGroupsId(row.getGroupsId());
  136. result.setSkuId(row.getSkuId());
  137. result.setUserId(row.getUserId());
  138. result.setStartTime(millis(row.getStartTime()));
  139. result.setExpiryTime(millis(row.getExpiryTime()));
  140. result.setUpdateTime(millis(row.getUpdateTime()));
  141. result.setOrderCount(orders.size());
  142. if (!isTarget(row.getSkuId())) {
  143. return decision(result, "skipped", "不是规格772或774");
  144. }
  145. if (row.getStartTime() == null || row.getExpiryTime() == null) {
  146. return decision(result, "skipped", "未设置完整起止时间");
  147. }
  148. Date expected = DateUtil.offsetMonth(row.getStartTime(), 12);
  149. long difference = row.getExpiryTime().getTime() - expected.getTime();
  150. result.setYearDifferenceMillis(difference);
  151. if (difference != 0) {
  152. if (Math.abs(difference) <= 60_000 || DateUtil.isSameDay(expected, row.getExpiryTime())) {
  153. return decision(result, "review", "接近一年但存在时间偏差,需核对");
  154. }
  155. return decision(result, "skipped", "已设置的时长不是12个自然月");
  156. }
  157. List<String> reasons = new ArrayList<>();
  158. if (row.getUserId() == null || row.getUserId() <= 0
  159. || (row.getStatus() != GroupsRelation.Status.validity && row.getStatus() != GroupsRelation.Status.outside)) {
  160. reasons.add("车票无当前用户或状态无效");
  161. }
  162. if (Boolean.TRUE.equals(row.getIsFrozen()) || Boolean.TRUE.equals(row.getIsRecover()) || nonzero(row.getReservedDays())) {
  163. reasons.add("有冻结/恢复/保留天数");
  164. }
  165. if (nonzero(row.getCompensationDays()) || adjusted) {
  166. reasons.add("有补偿或手工时长调整");
  167. }
  168. if (Boolean.TRUE.equals(row.getIsVip()) || !Objects.equals(row.getAqType(), 1)) {
  169. reasons.add("VIP或非普通购买车票");
  170. }
  171. if (orders.size() != 1) {
  172. reasons.add("有效关联订单不是一笔");
  173. } else {
  174. TicketDurationRepairOrder order = orders.get(0);
  175. if (!Objects.equals(order.getOrderType(), 1)) {
  176. reasons.add("续费或未知订单类型");
  177. }
  178. if (!Objects.equals(order.getSkuId(), row.getSkuId())) {
  179. reasons.add("订单规格与当前车票不一致");
  180. }
  181. if (order.getNum() == null || order.getNum() < 1
  182. || (order.getNum() > 1 && !Boolean.TRUE.equals(order.getSlot()))) {
  183. reasons.add("购买数量可能叠加时长");
  184. }
  185. }
  186. if (!reasons.isEmpty()) {
  187. return decision(result, "review", String.join(";", reasons));
  188. }
  189. Date newExpiry = DateUtil.offsetMonth(row.getExpiryTime(), -6);
  190. result.setNewExpiryTime(newExpiry.getTime());
  191. result.setExpiredAfterRepair(!newExpiry.after(new Date()));
  192. return decision(result, "candidate", "可修复:原到期时间减6个自然月");
  193. }
  194. private TicketDurationRepairItemResp decision(TicketDurationRepairItemResp result, String decision, String reason) {
  195. result.setDecision(decision);
  196. result.setReason(reason);
  197. return result;
  198. }
  199. private boolean matches(TicketDurationRepairRow row, TicketDurationRepairReq.Snapshot snapshot) {
  200. return Objects.equals(row.getRelationId(), snapshot.getRelationId())
  201. && Objects.equals(row.getGroupsId(), snapshot.getGroupsId())
  202. && Objects.equals(row.getSkuId(), snapshot.getSkuId()) && isTarget(row.getSkuId())
  203. && Objects.equals(row.getUserId(), snapshot.getUserId())
  204. && Objects.equals(millis(row.getStartTime()), snapshot.getStartTime())
  205. && Objects.equals(millis(row.getExpiryTime()), snapshot.getExpiryTime())
  206. && Objects.equals(millis(row.getUpdateTime()), snapshot.getUpdateTime());
  207. }
  208. private void validateRequest(TicketDurationRepairReq req, Long adminId) {
  209. if (adminId == null || adminId <= 0 || req == null || req.getItems() == null
  210. || req.getItems().isEmpty() || req.getItems().size() > 200) {
  211. throw BusinessRuntimeException.getInstance("请以后台账号提交1至200张预览中的车票");
  212. }
  213. Set<Long> ids = new HashSet<>();
  214. for (TicketDurationRepairReq.Snapshot item : req.getItems()) {
  215. if (item == null || item.getRelationId() == null || item.getRelationId() <= 0
  216. || item.getGroupsId() == null || item.getGroupsId() <= 0
  217. || item.getUserId() == null || item.getUserId() <= 0 || !isTarget(item.getSkuId())
  218. || item.getStartTime() == null || item.getExpiryTime() == null) {
  219. throw BusinessRuntimeException.getInstance("车票快照不完整或规格不属于772/774");
  220. }
  221. if (!ids.add(item.getRelationId())) {
  222. throw BusinessRuntimeException.getInstance("不能重复提交同一张车票");
  223. }
  224. }
  225. }
  226. private static Long millis(Date date) {
  227. return date == null ? null : date.getTime();
  228. }
  229. private static boolean nonzero(Integer value) {
  230. return value != null && value != 0;
  231. }
  232. private static boolean isTarget(Long skuId) {
  233. return Objects.equals(skuId, 772L) || Objects.equals(skuId, 774L);
  234. }
  235. }