Browse Source

feat(data-board): 新增数据看板模块及相关接口

- 新增数据看板Controller,提供数据概览、学校预约概览、月份统计及分页查询等接口
- 新增数据看板Service及其实现,完成数据统计、分页逻辑及履约率计算
- 新增对应Mapper及XML,实现SQL查询逻辑支持
- 新增数据看板相关VO定义,规范接口数据结构
- 优化家庭成员模块,增加实名认证校验接口与逻辑
- 家庭成员添加接口新增实名认证图片大小限制及数据校验流程
- 优化订单库存处理,新增原子扣减和恢复库存接口,防止并发库存超卖
- 订单服务中应用库存扣减的乐观锁校验及缓存更新逻辑
- AppSiteService实现修正商户名称括号格式问题
wzq 1 tuần trước cách đây
mục cha
commit
ca46d4d71f

+ 45 - 38
national-motion-base-core/src/main/java/org/jeecg/common/util/SecurityUtils.java

@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
 
 import java.util.Arrays;
 import java.util.HashSet;
+import java.util.Locale;
 import java.util.Set;
 import java.util.regex.Pattern;
 
@@ -108,27 +109,17 @@ public class SecurityUtils {
      * SQL 注入检测正则表达式
      */
     private static final Pattern[] SQL_INJECTION_PATTERNS = {
-            // SQL 注释
-            Pattern.compile("('.+--)|(--)|(;)|(\\|{2})"),
-            // SQL 函数调用
-            Pattern.compile("\\bexec(ute)?\\s*\\(", Pattern.CASE_INSENSITIVE),
-            // union 查询
-            Pattern.compile("\\bunion\\b.*\\bselect\\b", Pattern.CASE_INSENSITIVE),
-            // 多语句
-            Pattern.compile(";.*?(select|insert|update|delete|drop|create|alter)", Pattern.CASE_INSENSITIVE),
-            // 16 进制编码
-            Pattern.compile("0x[0-9a-f]+", Pattern.CASE_INSENSITIVE),
-            // 字符串拼接
-            Pattern.compile("\\bconcat\\s*\\(", Pattern.CASE_INSENSITIVE),
-            // sleep 函数
-            Pattern.compile("\\bsleep\\s*\\(", Pattern.CASE_INSENSITIVE),
-            // benchmark 函数
-            Pattern.compile("\\bbenckmark\\s*\\(", Pattern.CASE_INSENSITIVE),
-            // waitfor delay
-            Pattern.compile("\\bwaitfor\\s+\\bdelay\\b", Pattern.CASE_INSENSITIVE),
-            // 子查询
-            Pattern.compile("\\bsubstr\\s*\\(", Pattern.CASE_INSENSITIVE),
-            Pattern.compile("\\bsubstring\\s*\\(", Pattern.CASE_INSENSITIVE)
+            Pattern.compile("\\bunion\\s+(all\\s+)?select\\b", Pattern.CASE_INSENSITIVE),
+            Pattern.compile("\\bselect\\s*(\\*|[a-z0-9_`,.()]+)\\s*from\\b", Pattern.CASE_INSENSITIVE),
+            Pattern.compile("\\b(or|and)\\s+\\d+\\s*=\\s*\\d+\\b", Pattern.CASE_INSENSITIVE),
+            Pattern.compile("\\bor\\s*\\(?\\s*select\\s*(\\*|[a-z0-9_`,.()]+)\\s*from\\b", Pattern.CASE_INSENSITIVE),
+            Pattern.compile(";\\s*(select|insert|update|delete|drop|create|alter|truncate)\\b", Pattern.CASE_INSENSITIVE),
+            Pattern.compile("\\b(insert\\s+into|update\\s+[a-z0-9_`.]+\\s+set|delete\\s+from|drop\\s+table|alter\\s+table|truncate\\s+table)\\b", Pattern.CASE_INSENSITIVE),
+            Pattern.compile("\\b(exec(ute)?|xp_cmdshell|sp_executesql)\\s*\\(?", Pattern.CASE_INSENSITIVE),
+            Pattern.compile("\\b(concat|sleep|benchmark|waitfor|extractvalue|updatexml|load_file|substr|substring)\\s*\\(", Pattern.CASE_INSENSITIVE),
+            Pattern.compile("\\bwaitfor\\s+delay\\b", Pattern.CASE_INSENSITIVE),
+            Pattern.compile("\\binformation_schema\\b|\\bmysql\\.user\\b", Pattern.CASE_INSENSITIVE),
+            Pattern.compile("0x[0-9a-f]+", Pattern.CASE_INSENSITIVE)
     };
 
     /**
@@ -167,16 +158,8 @@ public class SecurityUtils {
             return false;
         }
 
-        String lowerValue = value.toLowerCase();
-
-        // 检查注释符号(更严格的检测)
-        if (lowerValue.contains("--") || lowerValue.contains("/*") || lowerValue.contains("*/") || lowerValue.contains("#")) {
-            // 检查是否是真正的注释而不是普通文本
-            if (lowerValue.matches(".*\\s(--|#).*") || lowerValue.contains("/*") || lowerValue.contains("*/")) {
-                log.warn("检测到 SQL 注入注释符号: {}, 内容: {}", "--/#/*", value);
-                return true;
-            }
-        }
+        String decodedValue = urlDecode(value);
+        String lowerValue = normalizeSqlValue(decodedValue);
 
         // 检查危险关键词(使用更精确的匹配规则)
         for (String keyword : SQL_KEYWORDS) {
@@ -187,7 +170,7 @@ public class SecurityUtils {
                 // 进一步检查是否为真实攻击而非正常文本
                 // 例如:"select" 在 "selected" 中是正常文本,但在 "select * from" 中可能是攻击
                 if (isRealSqlInjection(keyword, lowerValue)) {
-                    log.warn("检测到 SQL 注入关键词: {}, 内容: {}", keyword, value);
+                    log.warn("检测到 SQL 注入关键词: {}, 内容: {}", keyword, safeLogContent(value));
                     return true;
                 }
             }
@@ -195,8 +178,8 @@ public class SecurityUtils {
 
         // 使用正则表达式检测
         for (Pattern pattern : SQL_INJECTION_PATTERNS) {
-            if (pattern.matcher(value).find()) {
-                log.warn("检测到 SQL 注入,匹配模式: {}, 内容: {}", pattern.pattern(), value);
+            if (pattern.matcher(lowerValue).find()) {
+                log.warn("检测到 SQL 注入,匹配模式: {}, 内容: {}", pattern.pattern(), safeLogContent(value));
                 return true;
             }
         }
@@ -249,6 +232,26 @@ public class SecurityUtils {
         return decoded;
     }
 
+    /**
+     * SQL 注入检测前归一化:统一大小写、移除注释绕过、压缩空白。
+     */
+    private static String normalizeSqlValue(String value) {
+        String normalized = value.toLowerCase(Locale.ROOT);
+        normalized = normalized.replaceAll("(?s)/\\*.*?\\*/", " ");
+        normalized = normalized.replace('\u00A0', ' ');
+        normalized = normalized.replaceAll("[\\r\\n\\t]+", " ");
+        normalized = normalized.replaceAll("\\s+", " ");
+        return normalized.trim();
+    }
+
+    private static String safeLogContent(String value) {
+        String content = value.replaceAll("[\\r\\n\\t]+", " ");
+        if (content.length() > 200) {
+            return content.substring(0, 200) + "...";
+        }
+        return content;
+    }
+
     /**
      * 验证输入是否安全(综合检查 XSS 和 SQL 注入)
      *
@@ -278,7 +281,9 @@ public class SecurityUtils {
                 case "select":
                     // 只有当 select 后面跟着典型的 SQL 结构时才认为是攻击
                     boolean isSelectAttack = value.matches(".*select\\s+[a-z0-9_*]+\\s+from\\s+[a-z0-9_]+.*") || 
-                           value.matches(".*select\\s+\\*\\s+from\\s+[a-z0-9_]+.*");
+                           value.matches(".*select\\s+\\*\\s+from\\s+[a-z0-9_]+.*") ||
+                           value.matches(".*select\\s*(\\*|[a-z0-9_`,.()]+)\\s*from\\s+[a-z0-9_`.]+.*") ||
+                           value.matches(".*\\bor\\s*\\(?\\s*select\\s*(\\*|[a-z0-9_`,.()]+)\\s*from\\s+[a-z0-9_`.]+.*");
                     if (isSelectAttack) {
                         log.debug("检测到可能的 SELECT 攻击: {}", value);
                     }
@@ -311,7 +316,7 @@ public class SecurityUtils {
                     }
                     return isDdlAttack;
                 case "union":
-                    boolean isUnionAttack = value.contains("union select");
+                    boolean isUnionAttack = value.matches(".*\\bunion\\s+(all\\s+)?select\\b.*");
                     if (isUnionAttack) {
                         log.debug("检测到可能的 UNION 攻击: {}", value);
                     }
@@ -333,7 +338,9 @@ public class SecurityUtils {
                 case "select":
                     // select 通常是攻击的一部分,后面跟着列名和 from
                     boolean isSelectAttack = value.matches(".*select\\s+[a-z0-9_*]+\\s+from\\s+[a-z0-9_]+.*") || 
-                           value.matches(".*select\\s+\\*\\s+from\\s+[a-z0-9_]+.*");
+                           value.matches(".*select\\s+\\*\\s+from\\s+[a-z0-9_]+.*") ||
+                           value.matches(".*select\\s*(\\*|[a-z0-9_`,.()]+)\\s*from\\s+[a-z0-9_`.]+.*") ||
+                           value.matches(".*\\bor\\s*\\(?\\s*select\\s*(\\*|[a-z0-9_`,.()]+)\\s*from\\s+[a-z0-9_`.]+.*");
                     if (isSelectAttack) {
                         log.debug("[严格模式] 检测到可能的 SELECT 攻击: {}", value);
                     }
@@ -371,7 +378,7 @@ public class SecurityUtils {
                     return isDdlAttack;
                 case "union":
                     // union 通常是攻击的一部分,后面跟着 select
-                    boolean isUnionAttack = value.contains("union select");
+                    boolean isUnionAttack = value.matches(".*\\bunion\\s+(all\\s+)?select\\b.*");
                     if (isUnionAttack) {
                         log.debug("[严格模式] 检测到可能的 UNION 攻击: {}", value);
                     }

+ 11 - 16
national-motion-base-core/src/main/java/org/jeecg/config/filter/XssAndSqlInjectionFilter.java

@@ -30,7 +30,7 @@ import java.util.Set;
  * <ul>
  *     <li>请求参数(Query String 和 Form Data)</li>
  *     <li>请求体(JSON、XML 等)</li>
- *     <li>请求头</li>
+ *     <li>配置中显式指定的请求头</li>
  * </ul>
  *
  * @author zsElectric
@@ -51,16 +51,9 @@ public class XssAndSqlInjectionFilter implements Filter {
             "/webjars"
     ));
 
-    /**
-     * 默认需要检查的请求头
-     */
-    private static final Set<String> DEFAULT_CHECK_HEADERS = new HashSet<>(Arrays.asList(
-            "Referer",
-            "X-Forwarded-For"
-    ));
-
     public XssAndSqlInjectionFilter(SecurityFilterProperties properties) {
         this.properties = properties;
+        SecurityUtils.setSqlStrictMode(Boolean.TRUE.equals(properties.getSqlStrictMode()));
     }
 
     @Override
@@ -203,15 +196,17 @@ public class XssAndSqlInjectionFilter implements Filter {
         }
 
         /**
-         * 检查请求头
+         * 检查配置中显式指定的请求头;默认不检查,避免代理链和 Referer 特殊字符误拦截。
          */
         private void checkHeaders() {
-            Set<String> headersToCheck = new HashSet<>(DEFAULT_CHECK_HEADERS);
-            if (properties.getCheckHeaders() != null && !properties.getCheckHeaders().isEmpty()) {
-                headersToCheck.addAll(properties.getCheckHeaders());
+            if (properties.getCheckHeaders() == null || properties.getCheckHeaders().isEmpty()) {
+                return;
             }
             
-            for (String headerName : headersToCheck) {
+            for (String headerName : properties.getCheckHeaders()) {
+                if (StrUtil.isBlank(headerName)) {
+                    continue;
+                }
                 String headerValue = super.getHeader(headerName);
                 if (headerValue != null) {
                     checkContent(headerValue, "请求头[" + headerName + "]");
@@ -227,13 +222,13 @@ public class XssAndSqlInjectionFilter implements Filter {
          */
         private void checkContent(String content, String location) {
             // XSS 检测
-            if (properties.getXssEnabled() && SecurityUtils.containsXss(content)) {
+            if (Boolean.TRUE.equals(properties.getXssEnabled()) && SecurityUtils.containsXss(content)) {
 //                throw new JeecgBootException("检测到 XSS 攻击尝试,位置: " + location);
                 throw new JeecgBootException("用户输入包含非法内容,请输入合法内容!");
             }
 
             // SQL 注入检测
-            if (properties.getSqlInjectionEnabled() && SecurityUtils.containsSqlInjection(content)) {
+            if (Boolean.TRUE.equals(properties.getSqlInjectionEnabled()) && SecurityUtils.containsSqlInjection(content)) {
                 throw new JeecgBootException("用户输入包含非法内容,请输入合法内容!");
             }
         }

+ 45 - 147
national-motion-module-system/national-motion-system-biz/src/main/java/org/jeecg/modules/app/service/impl/OrderServiceImpl.java

@@ -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);
                 }
             }
 

+ 31 - 12
national-motion-module-system/national-motion-system-biz/src/main/java/org/jeecg/modules/quartz/job/TodayExpireOrderJobService.java

@@ -1,17 +1,12 @@
 package org.jeecg.modules.quartz.job;
 
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
-import lombok.AllArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.jeecg.modules.rabbitmq.DelayedMessageService;
-import org.jeecg.modules.system.app.entity.AppOrder;
 import org.jeecg.modules.system.app.entity.AppOrderProInfo;
-import org.jeecg.modules.system.app.mapper.AppGmtInfoMapper;
 import org.jeecg.modules.system.app.service.IAppOrderProInfoService;
-import org.jeecg.modules.system.app.service.IAppOrderService;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
 import java.time.LocalDate;
@@ -29,7 +24,6 @@ import java.util.regex.Pattern;
  * @description 统计即将今日过期的子订单,发送延迟队列,在过期时修改订单过期状态,排除过期自动退款(无固定场)的订单
  */
 @Slf4j
-@AllArgsConstructor
 @Component
 public class TodayExpireOrderJobService {
 
@@ -42,27 +36,33 @@ public class TodayExpireOrderJobService {
      * @Description 统计24小时内即将过期的订单,并发送过期订单延迟消息
      */
     @Scheduled(cron = "0 0 4 * * ?")
-    @Transactional(rollbackFor = Exception.class)
     public void execute() {
         log.info("开始执行统计24小时内即将过期的订单,并发送过期订单延迟消息定时任务");
         int totalCount = 0;
         int successCount = 0;
+        int expiredCount = 0;
         int errorCount = 0;
         
         try {
-            // 查询24小时内即将过期的子订单(状态为待使用)
+            // SQL层增加时间范围预过滤,只查询今天和明天过期的待使用订单,避免全表扫描
+            String todayStr = LocalDate.now().format(DATE_FORMATTER);
+            String tomorrowStr = LocalDate.now().plusDays(1).format(DATE_FORMATTER);
+            
             List<AppOrderProInfo> orderProInfoList = appOrderProInfoService.list(
                 Wrappers.lambdaQuery(AppOrderProInfo.class)
                     .eq(AppOrderProInfo::getOrderStatus, 1)
+                    .and(w -> w.likeRight(AppOrderProInfo::getExpireTime, todayStr)
+                        .or()
+                        .likeRight(AppOrderProInfo::getExpireTime, tomorrowStr))
             );
             
             totalCount = orderProInfoList.size();
-            log.info("查询到待使用订单总数:{}", totalCount);
+            log.info("查询到待使用且即将过期的订单总数:{}", totalCount);
             
             for (AppOrderProInfo orderProInfo : orderProInfoList) {
                 try {
                     String expireTime = orderProInfo.getExpireTime();
-                    
+
                     // 增加空值检查
                     if (expireTime == null || expireTime.trim().isEmpty()) {
                         log.warn("订单[{}]过期时间为空,跳过处理", orderProInfo.getId());
@@ -93,6 +93,15 @@ public class TodayExpireOrderJobService {
                             (int) differenceIfWithin24Hours
                         );
                         successCount++;
+                    } else if (isExpired(date)) {
+                        // 处理已过期但状态未更新的订单(如凌晨0:00~4:00之间过期的订单)
+                        log.info("已过期的子订单,立即发送过期消息:订单ID={}, 订单编号={}, 过期时间={}", 
+                            orderProInfo.getId(), orderProInfo.getOrderCode(), expireTime);
+                        delayedMessageService.sendOrderExpireMessage(
+                            orderProInfo.getId(),
+                            0
+                        );
+                        expiredCount++;
                     }
                     
                 } catch (IllegalArgumentException e) {
@@ -108,8 +117,8 @@ public class TodayExpireOrderJobService {
         } catch (Exception e) {
             log.error("统计24小时内即将过期的订单,并发送过期订单延迟消息异常", e);
         } finally {
-            log.info("定时任务执行完毕 - 总订单数:{},成功发送消息:{},处理失败:{}", 
-                totalCount, successCount, errorCount);
+            log.info("定时任务执行完毕 - 总订单数:{},成功发送延迟消息:{},已过期立即处理:{},处理失败:{}", 
+                totalCount, successCount, expiredCount, errorCount);
         }
     }
 
@@ -180,6 +189,16 @@ public class TodayExpireOrderJobService {
         }
     }
 
+    /**
+     * 判断传入时间是否已过期(在当前时间之前)
+     */
+    private static boolean isExpired(Date targetDate) {
+        if (targetDate == null) {
+            return false;
+        }
+        return targetDate.getTime() <= System.currentTimeMillis();
+    }
+
     private static Date toDate(LocalDateTime localDateTime) {
         return Date.from(
                 localDateTime.atZone(ZoneId.systemDefault())

+ 12 - 1
national-motion-module-system/national-motion-system-biz/src/main/java/org/jeecg/modules/rabbitmq/OrderExpireDelayedMessageListener.java

@@ -89,7 +89,18 @@ public class OrderExpireDelayedMessageListener {
 
             //执行业务代码
             AppOrder appOrder = appOrderService.getById(appOrderProInfo.getOrderId());
+            if (appOrder == null) {
+                log.warn("子订单对应主订单不存在或已删除,子订单ID={},主订单ID={},订单编号={},跳过主订单过期状态更新",
+                        appOrderProInfo.getId(), appOrderProInfo.getOrderId(), appOrderProInfo.getOrderCode());
+                return;
+            }
+
             List<AppOrderProInfo> appOrderProInfoList = appOrderProInfoService.list(Wrappers.<AppOrderProInfo>lambdaQuery().eq(AppOrderProInfo::getOrderId, appOrderProInfo.getOrderId()));
+            if (CollUtil.isEmpty(appOrderProInfoList)) {
+                log.warn("主订单下未查询到子订单,主订单ID={},订单编号={},跳过主订单过期状态更新",
+                        appOrderProInfo.getOrderId(), appOrder.getOrderCode());
+                return;
+            }
 
             if (appOrderProInfoList.stream().filter(orderProInfo -> Objects.equals(orderProInfo.getOrderStatus(),
                     CommonConstant.ORDER_STATUS_3)).count() == appOrderProInfoList.size()) {
@@ -100,4 +111,4 @@ public class OrderExpireDelayedMessageListener {
         }
 
     }
-}
+}

+ 5 - 2
national-motion-module-system/national-motion-system-biz/src/main/java/org/jeecg/modules/system/app/mapper/xml/AppSitePriceRulesMapper.xml

@@ -138,7 +138,7 @@
     <select id="timeSlot" resultType="org.jeecg.modules.app.vo.OrderVO$PreviewOrderPlaceSchoolChild">
         SELECT
             r.id,
-            r.inventory,
+            r.ticket_num AS inventory,
             r.selling_price AS price,
             COALESCE(t.is_teaching, r.is_teaching) AS is_teaching,
             r.start_time,
@@ -270,6 +270,9 @@
                 <if test="item.sellingPrice != null and item.sellingPrice != ''">
                     selling_price = #{item.sellingPrice},
                 </if>
+                <if test="item.ticketNum != null and item.ticketNum != ''">
+                    ticket_num = #{item.ticketNum},
+                </if>
                 <if test="item.isTeaching != null and item.isTeaching != ''">
                     is_teaching = #{item.isTeaching},
                 </if>
@@ -306,4 +309,4 @@
         </foreach>
     </update>
 
-</mapper>
+</mapper>

+ 2 - 2
national-motion-module-system/national-motion-system-biz/src/main/java/org/jeecg/modules/system/app/mapper/xml/AppTeachingTimeMapper.xml

@@ -17,7 +17,7 @@
         END AS named_day
         FROM
 	        nm_teaching_time a
-	    LEFT JOIN ( SELECT date_of_sale, org_code, SUM( inventory ) AS inventory FROM nm_site_price_rules WHERE org_code = #{orgCode} GROUP BY date_of_sale, org_code ) b ON a.DAY = b.date_of_sale
+	    LEFT JOIN ( SELECT date_of_sale, org_code, SUM( ticket_num ) AS inventory FROM nm_site_price_rules WHERE org_code = #{orgCode} GROUP BY date_of_sale, org_code ) b ON a.DAY = b.date_of_sale
     	AND a.org_code = b.org_code
         WHERE
 	    a.DAY BETWEEN CURDATE()
@@ -60,4 +60,4 @@
         </foreach>
     </insert>
 
-</mapper>
+</mapper>

+ 2 - 4
national-motion-module-system/national-motion-system-start/src/main/resources/application-prod.yml

@@ -440,7 +440,5 @@ security:
       - /swagger-ui
       - /v3/api-docs
       - /webjars
-    # 检查的请求头列表
-    check-headers:
-      - Referer
-      - X-Forwarded-For
+    # 默认不检查请求头,避免 Referer/X-Forwarded-For 误判;如需启用请显式配置具体 header 名称
+    check-headers: []