|
|
@@ -7,7 +7,6 @@ import cn.hutool.core.date.DateUtil;
|
|
|
import cn.hutool.core.util.ObjectUtil;
|
|
|
import cn.hutool.core.util.RandomUtil;
|
|
|
import cn.hutool.core.util.StrUtil;
|
|
|
-import com.alibaba.fastjson.JSON;
|
|
|
import com.alibaba.fastjson2.JSONObject;
|
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
|
|
@@ -67,9 +66,7 @@ import java.text.ParseException;
|
|
|
import java.text.SimpleDateFormat;
|
|
|
import java.time.Instant;
|
|
|
import java.time.LocalDate;
|
|
|
-import java.time.LocalTime;
|
|
|
import java.time.ZoneId;
|
|
|
-import java.time.format.DateTimeFormatter;
|
|
|
import java.util.*;
|
|
|
import java.util.concurrent.*;
|
|
|
import java.util.stream.Collectors;
|
|
|
@@ -200,6 +197,25 @@ public class OrderServiceImpl extends ServiceImpl<AppOrderMapper, AppOrder> impl
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 将入参日期归一到当天 00:00,避免 date_of_sale 比较时受时分秒影响。
|
|
|
+ */
|
|
|
+ private Date toDateOnly(Date date) {
|
|
|
+ if (date == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
|
|
|
+ return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 清理学校场地库存相关缓存,避免事务回滚或并发扣减后继续使用旧库存。
|
|
|
+ */
|
|
|
+ private void resetSchoolStockCache(String productKey, String stockKey) {
|
|
|
+ redisTemplate.delete(productKey);
|
|
|
+ redisTemplate.delete(stockKey);
|
|
|
+ }
|
|
|
+
|
|
|
@Override
|
|
|
public OrderVO.PreviewOrderPlaceSchool previewOrderPlaceSchool(String placeId, Date startTime) {
|
|
|
LocalDate localDate = startTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
|
|
|
@@ -207,47 +223,9 @@ public class OrderServiceImpl extends ServiceImpl<AppOrderMapper, AppOrder> impl
|
|
|
OrderVO.PreviewOrderPlaceSchool previewOrderPlaceSchool = new OrderVO.PreviewOrderPlaceSchool();
|
|
|
AppSite appSite = appSiteMapper.selectOne(Wrappers.<AppSite>lambdaQuery().eq(AppSite::getId, appSitePlaceMapper.selectById(placeId).getSiteId()));
|
|
|
List<OrderVO.PreviewOrderPlaceSchoolChild> timeSlot = appSitePriceRulesMapper.timeSlot(placeId, dateOnly);
|
|
|
- // 解析教学日和非教学日数据
|
|
|
- List<OrderVO.TimeSlotData> teachingList = parseTimeSlotData(appSite.getTeachingDay());
|
|
|
- List<OrderVO.TimeSlotData> nonTeachingList = parseTimeSlotData(appSite.getNoTeachingDay());
|
|
|
-
|
|
|
- log.info("[previewOrderPlaceSchool] placeId={}, startTime={}, teachingList={}, nonTeachingList={}",
|
|
|
- placeId, DateUtil.format(startTime, "yyyy-MM-dd"), appSite.getTeachingDay(), appSite.getNoTeachingDay());
|
|
|
-
|
|
|
- timeSlot.forEach(a -> {
|
|
|
- // is_teaching: 0=是(教学日), 1=否(非教学日)
|
|
|
- boolean isTeaching = a.getIsTeaching() == 0;
|
|
|
- List<OrderVO.TimeSlotData> targetList = isTeaching ? teachingList : nonTeachingList;
|
|
|
- log.info("[previewOrderPlaceSchool] 原始timeSlot: is_teaching={}, 使用配置={}, startTime={}, endTime={}",
|
|
|
- a.getIsTeaching(), isTeaching ? "教学日" : "非教学日", a.getStartTime(), a.getEndTime());
|
|
|
-
|
|
|
- // 根据 is_teaching 动态替换时间段配置
|
|
|
- if (!targetList.isEmpty()) {
|
|
|
- OrderVO.TimeSlotData timeSlotData = targetList.get(0);
|
|
|
- try {
|
|
|
- // 更新时间段
|
|
|
- LocalTime newStartTime = LocalTime.parse(timeSlotData.getStartTime(), DateTimeFormatter.ofPattern("HH:mm"));
|
|
|
- LocalTime newEndTime = LocalTime.parse(timeSlotData.getEndTime(), DateTimeFormatter.ofPattern("HH:mm"));
|
|
|
- a.setStartTime(java.sql.Time.valueOf(newStartTime));
|
|
|
- a.setEndTime(java.sql.Time.valueOf(newEndTime));
|
|
|
- a.setName(timeSlotData.getStartTime() + "-" + timeSlotData.getEndTime());
|
|
|
-
|
|
|
- // 计算库存
|
|
|
- int totalInventory = Integer.parseInt(timeSlotData.getTicketNum());
|
|
|
- int bookedCount = appOrderMapper.queryBookedCount(
|
|
|
- placeId,
|
|
|
- DateUtil.format(startTime, "yyyy-MM-dd"),
|
|
|
- timeSlotData.getStartTime(),
|
|
|
- timeSlotData.getEndTime()
|
|
|
- );
|
|
|
- a.setInventory(totalInventory - bookedCount);
|
|
|
- log.info("[previewOrderPlaceSchool] 更新后timeSlot: startTime={}, endTime={}, inventory={}",
|
|
|
- a.getStartTime(), a.getEndTime(), a.getInventory());
|
|
|
- } catch (Exception e) {
|
|
|
- log.error("[previewOrderPlaceSchool] 解析时间段失败: {}", e.getMessage());
|
|
|
- }
|
|
|
- }
|
|
|
- });
|
|
|
+ // 学校场地预约库存必须与下单扣减字段保持一致:这里直接使用 nm_site_price_rules.ticket_num。
|
|
|
+ log.info("[previewOrderPlaceSchool] placeId={}, startTime={}, timeSlotSize={}",
|
|
|
+ placeId, DateUtil.format(startTime, "yyyy-MM-dd"), timeSlot.size());
|
|
|
AppSitePlace appSitePlace = appSitePlaceMapper.selectById(placeId);
|
|
|
previewOrderPlaceSchool.setName(appSite.getName())
|
|
|
.setId(appSitePlace.getId())
|
|
|
@@ -377,78 +355,17 @@ public class OrderServiceImpl extends ServiceImpl<AppOrderMapper, AppOrder> impl
|
|
|
AppSitePlace sitePlace = appSitePlaceMapper.selectById(placeId);
|
|
|
String orgCode = sitePlace.getOrgCode();
|
|
|
log.info("[previewOrderPlaceSchoolTime] placeId={}, orgCode={}, siteId={}", placeId, orgCode, sitePlace.getSiteId());
|
|
|
-
|
|
|
- // 直接查询 nm_teaching_time 表的原始数据,确认数据库中的配置
|
|
|
- List<AppTeachingTime> rawTeachingTimes = appTeachingTimeMapper.selectList(
|
|
|
- Wrappers.<AppTeachingTime>lambdaQuery()
|
|
|
- .eq(AppTeachingTime::getOrgCode, orgCode)
|
|
|
- .ge(AppTeachingTime::getDay, cn.hutool.core.date.DateUtil.beginOfDay(new java.util.Date()))
|
|
|
- .le(AppTeachingTime::getDay, cn.hutool.core.date.DateUtil.offsetDay(new java.util.Date(), 6))
|
|
|
- .eq(AppTeachingTime::getDelFlag, 0)
|
|
|
- );
|
|
|
- log.info("[previewOrderPlaceSchoolTime] 原始数据库查询 nm_teaching_time 表,找到{}条记录:", rawTeachingTimes.size());
|
|
|
- rawTeachingTimes.forEach(t -> log.info(" -> day={}, is_teaching={}", t.getDay(), t.getIsTeaching()));
|
|
|
-
|
|
|
+
|
|
|
+ // 日期列表来自教学日维护表,库存统一按当前场地当天价格规则的 ticket_num 汇总。
|
|
|
List<OrderVO.PreviewOrderPlaceSchoolTime> previewOrderPlaceSchoolTimes = appTeachingTimeMapper.previewOrderPlaceSchoolTime(orgCode);
|
|
|
- AppSite appSite = appSiteMapper.selectOne(Wrappers.<AppSite>lambdaQuery().eq(AppSite::getId, sitePlace.getSiteId()));
|
|
|
-
|
|
|
- log.info("[previewOrderPlaceSchoolTime] Mapper查询到{}条教学时间记录", previewOrderPlaceSchoolTimes.size());
|
|
|
- previewOrderPlaceSchoolTimes.forEach(t -> log.info("[previewOrderPlaceSchoolTime] day={}, is_teaching={}, namedDay={}", t.getDay(), t.getIsTeaching(), t.getNamedDay()));
|
|
|
-
|
|
|
- // 解析教学日和非教学日数据
|
|
|
- List<OrderVO.TimeSlotData> teachingList = parseTimeSlotData(appSite.getTeachingDay());
|
|
|
- List<OrderVO.TimeSlotData> nonTeachingList = parseTimeSlotData(appSite.getNoTeachingDay());
|
|
|
-
|
|
|
- log.info("[previewOrderPlaceSchoolTime] teachingList size={}, nonTeachingList size={}", teachingList.size(), nonTeachingList.size());
|
|
|
- log.info("[previewOrderPlaceSchoolTime] 教学日原始配置 teachingDay={}", appSite.getTeachingDay());
|
|
|
- log.info("[previewOrderPlaceSchoolTime] 非教学日原始配置 noTeachingDay={}", appSite.getNoTeachingDay());
|
|
|
- if (!teachingList.isEmpty()) {
|
|
|
- log.info("[previewOrderPlaceSchoolTime] teachingList[0]: startTime={}, endTime={}, ticketNum={}",
|
|
|
- teachingList.get(0).getStartTime(), teachingList.get(0).getEndTime(), teachingList.get(0).getTicketNum());
|
|
|
- }
|
|
|
- if (!nonTeachingList.isEmpty()) {
|
|
|
- log.info("[previewOrderPlaceSchoolTime] nonTeachingList[0]: startTime={}, endTime={}, ticketNum={}",
|
|
|
- nonTeachingList.get(0).getStartTime(), nonTeachingList.get(0).getEndTime(), nonTeachingList.get(0).getTicketNum());
|
|
|
- }
|
|
|
-
|
|
|
previewOrderPlaceSchoolTimes.forEach(previewOrderPlaceSchoolTime -> {
|
|
|
- // is_teaching: 0=是(教学日), 1=否(非教学日)
|
|
|
- boolean isTeaching = previewOrderPlaceSchoolTime.getIsTeaching() == 0;
|
|
|
- log.info("[previewOrderPlaceSchoolTime] day={}, is_teaching={}, 使用配置={}",
|
|
|
- previewOrderPlaceSchoolTime.getDay(),
|
|
|
- previewOrderPlaceSchoolTime.getIsTeaching(),
|
|
|
- isTeaching ? "教学日(teachingList)" : "非教学日(nonTeachingList)");
|
|
|
- List<OrderVO.TimeSlotData> targetList = isTeaching ? teachingList : nonTeachingList;
|
|
|
- if (!targetList.isEmpty()) {
|
|
|
- // 找到最早开始时间
|
|
|
- Optional<OrderVO.TimeSlotData> earliest = targetList.stream()
|
|
|
- .min(Comparator.comparing(t -> LocalTime.parse(t.getStartTime(), DateTimeFormatter.ofPattern("HH:mm"))));
|
|
|
-
|
|
|
- // 找到最晚结束时间
|
|
|
- Optional<OrderVO.TimeSlotData> latest = targetList.stream()
|
|
|
- .max(Comparator.comparing(t -> LocalTime.parse(t.getEndTime(), DateTimeFormatter.ofPattern("HH:mm"))));
|
|
|
-
|
|
|
- String earliestStart = earliest.map(OrderVO.TimeSlotData::getStartTime).orElse("未知");
|
|
|
- String latestEnd = latest.map(OrderVO.TimeSlotData::getEndTime).orElse("未知");
|
|
|
-
|
|
|
- int bookedCount = appOrderMapper.queryBookedCount(
|
|
|
- placeId,
|
|
|
- DateUtil.format(previewOrderPlaceSchoolTime.getDay(), "yyyy-MM-dd"),
|
|
|
- earliestStart,
|
|
|
- latestEnd
|
|
|
- );
|
|
|
- int totalTicketNum = targetList.stream()
|
|
|
- .mapToInt(t -> {
|
|
|
- try {
|
|
|
- return Integer.parseInt(t.getTicketNum());
|
|
|
- } catch (NumberFormatException e) {
|
|
|
- return 0; // 默认值,或抛出异常
|
|
|
- }
|
|
|
- })
|
|
|
- .sum();
|
|
|
- previewOrderPlaceSchoolTime.setInventory(totalTicketNum - bookedCount);
|
|
|
- }
|
|
|
-
|
|
|
+ Date dateOnly = toDateOnly(previewOrderPlaceSchoolTime.getDay());
|
|
|
+ int remainingInventory = appSitePriceRulesMapper.timeSlot(placeId, dateOnly).stream()
|
|
|
+ .mapToInt(OrderVO.PreviewOrderPlaceSchoolChild::getInventory)
|
|
|
+ .sum();
|
|
|
+ previewOrderPlaceSchoolTime.setInventory(remainingInventory);
|
|
|
+ log.info("[previewOrderPlaceSchoolTime] day={}, remainingInventory={}",
|
|
|
+ DateUtil.format(dateOnly, "yyyy-MM-dd"), remainingInventory);
|
|
|
});
|
|
|
|
|
|
return previewOrderPlaceSchoolTimes;
|
|
|
@@ -605,38 +522,24 @@ public class OrderServiceImpl extends ServiceImpl<AppOrderMapper, AppOrder> impl
|
|
|
throw new JeecgBootException("系统正忙,请稍后再试");
|
|
|
}
|
|
|
try {
|
|
|
- // 查询库存(优先从Redis读)
|
|
|
- Object stockObj = redisTemplate.opsForValue().get(stockKey);
|
|
|
- Integer stock = stockObj != null ? Integer.parseInt(stockObj.toString()) : null;
|
|
|
- log.info("学校场地预约商品库存数量:{}", stock);
|
|
|
-
|
|
|
- // 缓存没有商品库存,查询数据库
|
|
|
- if (stock == null) {
|
|
|
- AppSitePriceRules product = appSitePriceRulesMapper.selectById(productId);
|
|
|
- if (Objects.isNull(product)) {
|
|
|
- throw new JeecgBootException("订单提交失败,商品已下架");
|
|
|
- }
|
|
|
- redisTemplate.opsForValue().set(productKey, JSON.toJSONString(product), 60 * 60 * 24, TimeUnit.SECONDS);
|
|
|
- if (product.getTicketNum() == null) {
|
|
|
- throw new JeecgBootException("订单提交失败,当前商品库存为空");
|
|
|
- }
|
|
|
- stock = product.getTicketNum();
|
|
|
- redisTemplate.opsForValue().set(stockKey, stock, 60 * 60 * 24, TimeUnit.SECONDS);
|
|
|
+ // 下单库存只以数据库实时值为准,避免 Redis 脏库存误拦截。
|
|
|
+ AppSitePriceRules product = appSitePriceRulesMapper.selectById(productId);
|
|
|
+ if (Objects.isNull(product)) {
|
|
|
+ throw new JeecgBootException("订单提交失败,商品已下架");
|
|
|
}
|
|
|
-
|
|
|
- // 检查库存是否足够
|
|
|
- if (stock < ids.size()) {
|
|
|
- throw new JeecgBootException("订单提交失败,库存不足");
|
|
|
+ if (product.getTicketNum() == null) {
|
|
|
+ throw new JeecgBootException("订单提交失败,当前商品库存为空");
|
|
|
}
|
|
|
|
|
|
- // 原子扣减数据库库存(防并发覆盖写)
|
|
|
- log.info("更新学校场地数据库中的库存数据,扣减数量:{}", ids.size());
|
|
|
+ // 使用条件更新原子扣减库存,row=0 表示并发下库存已不足。
|
|
|
+ log.info("更新学校场地数据库中的库存数据,productId={},当前库存={},扣减数量={}",
|
|
|
+ productId, product.getTicketNum(), ids.size());
|
|
|
int row = appSitePriceRulesMapper.deductStock(productId, ids.size());
|
|
|
if (row <= 0) {
|
|
|
throw new JeecgBootException("订单提交失败,库存不足");
|
|
|
}
|
|
|
- // 更新Redis中缓存的商品库存数据
|
|
|
- redisTemplate.opsForValue().decrement(stockKey, ids.size());
|
|
|
+ // 数据库扣减成功后清理缓存,下次读取必须回源 DB,避免缓存与事务不一致。
|
|
|
+ resetSchoolStockCache(productKey, stockKey);
|
|
|
} finally {
|
|
|
RedisLockUtils.unlock(stockLockKey);
|
|
|
}
|
|
|
@@ -2602,15 +2505,10 @@ public class OrderServiceImpl extends ServiceImpl<AppOrderMapper, AppOrder> impl
|
|
|
if (restored > 0) {
|
|
|
log.info("取消学校订单恢复库存,productId={},恢复数量={}", productId, restoreCount);
|
|
|
|
|
|
- // 恢复Redis缓存库存
|
|
|
+ // 恢复库存后直接清理缓存,下次查询回源 DB 获取真实 ticket_num。
|
|
|
+ String productKey = "ORDER_TYPE_1_PRODUCT_" + productId;
|
|
|
String stockKey = "ORDER_TYPE_1_PRODUCT_STOCK_" + productId;
|
|
|
- Object stockObj = redisTemplate.opsForValue().get(stockKey);
|
|
|
- if (stockObj != null) {
|
|
|
- redisTemplate.opsForValue().increment(stockKey, restoreCount);
|
|
|
- } else {
|
|
|
- // 缓存不存在时直接删除key,下次下单时会从已提交的DB重新加载
|
|
|
- redisTemplate.delete(stockKey);
|
|
|
- }
|
|
|
+ resetSchoolStockCache(productKey, stockKey);
|
|
|
}
|
|
|
}
|
|
|
|