소스 검색

Merge branch 'sit' into uat-20260202

dujian 1 개월 전
부모
커밋
1d57cf1ef0
72개의 변경된 파일2397개의 추가작업 그리고 402개의 파일을 삭제
  1. 38 20
      alien-api/src/main/resources/logback-spring.xml
  2. 10 11
      alien-dining/doc/订单变更记录表使用说明.md
  3. 1 1
      alien-dining/src/main/java/shop/alien/dining/controller/StoreInfoController.java
  4. 8 2
      alien-dining/src/main/java/shop/alien/dining/service/impl/CartServiceImpl.java
  5. 46 2
      alien-dining/src/main/java/shop/alien/dining/service/impl/DiningServiceImpl.java
  6. 4 1
      alien-dining/src/main/java/shop/alien/dining/service/impl/StoreInfoServiceImpl.java
  7. 90 17
      alien-dining/src/main/java/shop/alien/dining/service/impl/StoreOrderServiceImpl.java
  8. 29 16
      alien-dining/src/main/resources/logback-spring.xml
  9. 1 1
      alien-entity/src/main/java/shop/alien/entity/store/MerchantPaymentOrder.java
  10. 1 1
      alien-entity/src/main/java/shop/alien/entity/store/RefundRecord.java
  11. 2 2
      alien-entity/src/main/java/shop/alien/entity/store/StoreOrderChangeLog.java
  12. 1 1
      alien-entity/src/main/java/shop/alien/entity/store/UserReservationOrder.java
  13. 8 3
      alien-entity/src/main/java/shop/alien/entity/store/vo/CategoryWithCuisinesVO.java
  14. 2 2
      alien-entity/src/main/java/shop/alien/entity/store/vo/OrderChangeLogBatchVO.java
  15. 3 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/OrderChangeLogItemVO.java
  16. 3 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/OrderCuisineItemVO.java
  17. 16 0
      alien-entity/src/main/java/shop/alien/mapper/UserReservationOrderMapper.java
  18. 4 1
      alien-entity/src/main/java/shop/alien/mapper/second/SecondEntrustUserMapper.java
  19. 8 6
      alien-entity/src/main/resources/mapper/StoreReservationMapper.xml
  20. 2 2
      alien-entity/src/main/resources/mapper/UserReservationMapper.xml
  21. 24 0
      alien-entity/src/main/resources/mapper/UserReservationOrderMapper.xml
  22. 39 20
      alien-gateway/src/main/resources/logback-spring.xml
  23. 16 0
      alien-job/src/main/java/shop/alien/job/feign/AlienStoreFeign.java
  24. 32 0
      alien-job/src/main/java/shop/alien/job/store/RefundRetryJob.java
  25. 33 0
      alien-job/src/main/java/shop/alien/job/store/ReservationArrivalReminderJob.java
  26. 38 20
      alien-job/src/main/resources/logback-spring.xml
  27. 1 1
      alien-lawyer/src/main/resources/bootstrap.yml
  28. 38 20
      alien-lawyer/src/main/resources/logback-spring.xml
  29. 5 4
      alien-second/src/main/java/shop/alien/second/controller/SecondEntrustUserController.java
  30. 1 1
      alien-second/src/main/java/shop/alien/second/service/SecondEntrustUserService.java
  31. 2 2
      alien-second/src/main/java/shop/alien/second/service/impl/SecondEntrustUserServiceImpl.java
  32. 38 20
      alien-second/src/main/resources/logback-spring.xml
  33. 18 2
      alien-store-platform/src/main/java/shop/alien/storeplatform/controller/LicenseController.java
  34. 3 2
      alien-store-platform/src/main/java/shop/alien/storeplatform/controller/StorePlatformBusinessInfoController.java
  35. 3 2
      alien-store-platform/src/main/java/shop/alien/storeplatform/service/StorePlatformBusinessInfoService.java
  36. 51 7
      alien-store-platform/src/main/java/shop/alien/storeplatform/service/impl/StorePlatformBusinessInfoServiceImpl.java
  37. 38 20
      alien-store-platform/src/main/resources/logback-spring.xml
  38. 18 1
      alien-store/src/main/java/shop/alien/store/controller/DiningServiceController.java
  39. 20 0
      alien-store/src/main/java/shop/alien/store/controller/ReservationJobController.java
  40. 14 14
      alien-store/src/main/java/shop/alien/store/controller/StoreBookingBusinessHoursController.java
  41. 47 8
      alien-store/src/main/java/shop/alien/store/controller/StoreBookingCategoryController.java
  42. 29 32
      alien-store/src/main/java/shop/alien/store/controller/StoreBookingSettingsController.java
  43. 46 6
      alien-store/src/main/java/shop/alien/store/controller/StoreBookingTableController.java
  44. 64 0
      alien-store/src/main/java/shop/alien/store/controller/StoreReservationController.java
  45. 13 0
      alien-store/src/main/java/shop/alien/store/feign/DiningServiceFeign.java
  46. 25 0
      alien-store/src/main/java/shop/alien/store/service/ArrivalReminderNoticeService.java
  47. 8 0
      alien-store/src/main/java/shop/alien/store/service/MerchantPaymentOrderService.java
  48. 32 0
      alien-store/src/main/java/shop/alien/store/service/ReservationNoticeAsyncService.java
  49. 20 0
      alien-store/src/main/java/shop/alien/store/service/StoreBookingCategoryService.java
  50. 21 0
      alien-store/src/main/java/shop/alien/store/service/StoreBookingTableService.java
  51. 31 0
      alien-store/src/main/java/shop/alien/store/service/StoreReservationService.java
  52. 9 0
      alien-store/src/main/java/shop/alien/store/service/UserReservationOrderService.java
  53. 16 0
      alien-store/src/main/java/shop/alien/store/service/UserReservationService.java
  54. 79 0
      alien-store/src/main/java/shop/alien/store/service/impl/ArrivalReminderNoticeServiceImpl.java
  55. 1 1
      alien-store/src/main/java/shop/alien/store/service/impl/CommonCommentServiceImpl.java
  56. 43 42
      alien-store/src/main/java/shop/alien/store/service/impl/LicenseAuditAsyncService.java
  57. 2 2
      alien-store/src/main/java/shop/alien/store/service/impl/LifeUserLearningVideoServiceImpl.java
  58. 13 0
      alien-store/src/main/java/shop/alien/store/service/impl/MerchantPaymentOrderServiceImpl.java
  59. 206 0
      alien-store/src/main/java/shop/alien/store/service/impl/ReservationNoticeAsyncServiceImpl.java
  60. 31 31
      alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingBusinessHoursServiceImpl.java
  61. 48 1
      alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingCategoryServiceImpl.java
  62. 236 4
      alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingSettingsServiceImpl.java
  63. 88 4
      alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingTableServiceImpl.java
  64. 152 0
      alien-store/src/main/java/shop/alien/store/service/impl/StoreReservationServiceImpl.java
  65. 1 0
      alien-store/src/main/java/shop/alien/store/service/impl/StoreStaffConfigServiceImpl.java
  66. 6 0
      alien-store/src/main/java/shop/alien/store/service/impl/UserReservationOrderServiceImpl.java
  67. 185 13
      alien-store/src/main/java/shop/alien/store/service/impl/UserReservationServiceImpl.java
  68. 56 3
      alien-store/src/main/java/shop/alien/store/strategy/merchantPayment/impl/MerchantAlipayPaymentStrategyImpl.java
  69. 58 4
      alien-store/src/main/java/shop/alien/store/strategy/merchantPayment/impl/MerchantWechatPaymentStrategyImpl.java
  70. 79 0
      alien-store/src/main/java/shop/alien/store/util/ali/AliSms.java
  71. 38 20
      alien-store/src/main/resources/logback-spring.xml
  72. 5 6
      store_order_change_log.sql

+ 38 - 20
alien-api/src/main/resources/logback-spring.xml

@@ -4,10 +4,11 @@
 
     <!-- 定义全局参数常量 -->
     <property name="log.level" value="debug"/>
-    <property name="log.maxHistory" value="30"/><!-- 30表示30天 -->
+    <property name="log.maxHistory" value="30"/><!-- 30表示保留30个归档 -->
     <springProperty scope="context" name="logging.path" source="logging.path" defaultValue="C:/project/ext/log"/>
-    <!--输出文件前缀-->
-    <property name="FILENAME" value="alien"/>
+    <!--输出文件前缀;各服务日志写入各自子目录 logging.path/FILENAME/ -->
+    <property name="FILENAME" value="alien-api"/>
+    <property name="LOG_DIR" value="${logging.path}/${FILENAME}"/>
 
     <!-- 文件输出格式 -->
     <property name="FILE_LOG_PATTERN" value="[%d{MM/dd HH:mm:ss.SSS}][%-10.10thread][%-5level][%-40.40c{1}:%5line]:[%15method] || %m%n"/>
@@ -28,15 +29,19 @@
     </appender>
 
     <!--2. 输出到文档-->
-    <!-- DEBUG 日志 -->
+    <!-- DEBUG 日志:按 1MB 大小滚动,满 1MB 自动切到下一个文件 -->
     <appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/DEBUG.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_DEBUG.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/DEBUG.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_DEBUG.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>DEBUG</level>
@@ -45,15 +50,19 @@
         </filter>
     </appender>
 
-    <!-- INFO 日志 -->
+    <!-- INFO 日志:按 1MB 大小滚动 -->
     <appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/INFO.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_INFO.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/INFO.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_INFO.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>INFO</level>
@@ -62,15 +71,19 @@
         </filter>
     </appender>
 
-    <!-- WARN 日志 -->
+    <!-- WARN 日志:按 1MB 大小滚动 -->
     <appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/WARN.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_WARN.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/WARN.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_WARN.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>WARN</level>
@@ -79,14 +92,19 @@
         </filter>
     </appender>
 
+    <!-- ERROR 日志:按 1MB 大小滚动 -->
     <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/ERROR.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_ERROR.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/ERROR.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_ERROR.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>ERROR</level>
@@ -97,8 +115,8 @@
 
     <!-- 降噪配置 -->
     <logger name="springfox.documentation.spring.web.readers.operation.CachingOperationNameGenerator" level="WARN"/>
-    <logger name="org.springframework.security.web.DefaultSecurityFilterChain " level="WARN"/>
-    <logger name="com.netflix.config.sources.URLConfigurationSource " level="WARN"/>
+    <logger name="org.springframework.security.web.DefaultSecurityFilterChain" level="WARN"/>
+    <logger name="com.netflix.config.sources.URLConfigurationSource" level="WARN"/>
     <logger name="com.netflix.discovery" level="WARN"/>
     <logger name="org.apache.catalina.core.ContainerBase.[Tomcat].[localhost].[/]" level="WARN"/>
 

+ 10 - 11
alien-dining/doc/订单变更记录表使用说明.md

@@ -2,7 +2,7 @@
 
 ## 表设计目的
 
-记录每次下单/加餐时商品种类和数量的变化,用于展示每次操作都加了什么商品。
+记录每次下单/更新订单时商品种类和数量的变化,用于展示每次操作都加了什么商品。
 
 ## 核心字段说明
 
@@ -13,8 +13,7 @@
 
 ### 2. operation_type(操作类型)
 - **1:首次下单** - 创建订单时的操作
-- **2:加餐** - 通过加餐接口添加商品
-- **3:更新订单** - 更新订单时重新下单
+- **3:更新订单** - 更新订单时重新下单(只记录新增或数量增加的商品)
 
 ### 3. quantity_change(数量变化)
 - **正数**:新增的数量
@@ -75,7 +74,7 @@ WHERE batch_no = 'ORDER123_20250202143025'
 ORDER BY cuisine_id;
 ```
 
-### 场景4:查询加餐记录
+### 场景4:查询更新订单记录
 ```sql
 SELECT 
     batch_no,
@@ -85,7 +84,7 @@ SELECT
     amount_change
 FROM store_order_change_log 
 WHERE order_id = 123 
-  AND operation_type = 2  -- 加餐
+  AND operation_type = 3  -- 更新订单
   AND delete_flag = 0
 ORDER BY operation_time DESC;
 ```
@@ -100,13 +99,13 @@ ORDER BY operation_time DESC;
 | 石板肉酱豆腐 | 1 | 0 | 1 | 19.90 |
 | 经典三杯鸡 | 1 | 0 | 1 | 26.90 |
 
-### 第一次加餐(batch_no: ORDER123_20250202143000)
+### 第一次更新订单(batch_no: ORDER123_20250202143000)
 | cuisine_name | quantity_change | quantity_before | quantity_after | amount_change |
 |-------------|----------------|-----------------|----------------|---------------|
 | 经典三杯鸡 | 1 | 1 | 2 | 26.90 |
 | 宫保鸡丁 | 1 | 0 | 1 | 28.00 |
 
-### 第二次加餐(batch_no: ORDER123_20250202150000)
+### 第二次更新订单(batch_no: ORDER123_20250202150000)
 | cuisine_name | quantity_change | quantity_before | quantity_after | amount_change |
 |-------------|----------------|-----------------|----------------|---------------|
 | 石板肉酱豆腐 | 1 | 1 | 2 | 19.90 |
@@ -119,11 +118,11 @@ ORDER BY operation_time DESC;
   • 石板肉酱豆腐 x1
   • 经典三杯鸡 x1
 
-14:30 - 加餐
+14:30 - 更新订单
   • 经典三杯鸡 x1(累计:2)
   • 宫保鸡丁 x1
 
-15:00 - 加餐
+15:00 - 更新订单
   • 石板肉酱豆腐 x1(累计:2)
 ```
 
@@ -134,12 +133,12 @@ ORDER BY operation_time DESC;
   经典三杯鸡 x1    ¥26.90
   小计:¥46.80
 
-批次2:加餐(14:30)
+批次2:更新订单(14:30)
   经典三杯鸡 x1    ¥26.90
   宫保鸡丁 x1      ¥28.00
   小计:¥54.90
 
-批次3:加餐(15:00)
+批次3:更新订单(15:00)
   石板肉酱豆腐 x1  ¥19.90
   小计:¥19.90
 ```

+ 1 - 1
alien-dining/src/main/java/shop/alien/dining/controller/StoreInfoController.java

@@ -64,7 +64,7 @@ public class StoreInfoController {
         }
     }
 
-    @ApiOperation(value = "根据门店ID查询菜品种类及各类别下菜品", notes = "一次返回所有菜品种类及每个分类下的菜品列表;可选 keyword 按菜品名称模糊查询")
+    @ApiOperation(value = "根据门店ID查询菜品种类及各类别下菜品", notes = "一次返回所有菜品种类及每个分类下的菜品列表;可选 keyword 按菜品名称模糊查询。返回中 category 与 /store/info/categories 单条结构一致,cuisines 与 /store/info/cuisines 返回结构一致,保证字段完整一致。")
     @GetMapping("/categories-with-cuisines")
     public R<List<CategoryWithCuisinesVO>> getCategoriesWithCuisinesByStoreId(
             @ApiParam(value = "门店ID", required = true) @RequestParam Integer storeId,

+ 8 - 2
alien-dining/src/main/java/shop/alien/dining/service/impl/CartServiceImpl.java

@@ -535,9 +535,15 @@ public class CartServiceImpl implements CartService {
 
             if (existingItem != null) {
                 // 合并数量
-                existingItem.setQuantity(existingItem.getQuantity() + fromItem.getQuantity());
+                int newQuantity = (existingItem.getQuantity() != null ? existingItem.getQuantity() : 0)
+                        + (fromItem.getQuantity() != null ? fromItem.getQuantity() : 0);
+                existingItem.setQuantity(newQuantity);
                 existingItem.setSubtotalAmount(existingItem.getUnitPrice()
-                        .multiply(BigDecimal.valueOf(existingItem.getQuantity())));
+                        .multiply(BigDecimal.valueOf(newQuantity)));
+                // 合并已下单数量(换桌后目标桌需保留两边的已下单数量)
+                int toLocked = existingItem.getLockedQuantity() != null ? existingItem.getLockedQuantity() : 0;
+                int fromLocked = fromItem.getLockedQuantity() != null ? fromItem.getLockedQuantity() : 0;
+                existingItem.setLockedQuantity(toLocked + fromLocked > 0 ? toLocked + fromLocked : null);
             } else {
                 mergedItems.add(fromItem);
             }

+ 46 - 2
alien-dining/src/main/java/shop/alien/dining/service/impl/DiningServiceImpl.java

@@ -333,6 +333,31 @@ public class DiningServiceImpl implements DiningService {
         // 获取购物车
         CartDTO cart = cartService.getCart(tableId);
 
+        // 为购物车项补全菜品标签(购物车从 DB 加载时可能无 tags)
+        List<CartItemDTO> items = cart.getItems();
+        if (items != null && !items.isEmpty()) {
+            Set<Integer> cuisineIds = items.stream()
+                    .map(CartItemDTO::getCuisineId)
+                    .filter(Objects::nonNull)
+                    .collect(Collectors.toSet());
+            if (!cuisineIds.isEmpty()) {
+                List<StoreCuisine> cuisines = storeCuisineMapper.selectBatchIds(new ArrayList<>(cuisineIds));
+                Map<Integer, String> tagsMap = new HashMap<>();
+                if (cuisines != null) {
+                    for (StoreCuisine c : cuisines) {
+                        if (c.getTags() != null) {
+                            tagsMap.put(c.getId(), c.getTags());
+                        }
+                    }
+                }
+                for (CartItemDTO item : items) {
+                    if (item.getCuisineId() != null && item.getTags() == null) {
+                        item.setTags(tagsMap.get(item.getCuisineId()));
+                    }
+                }
+            }
+        }
+
         // 检查订单锁定
         Integer lockUserId = orderLockService.checkOrderLock(tableId);
         boolean isLocked = lockUserId != null && !lockUserId.equals(userId);
@@ -342,7 +367,7 @@ public class DiningServiceImpl implements DiningService {
         vo.setTableNumber(pageInfo.getTableNumber());
         vo.setDinerCount(pageInfo.getDinerCount() != null ? pageInfo.getDinerCount() : dinerCount);
         // 联系电话和备注由前端传入,这里不设置默认值
-        vo.setItems(cart.getItems());
+        vo.setItems(items != null ? items : cart.getItems());
         vo.setTotalAmount(cart.getTotalAmount());
         vo.setIsLocked(isLocked);
         vo.setLockUserId(lockUserId);
@@ -516,7 +541,25 @@ public class DiningServiceImpl implements DiningService {
         detailWrapper.orderByDesc(shop.alien.entity.store.StoreOrderDetail::getCreatedTime);
         List<shop.alien.entity.store.StoreOrderDetail> details = storeOrderDetailMapper.selectList(detailWrapper);
 
-        // 转换为CartItemDTO
+        // 批量查询菜品标签
+        Set<Integer> cuisineIds = details.stream()
+                .map(shop.alien.entity.store.StoreOrderDetail::getCuisineId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        Map<Integer, String> cuisineIdToTags = new HashMap<>();
+        if (!cuisineIds.isEmpty()) {
+            List<StoreCuisine> cuisines = storeCuisineMapper.selectBatchIds(new ArrayList<>(cuisineIds));
+            if (cuisines != null) {
+                for (StoreCuisine c : cuisines) {
+                    if (c.getTags() != null) {
+                        cuisineIdToTags.put(c.getId(), c.getTags());
+                    }
+                }
+            }
+        }
+
+        // 转换为CartItemDTO(含菜品标签)
+        Map<Integer, String> finalTagsMap = cuisineIdToTags;
         List<shop.alien.entity.store.dto.CartItemDTO> items = details.stream().map(detail -> {
             shop.alien.entity.store.dto.CartItemDTO item = new shop.alien.entity.store.dto.CartItemDTO();
             item.setCuisineId(detail.getCuisineId());
@@ -529,6 +572,7 @@ public class DiningServiceImpl implements DiningService {
             item.setAddUserId(detail.getAddUserId());
             item.setAddUserPhone(detail.getAddUserPhone());
             item.setRemark(detail.getRemark());
+            item.setTags(detail.getCuisineId() != null ? finalTagsMap.get(detail.getCuisineId()) : null);
             return item;
         }).collect(Collectors.toList());
 

+ 4 - 1
alien-dining/src/main/java/shop/alien/dining/service/impl/StoreInfoServiceImpl.java

@@ -74,11 +74,12 @@ public class StoreInfoServiceImpl implements StoreInfoService {
             return new java.util.ArrayList<>();
         }
         
-        // 查询该门店下所有上架的菜品
+        // 查询该门店下所有上架的菜品(与 getCategoriesWithCuisinesByStoreId 中 cuisines 查询条件、排序一致)
         LambdaQueryWrapper<StoreCuisine> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(StoreCuisine::getStoreId, category.getStoreId());
         wrapper.eq(StoreCuisine::getDeleteFlag, 0);
         wrapper.eq(StoreCuisine::getShelfStatus, 1); // 只查询上架的菜品
+        wrapper.orderByAsc(StoreCuisine::getId); // 与 categories-with-cuisines 中 cuisines 顺序一致
         
         List<StoreCuisine> allCuisines = storeCuisineMapper.selectList(wrapper);
         
@@ -111,10 +112,12 @@ public class StoreInfoServiceImpl implements StoreInfoService {
         if (categories == null || categories.isEmpty()) {
             return new ArrayList<>();
         }
+        // 与 getCuisinesByCategoryId 相同的查询条件与排序,保证 cuisines 字段与 /store/info/cuisines 一致
         LambdaQueryWrapper<StoreCuisine> cuisineWrapper = new LambdaQueryWrapper<>();
         cuisineWrapper.eq(StoreCuisine::getStoreId, storeId);
         cuisineWrapper.eq(StoreCuisine::getDeleteFlag, 0);
         cuisineWrapper.eq(StoreCuisine::getShelfStatus, 1);
+        cuisineWrapper.orderByAsc(StoreCuisine::getId);
         List<StoreCuisine> allCuisines = storeCuisineMapper.selectList(cuisineWrapper);
         if (allCuisines == null) {
             allCuisines = new ArrayList<>();

+ 90 - 17
alien-dining/src/main/java/shop/alien/dining/service/impl/StoreOrderServiceImpl.java

@@ -751,6 +751,24 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         Map<Integer, List<StoreOrderDetail>> detailsMap = allDetails.stream()
                 .collect(Collectors.groupingBy(StoreOrderDetail::getOrderId));
         
+        // 4.1 批量查询菜品标签(用于分页列表展示)
+        Set<Integer> cuisineIds = allDetails.stream()
+                .map(StoreOrderDetail::getCuisineId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        Map<Integer, String> cuisineIdToTags = new HashMap<>();
+        if (!cuisineIds.isEmpty()) {
+            List<StoreCuisine> cuisines = storeCuisineMapper.selectBatchIds(new ArrayList<>(cuisineIds));
+            if (cuisines != null) {
+                for (StoreCuisine c : cuisines) {
+                    if (c.getTags() != null) {
+                        cuisineIdToTags.put(c.getId(), c.getTags());
+                    }
+                }
+            }
+        }
+        Map<Integer, String> finalCuisineIdToTags = cuisineIdToTags;
+        
         // 5. 批量查询门店名称
         Set<Integer> storeIds = orders.stream().map(StoreOrder::getStoreId).filter(Objects::nonNull).collect(Collectors.toSet());
         Map<Integer, String> storeNameMap = new HashMap<>();
@@ -769,7 +787,7 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             vo.setOrder(order);
             vo.setStoreName(storeNameMap.getOrDefault(order.getStoreId(), ""));
             
-            // 获取该订单的菜品列表
+            // 获取该订单的菜品列表(含菜品标签)
             List<StoreOrderDetail> orderDetails = detailsMap.getOrDefault(order.getId(), new ArrayList<>());
             List<OrderCuisineItemVO> cuisineItems = orderDetails.stream()
                     .map(detail -> {
@@ -779,6 +797,7 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                         item.setCuisineImage(detail.getCuisineImage());
                         item.setQuantity(detail.getQuantity());
                         item.setUnitPrice(detail.getUnitPrice());
+                        item.setTags(detail.getCuisineId() != null ? finalCuisineIdToTags.get(detail.getCuisineId()) : null);
                         return item;
                     })
                     .collect(Collectors.toList());
@@ -1292,6 +1311,24 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         Map<Integer, List<StoreOrderDetail>> detailsMap = allDetails.stream()
                 .collect(Collectors.groupingBy(StoreOrderDetail::getOrderId));
 
+        // 4.1 批量查询菜品标签(用于我的订单分页展示)
+        Set<Integer> cuisineIdsMy = allDetails.stream()
+                .map(StoreOrderDetail::getCuisineId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        Map<Integer, String> cuisineIdToTagsMy = new HashMap<>();
+        if (!cuisineIdsMy.isEmpty()) {
+            List<StoreCuisine> cuisinesMy = storeCuisineMapper.selectBatchIds(new ArrayList<>(cuisineIdsMy));
+            if (cuisinesMy != null) {
+                for (StoreCuisine c : cuisinesMy) {
+                    if (c.getTags() != null) {
+                        cuisineIdToTagsMy.put(c.getId(), c.getTags());
+                    }
+                }
+            }
+        }
+        Map<Integer, String> finalCuisineIdToTagsMy = cuisineIdToTagsMy;
+
         // 5. 批量查询门店名称
         Set<Integer> storeIds = orders.stream().map(StoreOrder::getStoreId).filter(Objects::nonNull).collect(Collectors.toSet());
         Map<Integer, String> storeNameMap = new HashMap<>();
@@ -1310,7 +1347,7 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             vo.setOrder(order);
             vo.setStoreName(storeNameMap.getOrDefault(order.getStoreId(), ""));
 
-            // 获取该订单的菜品列表
+            // 获取该订单的菜品列表(含菜品标签)
             List<StoreOrderDetail> orderDetails = detailsMap.getOrDefault(order.getId(), new ArrayList<>());
             List<OrderCuisineItemVO> cuisineItems = orderDetails.stream()
                     .map(detail -> {
@@ -1320,6 +1357,7 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                         item.setCuisineImage(detail.getCuisineImage());
                         item.setQuantity(detail.getQuantity());
                         item.setUnitPrice(detail.getUnitPrice());
+                        item.setTags(detail.getCuisineId() != null ? finalCuisineIdToTagsMy.get(detail.getCuisineId()) : null);
                         return item;
                     })
                     .collect(Collectors.toList());
@@ -1478,6 +1516,23 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         Map<String, List<StoreOrderChangeLog>> batchMap = logs.stream()
                 .collect(Collectors.groupingBy(StoreOrderChangeLog::getBatchNo));
         
+        // 2.1 批量查询菜品标签(用于订单详情展示)
+        Set<Integer> cuisineIds = logs.stream()
+                .map(StoreOrderChangeLog::getCuisineId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        Map<Integer, String> cuisineIdToTags = new HashMap<>();
+        if (!cuisineIds.isEmpty()) {
+            List<StoreCuisine> cuisines = storeCuisineMapper.selectBatchIds(new ArrayList<>(cuisineIds));
+            if (cuisines != null) {
+                for (StoreCuisine c : cuisines) {
+                    if (c.getTags() != null) {
+                        cuisineIdToTags.put(c.getId(), c.getTags());
+                    }
+                }
+            }
+        }
+        
         // 3. 转换为批次VO列表
         List<OrderChangeLogBatchVO> batchList = new ArrayList<>();
         for (Map.Entry<String, List<StoreOrderChangeLog>> entry : batchMap.entrySet()) {
@@ -1512,7 +1567,8 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             batchVO.setTotalAmountChange(totalAmountChange);
             batchVO.setItemCount(batchLogs.size());
             
-            // 转换为商品项VO列表
+            // 转换为商品项VO列表(含菜品标签)
+            Map<Integer, String> finalCuisineIdToTags = cuisineIdToTags;
             List<OrderChangeLogItemVO> items = batchLogs.stream().map(log -> {
                 OrderChangeLogItemVO itemVO = new OrderChangeLogItemVO();
                 itemVO.setCuisineId(log.getCuisineId());
@@ -1525,6 +1581,7 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                 itemVO.setQuantityAfter(log.getQuantityAfter());
                 itemVO.setAmountChange(log.getAmountChange());
                 itemVO.setRemark(log.getRemark());
+                itemVO.setTags(log.getCuisineId() != null ? finalCuisineIdToTags.get(log.getCuisineId()) : null);
                 return itemVO;
             }).collect(Collectors.toList());
             
@@ -1598,8 +1655,6 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         switch (operationType) {
             case 1:
                 return "首次下单";
-            case 2:
-                return "加餐";
             case 3:
                 return "更新订单";
             default:
@@ -1613,7 +1668,7 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
      * @param orderId 订单ID
      * @param orderNo 订单号
      * @param items 商品列表
-     * @param operationType 操作类型(1:首次下单, 2:加餐, 3:更新订单)
+     * @param operationType 操作类型(1:首次下单, 3:更新订单)
      * @param operationTime 操作时间
      * @param userId 操作人ID
      * @param userPhone 操作人手机号
@@ -1644,11 +1699,6 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                 quantityBefore = 0;
                 quantityAfter = currentQuantity != null ? currentQuantity : 0;
                 quantityChange = quantityAfter;
-            } else if (operationType == 2) {
-                // 加餐:记录加餐的商品
-                quantityBefore = lockedQuantity != null ? lockedQuantity : 0;
-                quantityAfter = currentQuantity != null ? currentQuantity : 0;
-                quantityChange = quantityAfter - quantityBefore;
             } else if (operationType == 3) {
                 // 更新订单:只记录新增的商品或数量增加的商品
                 if (lockedQuantity == null || lockedQuantity == 0) {
@@ -1842,28 +1892,51 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
     public shop.alien.entity.store.dto.CartDTO changeTable(Integer fromTableId, Integer toTableId, String changeReason, Integer userId) {
         log.info("换桌, fromTableId={}, toTableId={}, changeReason={}, userId={}", fromTableId, toTableId, changeReason, userId);
 
+        // 0. 校验:目标桌只能是空桌(空闲且无当前订单)
+        if (fromTableId.equals(toTableId)) {
+            throw new RuntimeException("原桌号与目标桌号不能相同");
+        }
+        StoreTable fromTable = storeTableMapper.selectById(fromTableId);
+        if (fromTable == null) {
+            throw new RuntimeException("原桌号不存在");
+        }
+        StoreTable toTable = storeTableMapper.selectById(toTableId);
+        if (toTable == null) {
+            throw new RuntimeException("目标桌号不存在");
+        }
+        if (!fromTable.getStoreId().equals(toTable.getStoreId())) {
+            throw new RuntimeException("原桌号与目标桌号须在同一门店");
+        }
+        // 空桌:状态为空闲(0)且无当前订单
+        boolean emptyStatus = (toTable.getStatus() == null || toTable.getStatus() == 0);
+        boolean noOrder = (toTable.getCurrentOrderId() == null);
+        if (!emptyStatus || !noOrder) {
+            throw new RuntimeException("只能换到空桌,请选择空闲且无订单的桌号");
+        }
+
         // 1. 迁移购物车
         shop.alien.entity.store.dto.CartDTO cart = cartService.migrateCart(fromTableId, toTableId);
 
         // 2. 迁移所有关联数据(订单、订单变更记录、优惠券使用记录等)
         migrateTableData(fromTableId, toTableId, userId);
 
-        // 3. 查询桌号信息
-        StoreTable fromTable = storeTableMapper.selectById(fromTableId);
-        StoreTable toTable = storeTableMapper.selectById(toTableId);
-
-        // 4. 记录换桌日志
+        // 3. 记录换桌日志(fromTable、toTable 已在步骤0中查询)
+        Date now = new Date();
         StoreTableLog tableLog = new StoreTableLog();
         tableLog.setStoreId(cart.getStoreId());
+        tableLog.setOrderId(fromTable.getCurrentOrderId()); // 有订单则记录,仅购物车换桌时为 null
         tableLog.setFromTableId(fromTableId);
         tableLog.setFromTableNumber(fromTable != null ? fromTable.getTableNumber() : null);
         tableLog.setToTableId(toTableId);
         tableLog.setToTableNumber(toTable != null ? toTable.getTableNumber() : null);
         tableLog.setChangeReason(changeReason);
         tableLog.setCreatedUserId(userId);
+        tableLog.setCreatedTime(now);
+        tableLog.setUpdatedTime(now);
+        tableLog.setUpdatedUserId(userId);
         storeTableLogMapper.insert(tableLog);
 
-        // 5. 推送购物车更新消息到新桌号
+        // 4. 推送购物车更新消息到新桌号
         sseService.pushCartUpdate(toTableId, cart);
 
         log.info("换桌完成, fromTableId={}, toTableId={}", fromTableId, toTableId);

+ 29 - 16
alien-dining/src/main/resources/logback-spring.xml

@@ -5,16 +5,18 @@
 <!-- debug:当此属性设置为true时,将打印出logback内部日志信息,实时查看logback运行状态。默认值为false。 -->
 <!-- 该信息是由于设置了当配置文件变化时重新加载,所以每当达到扫描时间的时候就会检查配置文件是否错误。但是由于一般配置文件都放在了JAR包中,
     而扫描的时候无法扫描JAR包内,因此会提示没有可以检查的文件,所以每隔一段时间就输出一次-->
-<configuration scan="false" scanPeriod="60 seconds" debug="true">
+<configuration scan="false" scanPeriod="60 seconds" debug="false">
 <!--    <contextName>logback-spring</contextName>-->
 
     <!-- name的值是变量的名称,value的值时变量定义的值。通过定义的值会被插入到logger上下文中。定义后,可以使"${}"来使用变量。 -->
     <!-- 定义全局参数常量 -->
     <property name="log.level" value="debug"/>
-    <property name="log.maxHistory" value="30"/><!-- 30表示30个 -->
+    <property name="log.maxHistory" value="30"/><!-- 30表示保留30个归档 -->
+    <!-- Linux/生产部署时请设置 logging.path;Logback 只自动创建子目录 LOG_DIR,根目录需存在 -->
     <springProperty scope="context" name="logging.path" source="logging.path"  defaultValue="C:/project/ext/log"/>
-    <!--输出文件前缀-->
+    <!--输出文件前缀;各服务日志写入各自子目录 logging.path/FILENAME/ -->
     <property name="FILENAME" value="alien-dining"/>
+    <property name="LOG_DIR" value="${logging.path}/${FILENAME}"/>
 
     <!--0. 日志格式和颜色渲染 -->
     <!-- 彩色日志依赖的渲染类 -->
@@ -26,11 +28,10 @@
     <property name="FILE_LOG_PATTERN" value="[%d{MM/dd HH:mm:ss.SSS}][%-10.10thread][%-5level][%-40.40c{1}:%5line]:[%15method] || %m%n"/>
     <property name="CONSOLE_LOG_PATTERN" value="${CONSOLE_LOG_PATTERN:-%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>
 
-    <!--1. 输出到控制台-->
+    <!--1. 输出到控制台(与其它模块一致:仅 INFO 及以上,避免生产/采集时 DEBUG 刷屏)-->
     <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
-        <!--此日志appender是为开发使用,只配置最底级别,控制台输出的日志级别是大于或等于此级别的日志信息-->
         <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
-            <level>${log.level}</level>
+            <level>INFO</level>
         </filter>
         <encoder>
             <Pattern>${CONSOLE_LOG_PATTERN}</Pattern>
@@ -42,14 +43,17 @@
     <!--2. 输出到文档-->
     <!-- DEBUG 日志:按 1MB 大小滚动,满 1MB 自动切到下一个文件 -->
     <appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/DEBUG.log</file>
+        <file>${LOG_DIR}/DEBUG.log</file>
         <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_DEBUG.%i.log.gz</fileNamePattern>
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_DEBUG.%i.log.gz</fileNamePattern>
             <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>DEBUG</level>
@@ -60,14 +64,17 @@
 
     <!-- INFO 日志:按 1MB 大小滚动 -->
     <appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/INFO.log</file>
+        <file>${LOG_DIR}/INFO.log</file>
         <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_INFO.%i.log.gz</fileNamePattern>
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_INFO.%i.log.gz</fileNamePattern>
             <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>INFO</level>
@@ -78,14 +85,17 @@
 
     <!-- WARN 日志:按 1MB 大小滚动 -->
     <appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/WARN.log</file>
+        <file>${LOG_DIR}/WARN.log</file>
         <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_WARN.%i.log.gz</fileNamePattern>
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_WARN.%i.log.gz</fileNamePattern>
             <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>WARN</level>
@@ -96,14 +106,17 @@
 
     <!-- ERROR 日志:按 1MB 大小滚动 -->
     <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/ERROR.log</file>
+        <file>${LOG_DIR}/ERROR.log</file>
         <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_ERROR.%i.log.gz</fileNamePattern>
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_ERROR.%i.log.gz</fileNamePattern>
             <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>ERROR</level>
@@ -142,8 +155,8 @@
     -->
 
     <logger name="springfox.documentation.spring.web.readers.operation.CachingOperationNameGenerator" level="WARN"/>
-    <logger name="org.springframework.security.web.DefaultSecurityFilterChain " level="WARN"/>
-    <logger name="com.netflix.config.sources.URLConfigurationSource " level="WARN"/>
+    <logger name="org.springframework.security.web.DefaultSecurityFilterChain" level="WARN"/>
+    <logger name="com.netflix.config.sources.URLConfigurationSource" level="WARN"/>
 
     <!-- 4. 最终的策略 -->
     <!-- 4.1 开发环境:打印控制台-->

+ 1 - 1
alien-entity/src/main/java/shop/alien/entity/store/MerchantPaymentOrder.java

@@ -62,7 +62,7 @@ public class MerchantPaymentOrder {
     @TableField("pay_amount")
     private BigDecimal payAmount;
 
-    @ApiModelProperty(value = "支付状态: 0-待支付, 1-已支付, 2-已关闭, 3-已退款")
+    @ApiModelProperty(value = "支付状态: 0-待支付, 1-已支付, 2-已关闭, 3-已退款, 4-退款中")
     @TableField("pay_status")
     private Integer payStatus;
 

+ 1 - 1
alien-entity/src/main/java/shop/alien/entity/store/RefundRecord.java

@@ -54,7 +54,7 @@ public class RefundRecord extends Model<RefundRecord> {
     @TableField("pay_type")
     private String payType;
 
-    @ApiModelProperty(value = "退款状态:SUCCESS-退款成功, CLOSED-退款关闭, PROCESSING-退款处理中, ABNORMAL-退款异常")
+    @ApiModelProperty(value = "退款状态:SUCCESS-退款成功, CLOSED-退款关闭, PROCESSING-退款处理中, ABNORMAL-退款异常, FAIL-退款失败")
     @TableField("refund_status")
     private String refundStatus;
 

+ 2 - 2
alien-entity/src/main/java/shop/alien/entity/store/StoreOrderChangeLog.java

@@ -11,7 +11,7 @@ import java.math.BigDecimal;
 import java.util.Date;
 
 /**
- * 订单变更记录表(记录每次下单/加餐的商品变化)
+ * 订单变更记录表(记录每次下单/更新订单的商品变化)
  *
  * @author system
  * @since 2025-02-02
@@ -38,7 +38,7 @@ public class StoreOrderChangeLog {
     @TableField("batch_no")
     private String batchNo;
 
-    @ApiModelProperty(value = "操作类型(1:首次下单, 2:加餐, 3:更新订单)")
+    @ApiModelProperty(value = "操作类型(1:首次下单, 3:更新订单)")
     @TableField("operation_type")
     private Integer operationType;
 

+ 1 - 1
alien-entity/src/main/java/shop/alien/entity/store/UserReservationOrder.java

@@ -46,7 +46,7 @@ public class UserReservationOrder {
     @TableField("order_status")
     private Integer orderStatus;
 
-    @ApiModelProperty(value = "支付状态 0:未支付 1:已支付 2:已退款 3:部分退款")
+    @ApiModelProperty(value = "支付状态 0:未支付 1:已支付 2:已退款 3:部分退款 4:退款中")
     @TableField("payment_status")
     private Integer paymentStatus;
 

+ 8 - 3
alien-entity/src/main/java/shop/alien/entity/store/vo/CategoryWithCuisinesVO.java

@@ -12,18 +12,23 @@ import java.util.List;
 
 /**
  * 菜品种类及其下属菜品 VO(通过门店ID查询时返回)
+ * <p>
+ * 与单独接口的返回结构一致:
+ * - category 与 GET /store/info/categories 中每个元素的字段完全一致(StoreCuisineCategory)
+ * - cuisines 与 GET /store/info/cuisines 返回的列表元素字段完全一致(StoreCuisine)
+ * </p>
  *
  * @author system
  */
 @Data
 @NoArgsConstructor
 @AllArgsConstructor
-@ApiModel(value = "CategoryWithCuisinesVO", description = "菜品种类及该分类下的菜品列表")
+@ApiModel(value = "CategoryWithCuisinesVO", description = "菜品种类及该分类下的菜品列表;category 与 /store/info/categories 单条一致,cuisines 与 /store/info/cuisines 返回列表元素一致")
 public class CategoryWithCuisinesVO {
 
-    @ApiModelProperty(value = "菜品种类信息")
+    @ApiModelProperty(value = "菜品种类信息,字段与 GET /store/info/categories 返回的每条一致(StoreCuisineCategory)")
     private StoreCuisineCategory category;
 
-    @ApiModelProperty(value = "该分类下的菜品列表")
+    @ApiModelProperty(value = "该分类下的菜品列表,每项字段与 GET /store/info/cuisines 返回的每条一致(StoreCuisine)")
     private List<StoreCuisine> cuisines;
 }

+ 2 - 2
alien-entity/src/main/java/shop/alien/entity/store/vo/OrderChangeLogBatchVO.java

@@ -21,7 +21,7 @@ public class OrderChangeLogBatchVO {
     @ApiModelProperty(value = "批次号")
     private String batchNo;
 
-    @ApiModelProperty(value = "操作类型(1:首次下单, 2:加餐, 3:更新订单)")
+    @ApiModelProperty(value = "操作类型(1:首次下单, 3:更新订单)")
     private Integer operationType;
 
     @ApiModelProperty(value = "操作类型文本")
@@ -36,7 +36,7 @@ public class OrderChangeLogBatchVO {
     @ApiModelProperty(value = "操作人手机号")
     private String operatorUserPhone;
 
-    @ApiModelProperty(value = "备注(该批次对应的备注,如下单/加餐时的备注)")
+    @ApiModelProperty(value = "备注(该批次对应的备注,如下单/更新订单时的备注)")
     private String remark;
 
     @ApiModelProperty(value = "该批次商品数量变化总和")

+ 3 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/OrderChangeLogItemVO.java

@@ -45,4 +45,7 @@ public class OrderChangeLogItemVO {
 
     @ApiModelProperty(value = "备注")
     private String remark;
+
+    @ApiModelProperty(value = "菜品标签(JSON数组,如:[\"招牌菜\",\"推荐\"])")
+    private String tags;
 }

+ 3 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/OrderCuisineItemVO.java

@@ -30,4 +30,7 @@ public class OrderCuisineItemVO {
 
     @ApiModelProperty(value = "单价")
     private BigDecimal unitPrice;
+
+    @ApiModelProperty(value = "菜品标签(JSON数组,如:[\"招牌菜\",\"推荐\"])")
+    private String tags;
 }

+ 16 - 0
alien-entity/src/main/java/shop/alien/mapper/UserReservationOrderMapper.java

@@ -9,6 +9,8 @@ import shop.alien.entity.store.UserReservationOrder;
 import shop.alien.entity.store.vo.ReservationOrderCountsDto;
 import shop.alien.entity.store.vo.ReservationOrderListDto;
 
+import java.util.List;
+
 /**
  * 用户预订订单表 Mapper 接口
  *
@@ -47,4 +49,18 @@ public interface UserReservationOrderMapper extends BaseMapper<UserReservationOr
      * @return 各状态数量
      */
     ReservationOrderCountsDto selectOrderCountsByUserId(@Param("userId") Integer userId);
+
+    /**
+     * 查询待使用订单且预定开始时间在「当前时间~当前时间+30分钟」内的订单ID列表,用于到店提醒短信
+     *
+     * @return 订单ID列表
+     */
+    List<Integer> listOrderIdsForArrivalReminder();
+
+    /**
+     * 查询支付状态为「退款中」(4) 的付费订单ID列表,用于定时任务重试退款
+     *
+     * @return 订单ID列表
+     */
+    List<Integer> listOrderIdsForRefundRetry();
 }

+ 4 - 1
alien-entity/src/main/java/shop/alien/mapper/second/SecondEntrustUserMapper.java

@@ -58,9 +58,12 @@ public interface SecondEntrustUserMapper extends BaseMapper<SecondEntrustUser> {
             "where entrust_user_name = #{entrustUserName} " +
             "and entrust_id_card = #{entrustIdCard} " +
             "and delete_flag = 0 " +
+            "and id = #{id} " +
             "order by created_time desc")
     List<SecondEntrustUser> getByUserNameAndIdCard(@Param("entrustUserName") String entrustUserName, 
-                                                     @Param("entrustIdCard") String entrustIdCard);
+                                                     @Param("entrustIdCard") String entrustIdCard,
+                                                    @Param("id") Integer id
+    );
 
 }
 

+ 8 - 6
alien-entity/src/main/resources/mapper/StoreReservationMapper.xml

@@ -56,8 +56,10 @@
             ur.end_time,
             CONCAT(IFNULL(ur.start_time, ''), '-', IFNULL(ur.end_time, '')) AS time_slot,
             ur.user_id,
-            IFNULL(lu.real_name, lu.user_name) AS customer_name,
-            lu.user_phone AS contact_phone,
+--             IFNULL(lu.real_name, lu.user_name) AS customer_name,
+--             lu.user_phone AS contact_phone,
+            ur.reservation_user_phone AS contact_phone,
+            ur.reservation_user_name AS customer_name,
             ur.remark,
             IFNULL(ur.reason, '') AS reason,
             ur.status,
@@ -83,7 +85,7 @@
                 WHEN 4 THEN '已取消'
                 WHEN 5 THEN '已关闭'
                 WHEN 6 THEN '退款中'
-                WHEN 7 THEN '退款'
+                WHEN 7 THEN '退款成功'
                 WHEN 8 THEN '商家预订'
                 ELSE '未知'
             END AS order_status_text,
@@ -119,9 +121,9 @@
         GROUP BY
             ur.id
         ORDER BY
-            ur.reservation_date DESC,
-            ur.start_time ASC,
-            ur.created_time DESC
+            CASE WHEN ur.status = 1 THEN 0 ELSE 1 END ASC,
+            ur.reservation_date ASC,
+            ur.start_time ASC
     </select>
 
 </mapper>

+ 2 - 2
alien-entity/src/main/resources/mapper/UserReservationMapper.xml

@@ -139,7 +139,7 @@
         DELETE FROM user_reservation WHERE id = #{id}
     </delete>
 
-    <!-- 关联查询:订单待使用 + 预约结束时间已过,仅返回 reservation_id -->
+    <!-- 关联查询:订单待使用 + 预约结束时间已过,仅返回 reservation_id。end_time 格式为 yyyy-MM-dd HH:mm -->
     <select id="listReservationIdsForTimeoutMark" resultType="java.lang.Integer">
         SELECT DISTINCT r.id
         FROM user_reservation r
@@ -148,7 +148,7 @@
           AND o.order_status = 1
           AND r.status IN (0, 1)
           AND r.end_time IS NOT NULL AND TRIM(r.end_time) != ''
-          AND STR_TO_DATE(CONCAT(DATE(r.reservation_date), ' ', TRIM(r.end_time)), '%Y-%m-%d %H:%i') &lt; NOW()
+          AND STR_TO_DATE(TRIM(r.end_time), '%Y-%m-%d %H:%i') &lt; NOW()
     </select>
 
 </mapper>

+ 24 - 0
alien-entity/src/main/resources/mapper/UserReservationOrderMapper.xml

@@ -70,4 +70,28 @@
         WHERE o.delete_flag = 0
           AND o.user_id = #{userId}
     </select>
+
+    <!-- 到店提醒:待使用订单且预定开始时间在 当前时间~当前时间+30分钟 内的订单ID。start_time 格式为 yyyy-MM-dd HH:mm -->
+    <select id="listOrderIdsForArrivalReminder" resultType="java.lang.Integer">
+        SELECT o.id
+        FROM user_reservation_order o
+        INNER JOIN user_reservation r ON o.reservation_id = r.id AND r.delete_flag = 0
+        WHERE o.delete_flag = 0
+          AND o.order_status = 1
+          AND r.status = 1
+          AND r.start_time IS NOT NULL AND TRIM(r.start_time) != ''
+          AND STR_TO_DATE(TRIM(r.start_time), '%Y-%m-%d %H:%i') BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 30 MINUTE)
+    </select>
+
+    <!-- 退款重试:支付状态为退款中(4)的付费订单 -->
+    <select id="listOrderIdsForRefundRetry" resultType="java.lang.Integer">
+        SELECT id
+        FROM user_reservation_order
+        WHERE delete_flag = 0
+          AND payment_status = 4
+          AND order_cost_type = 1
+          AND out_trade_no IS NOT NULL AND TRIM(out_trade_no) != ''
+          AND deposit_amount IS NOT NULL AND deposit_amount > 0
+        ORDER BY updated_time ASC
+    </select>
 </mapper>

+ 39 - 20
alien-gateway/src/main/resources/logback-spring.xml

@@ -4,10 +4,12 @@
 
     <!-- 定义全局参数常量 -->
     <property name="log.level" value="debug"/>
-    <property name="log.maxHistory" value="30"/><!-- 30表示30天 -->
+    <property name="log.maxHistory" value="30"/><!-- 30表示保留30个归档 -->
+    <!-- Linux/生产部署时请在 application.yml 或环境变量中设置 logging.path,否则默认 C:/project/ext/log 可能不存在 -->
     <springProperty scope="context" name="logging.path" source="logging.path" defaultValue="C:/project/ext/log"/>
-    <!--输出文件前缀-->
-    <property name="FILENAME" value="alien"/>
+    <!--输出文件前缀;各服务日志写入各自子目录 logging.path/FILENAME/ -->
+    <property name="FILENAME" value="alien-gateway"/>
+    <property name="LOG_DIR" value="${logging.path}/${FILENAME}"/>
 
     <!-- 文件输出格式 -->
     <property name="FILE_LOG_PATTERN" value="[%d{MM/dd HH:mm:ss.SSS}][%-10.10thread][%-5level][%-40.40c{1}:%5line]:[%15method] || %m%n"/>
@@ -28,15 +30,19 @@
     </appender>
 
     <!--2. 输出到文档-->
-    <!-- DEBUG 日志 -->
+    <!-- DEBUG 日志:按 1MB 大小滚动,满 1MB 自动切到下一个文件 -->
     <appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/DEBUG.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_DEBUG.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/DEBUG.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_DEBUG.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>DEBUG</level>
@@ -45,15 +51,19 @@
         </filter>
     </appender>
 
-    <!-- INFO 日志 -->
+    <!-- INFO 日志:按 1MB 大小滚动 -->
     <appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/INFO.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_INFO.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/INFO.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_INFO.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>INFO</level>
@@ -62,15 +72,19 @@
         </filter>
     </appender>
 
-    <!-- WARN 日志 -->
+    <!-- WARN 日志:按 1MB 大小滚动 -->
     <appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/WARN.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_WARN.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/WARN.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_WARN.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>WARN</level>
@@ -79,14 +93,19 @@
         </filter>
     </appender>
 
+    <!-- ERROR 日志:按 1MB 大小滚动 -->
     <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/ERROR.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_ERROR.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/ERROR.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_ERROR.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>ERROR</level>
@@ -97,8 +116,8 @@
 
     <!-- 降噪配置 -->
     <logger name="springfox.documentation.spring.web.readers.operation.CachingOperationNameGenerator" level="WARN"/>
-    <logger name="org.springframework.security.web.DefaultSecurityFilterChain " level="WARN"/>
-    <logger name="com.netflix.config.sources.URLConfigurationSource " level="WARN"/>
+    <logger name="org.springframework.security.web.DefaultSecurityFilterChain" level="WARN"/>
+    <logger name="com.netflix.config.sources.URLConfigurationSource" level="WARN"/>
     <logger name="com.netflix.discovery" level="WARN"/>
     <logger name="org.apache.catalina.core.ContainerBase.[Tomcat].[localhost].[/]" level="WARN"/>
 

+ 16 - 0
alien-job/src/main/java/shop/alien/job/feign/AlienStoreFeign.java

@@ -77,4 +77,20 @@ public interface AlienStoreFeign {
     @org.springframework.web.bind.annotation.PostMapping("/reservation/job/markTimeout")
     R<Integer> markReservationTimeoutByEndTime();
 
+    /**
+     * 到店提醒定时任务:订单待使用且距预定开始时间≤30分钟时发送短信提醒
+     *
+     * @return R.data 为本次发送短信条数
+     */
+    @org.springframework.web.bind.annotation.PostMapping("/reservation/job/sendArrivalReminder")
+    R<Integer> sendArrivalReminder();
+
+    /**
+     * 重试退款定时任务:查询支付状态为退款中的订单并重新发起退款
+     *
+     * @return R.data 为本次退款成功的订单数
+     */
+    @org.springframework.web.bind.annotation.PostMapping("/reservation/job/retryRefundFailed")
+    R<Integer> retryRefundFailed();
+
 }

+ 32 - 0
alien-job/src/main/java/shop/alien/job/store/RefundRetryJob.java

@@ -0,0 +1,32 @@
+package shop.alien.job.store;
+
+import com.xxl.job.core.handler.annotation.XxlJob;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import shop.alien.entity.result.R;
+import shop.alien.job.feign.AlienStoreFeign;
+
+/**
+ * 退款重试定时任务:查询支付状态为「退款中」的预订订单并重新发起退款(不发送短信和通知)
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class RefundRetryJob {
+
+    private final AlienStoreFeign alienStoreFeign;
+
+    @XxlJob("refundRetryJob")
+    public void refundRetryJob() {
+        log.info("【定时任务】退款重试:开始执行");
+        try {
+            R<Integer> result = alienStoreFeign.retryRefundFailed();
+            int count = (result != null && result.getData() != null) ? result.getData() : 0;
+            log.info("【定时任务】退款重试:执行完成,本次退款成功条数={}", count);
+        } catch (Exception e) {
+            log.error("【定时任务】退款重试:执行异常", e);
+            throw e;
+        }
+    }
+}

+ 33 - 0
alien-job/src/main/java/shop/alien/job/store/ReservationArrivalReminderJob.java

@@ -0,0 +1,33 @@
+package shop.alien.job.store;
+
+import com.xxl.job.core.handler.annotation.XxlJob;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import shop.alien.entity.result.R;
+import shop.alien.job.feign.AlienStoreFeign;
+
+/**
+ * 到店提醒定时任务
+ * 订单状态为待使用且当前时间距离预定开始时间(user_reservation.reservation_date + start_time)≤30分钟时,发送到店提醒短信
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ReservationArrivalReminderJob {
+
+    private final AlienStoreFeign alienStoreFeign;
+
+    @XxlJob("reservationArrivalReminderJob")
+    public void reservationArrivalReminderJob() {
+        log.info("【定时任务】到店提醒:开始执行");
+        try {
+            R<Integer> result = alienStoreFeign.sendArrivalReminder();
+            int count = (result != null && result.getData() != null) ? result.getData() : 0;
+            log.info("【定时任务】到店提醒:执行完成,本次发送短信条数={}", count);
+        } catch (Exception e) {
+            log.error("【定时任务】到店提醒:执行异常", e);
+            throw e;
+        }
+    }
+}

+ 38 - 20
alien-job/src/main/resources/logback-spring.xml

@@ -4,10 +4,11 @@
 
     <!-- 定义全局参数常量 -->
     <property name="log.level" value="debug"/>
-    <property name="log.maxHistory" value="30"/><!-- 30表示30天 -->
+    <property name="log.maxHistory" value="30"/><!-- 30表示保留30个归档 -->
     <springProperty scope="context" name="logging.path" source="logging.path" defaultValue="C:/project/ext/log"/>
-    <!--输出文件前缀-->
-    <property name="FILENAME" value="alien"/>
+    <!--输出文件前缀;各服务日志写入各自子目录 logging.path/FILENAME/ -->
+    <property name="FILENAME" value="alien-job"/>
+    <property name="LOG_DIR" value="${logging.path}/${FILENAME}"/>
 
     <!-- 文件输出格式 -->
     <property name="FILE_LOG_PATTERN" value="[%d{MM/dd HH:mm:ss.SSS}][%-10.10thread][%-5level][%-40.40c{1}:%5line]:[%15method] || %m%n"/>
@@ -28,15 +29,19 @@
     </appender>
 
     <!--2. 输出到文档-->
-    <!-- DEBUG 日志 -->
+    <!-- DEBUG 日志:按 1MB 大小滚动,满 1MB 自动切到下一个文件 -->
     <appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/DEBUG.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_DEBUG.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/DEBUG.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_DEBUG.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>DEBUG</level>
@@ -45,15 +50,19 @@
         </filter>
     </appender>
 
-    <!-- INFO 日志 -->
+    <!-- INFO 日志:按 1MB 大小滚动 -->
     <appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/INFO.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_INFO.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/INFO.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_INFO.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>INFO</level>
@@ -62,15 +71,19 @@
         </filter>
     </appender>
 
-    <!-- WARN 日志 -->
+    <!-- WARN 日志:按 1MB 大小滚动 -->
     <appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/WARN.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_WARN.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/WARN.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_WARN.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>WARN</level>
@@ -79,14 +92,19 @@
         </filter>
     </appender>
 
+    <!-- ERROR 日志:按 1MB 大小滚动 -->
     <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/ERROR.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_ERROR.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/ERROR.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_ERROR.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>ERROR</level>
@@ -97,8 +115,8 @@
 
     <!-- 降噪配置 -->
     <logger name="springfox.documentation.spring.web.readers.operation.CachingOperationNameGenerator" level="WARN"/>
-    <logger name="org.springframework.security.web.DefaultSecurityFilterChain " level="WARN"/>
-    <logger name="com.netflix.config.sources.URLConfigurationSource " level="WARN"/>
+    <logger name="org.springframework.security.web.DefaultSecurityFilterChain" level="WARN"/>
+    <logger name="com.netflix.config.sources.URLConfigurationSource" level="WARN"/>
     <logger name="com.netflix.discovery" level="WARN"/>
     <logger name="org.apache.catalina.core.ContainerBase.[Tomcat].[localhost].[/]" level="WARN"/>
 

+ 1 - 1
alien-lawyer/src/main/resources/bootstrap.yml

@@ -1,3 +1,3 @@
 spring:
   profiles:
-    active: uat
+    active: dev

+ 38 - 20
alien-lawyer/src/main/resources/logback-spring.xml

@@ -4,10 +4,11 @@
 
     <!-- 定义全局参数常量 -->
     <property name="log.level" value="debug"/>
-    <property name="log.maxHistory" value="30"/><!-- 30表示30天 -->
+    <property name="log.maxHistory" value="30"/><!-- 30表示保留30个归档 -->
     <springProperty scope="context" name="logging.path" source="logging.path" defaultValue="C:/project/ext/log"/>
-    <!--输出文件前缀-->
-    <property name="FILENAME" value="alien"/>
+    <!--输出文件前缀;各服务日志写入各自子目录 logging.path/FILENAME/ -->
+    <property name="FILENAME" value="alien-lawyer"/>
+    <property name="LOG_DIR" value="${logging.path}/${FILENAME}"/>
 
     <!-- 文件输出格式 -->
     <property name="FILE_LOG_PATTERN" value="[%d{MM/dd HH:mm:ss.SSS}][%-10.10thread][%-5level][%-40.40c{1}:%5line]:[%15method] || %m%n"/>
@@ -28,15 +29,19 @@
     </appender>
 
     <!--2. 输出到文档-->
-    <!-- DEBUG 日志 -->
+    <!-- DEBUG 日志:按 1MB 大小滚动,满 1MB 自动切到下一个文件 -->
     <appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/DEBUG.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_DEBUG.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/DEBUG.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_DEBUG.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>DEBUG</level>
@@ -45,15 +50,19 @@
         </filter>
     </appender>
 
-    <!-- INFO 日志 -->
+    <!-- INFO 日志:按 1MB 大小滚动 -->
     <appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/INFO.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_INFO.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/INFO.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_INFO.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>INFO</level>
@@ -62,15 +71,19 @@
         </filter>
     </appender>
 
-    <!-- WARN 日志 -->
+    <!-- WARN 日志:按 1MB 大小滚动 -->
     <appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/WARN.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_WARN.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/WARN.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_WARN.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>WARN</level>
@@ -79,14 +92,19 @@
         </filter>
     </appender>
 
+    <!-- ERROR 日志:按 1MB 大小滚动 -->
     <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/ERROR.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_ERROR.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/ERROR.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_ERROR.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>ERROR</level>
@@ -97,8 +115,8 @@
 
     <!-- 降噪配置 -->
     <logger name="springfox.documentation.spring.web.readers.operation.CachingOperationNameGenerator" level="WARN"/>
-    <logger name="org.springframework.security.web.DefaultSecurityFilterChain " level="WARN"/>
-    <logger name="com.netflix.config.sources.URLConfigurationSource " level="WARN"/>
+    <logger name="org.springframework.security.web.DefaultSecurityFilterChain" level="WARN"/>
+    <logger name="com.netflix.config.sources.URLConfigurationSource" level="WARN"/>
     <logger name="com.netflix.discovery" level="WARN"/>
     <logger name="org.apache.catalina.core.ContainerBase.[Tomcat].[localhost].[/]" level="WARN"/>
 

+ 5 - 4
alien-second/src/main/java/shop/alien/second/controller/SecondEntrustUserController.java

@@ -188,13 +188,14 @@ public class SecondEntrustUserController {
     @ApiOperationSupport(order = 8)
     @ApiImplicitParams({
             @ApiImplicitParam(name = "entrustUserName", value = "委托人姓名", dataType = "String", paramType = "query", required = true),
-            @ApiImplicitParam(name = "entrustIdCard", value = "委托人身份证号", dataType = "String", paramType = "query", required = true)
+            @ApiImplicitParam(name = "entrustIdCard", value = "委托人身份证号", dataType = "String", paramType = "query", required = true),
+            @ApiImplicitParam(name = "id", value = "id", dataType = "Integer", paramType = "query", required = true)
     })
     @GetMapping("/detail")
-    public R<SecondEntrustUserDetailVo> getEntrustUserDetail(@RequestParam String entrustUserName, @RequestParam String entrustIdCard) {
-        log.info("SecondEntrustUserController.getEntrustUserDetail entrustUserName={}, entrustIdCard={}", entrustUserName, entrustIdCard);
+    public R<SecondEntrustUserDetailVo> getEntrustUserDetail(@RequestParam String entrustUserName, @RequestParam String entrustIdCard, @RequestParam Integer id) {
+        log.info("SecondEntrustUserController.getEntrustUserDetail entrustUserName={}, entrustIdCard={},id={}", entrustUserName, entrustIdCard,id);
         try {
-            SecondEntrustUserDetailVo detailVo = secondEntrustUserService.getEntrustUserDetail(entrustUserName, entrustIdCard);
+            SecondEntrustUserDetailVo detailVo = secondEntrustUserService.getEntrustUserDetail(entrustUserName, entrustIdCard,id);
             return R.data(detailVo);
         } catch (Exception e) {
             log.error("SecondEntrustUserController.getEntrustUserDetail error: {}", e.getMessage(), e);

+ 1 - 1
alien-second/src/main/java/shop/alien/second/service/SecondEntrustUserService.java

@@ -84,7 +84,7 @@ public interface SecondEntrustUserService extends IService<SecondEntrustUser> {
      * @param entrustIdCard 委托人身份证号
      * @return 委托人详情
      */
-    SecondEntrustUserDetailVo getEntrustUserDetail(String entrustUserName, String entrustIdCard) throws Exception;
+    SecondEntrustUserDetailVo getEntrustUserDetail(String entrustUserName, String entrustIdCard,Integer id) throws Exception;
 
 }
 

+ 2 - 2
alien-second/src/main/java/shop/alien/second/service/impl/SecondEntrustUserServiceImpl.java

@@ -227,11 +227,11 @@ public class SecondEntrustUserServiceImpl extends ServiceImpl<SecondEntrustUserM
      * @return 委托人详情
      */
     @Override
-    public SecondEntrustUserDetailVo getEntrustUserDetail(String entrustUserName, String entrustIdCard) throws Exception {
+    public SecondEntrustUserDetailVo getEntrustUserDetail(String entrustUserName, String entrustIdCard,Integer id) throws Exception {
         log.info("SecondEntrustUserServiceImpl.getEntrustUserDetail entrustUserName={}, entrustIdCard={}", entrustUserName, entrustIdCard);
         try {
             // 1. 根据姓名和身份证号查询该人的所有委托记录
-            List<SecondEntrustUser> entrustUsers = secondEntrustUserMapper.getByUserNameAndIdCard(entrustUserName, entrustIdCard);
+            List<SecondEntrustUser> entrustUsers = secondEntrustUserMapper.getByUserNameAndIdCard(entrustUserName, entrustIdCard, id);
             
             if (entrustUsers == null || entrustUsers.isEmpty()) {
                 log.warn("委托人信息不存在, entrustUserName={}, entrustIdCard={}", entrustUserName, entrustIdCard);

+ 38 - 20
alien-second/src/main/resources/logback-spring.xml

@@ -4,10 +4,11 @@
 
     <!-- 定义全局参数常量 -->
     <property name="log.level" value="debug"/>
-    <property name="log.maxHistory" value="30"/><!-- 30表示30天 -->
+    <property name="log.maxHistory" value="30"/><!-- 30表示保留30个归档 -->
     <springProperty scope="context" name="logging.path" source="logging.path" defaultValue="C:/project/ext/log"/>
-    <!--输出文件前缀-->
-    <property name="FILENAME" value="alien"/>
+    <!--输出文件前缀;各服务日志写入各自子目录 logging.path/FILENAME/ -->
+    <property name="FILENAME" value="alien-second"/>
+    <property name="LOG_DIR" value="${logging.path}/${FILENAME}"/>
 
     <!-- 文件输出格式 -->
     <property name="FILE_LOG_PATTERN" value="[%d{MM/dd HH:mm:ss.SSS}][%-10.10thread][%-5level][%-40.40c{1}:%5line]:[%15method] || %m%n"/>
@@ -28,15 +29,19 @@
     </appender>
 
     <!--2. 输出到文档-->
-    <!-- DEBUG 日志 -->
+    <!-- DEBUG 日志:按 1MB 大小滚动,满 1MB 自动切到下一个文件 -->
     <appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/DEBUG.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_DEBUG.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/DEBUG.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_DEBUG.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>DEBUG</level>
@@ -45,15 +50,19 @@
         </filter>
     </appender>
 
-    <!-- INFO 日志 -->
+    <!-- INFO 日志:按 1MB 大小滚动 -->
     <appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/INFO.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_INFO.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/INFO.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_INFO.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>INFO</level>
@@ -62,15 +71,19 @@
         </filter>
     </appender>
 
-    <!-- WARN 日志 -->
+    <!-- WARN 日志:按 1MB 大小滚动 -->
     <appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/WARN.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_WARN.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/WARN.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_WARN.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>WARN</level>
@@ -79,14 +92,19 @@
         </filter>
     </appender>
 
+    <!-- ERROR 日志:按 1MB 大小滚动 -->
     <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/ERROR.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_ERROR.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/ERROR.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_ERROR.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>ERROR</level>
@@ -97,8 +115,8 @@
 
     <!-- 降噪配置 -->
     <logger name="springfox.documentation.spring.web.readers.operation.CachingOperationNameGenerator" level="WARN"/>
-    <logger name="org.springframework.security.web.DefaultSecurityFilterChain " level="WARN"/>
-    <logger name="com.netflix.config.sources.URLConfigurationSource " level="WARN"/>
+    <logger name="org.springframework.security.web.DefaultSecurityFilterChain" level="WARN"/>
+    <logger name="com.netflix.config.sources.URLConfigurationSource" level="WARN"/>
     <logger name="com.netflix.discovery" level="WARN"/>
     <logger name="org.apache.catalina.core.ContainerBase.[Tomcat].[localhost].[/]" level="WARN"/>
 

+ 18 - 2
alien-store-platform/src/main/java/shop/alien/storeplatform/controller/LicenseController.java

@@ -13,10 +13,11 @@ import shop.alien.entity.storePlatform.vo.StoreLicenseHistoryDto;
 import shop.alien.entity.storePlatform.vo.StoreLicenseHistoryVO;
 import shop.alien.mapper.WebAuditMapper;
 import shop.alien.storeplatform.service.LicenseService;
+import shop.alien.storeplatform.util.AiContentModerationUtil;
 
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
-import java.util.Map;
 
 
 @Slf4j
@@ -32,6 +33,8 @@ public class LicenseController {
 
     private final WebAuditMapper webAuditMapper;
 
+    private final AiContentModerationUtil aiContentModerationUtil;
+
 
     @ApiOperation("获取营业执照图片信息")
     @ApiOperationSupport(order = 1)
@@ -161,10 +164,23 @@ public class LicenseController {
     }
 
 
-    @ApiOperation(value = "门店装修-修改娱乐经营许可证")
+    @ApiOperation(value = "门店装修-修改娱乐经营许可证", notes = "上传前会同步进行AI图片审核,不通过则直接返回失败原因")
     @PostMapping("/uploadEntertainmentLicence")
     public R<String> uploadEntertainmentLicence(@RequestBody StoreImg storeImg) {
         log.info("StoreInfoController.uploadEntertainmentLicence?storeImg={}", storeImg);
+        if (storeImg == null) {
+            return R.fail("参数不能为空");
+        }
+        // AI 图片审核(同步):不通过则直接返回原因给前台
+        if (storeImg.getImgUrl() != null && !storeImg.getImgUrl().trim().isEmpty()) {
+            AiContentModerationUtil.AuditResult auditResult = aiContentModerationUtil.auditContent(
+                    null, Collections.singletonList(storeImg.getImgUrl().trim()));
+            if (!auditResult.isPassed()) {
+                String reason = auditResult.getFailureReason() != null ? auditResult.getFailureReason() : "图片审核未通过";
+                log.warn("娱乐经营许可证图片AI审核不通过, storeId={}, reason={}", storeImg.getStoreId(), reason);
+                return R.fail(reason);
+            }
+        }
         int num = licenseService.uploadEntertainmentLicence(storeImg);
         if (num > 0) {
             WebAudit webAudit = new WebAudit();

+ 3 - 2
alien-store-platform/src/main/java/shop/alien/storeplatform/controller/StorePlatformBusinessInfoController.java

@@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 import shop.alien.entity.result.R;
 import shop.alien.entity.store.StoreBusinessInfo;
+import shop.alien.entity.store.vo.StoreBusinessInfoVo;
 import shop.alien.mapper.StoreBusinessInfoMapper;
 import shop.alien.storeplatform.service.StorePlatformBusinessInfoService;
 
@@ -35,13 +36,13 @@ public class StorePlatformBusinessInfoController {
 
     private final StoreBusinessInfoMapper storeBusinessInfoMapper;
 
-    @ApiOperation("获取门店营业信息")
+    @ApiOperation("获取门店营业信息(包含正常营业时间和特殊营业时间,特殊营业时间关联节假日信息)")
     @ApiOperationSupport(order = 1)
     @ApiImplicitParams({
             @ApiImplicitParam(name = "storeId", value = "门店id", dataType = "Long", paramType = "query", required = true)
     })
     @GetMapping("/getByStoreId")
-    public R<List<StoreBusinessInfo>> getByStoreId(Long storeId) {
+    public R<List<StoreBusinessInfoVo>> getByStoreId(Long storeId) {
         log.info("StorePlatformBusinessInfoController.getByStoreId?storeId={}", storeId);
         return R.data(storeBusinessInfoService.getStoreBusinessInfo(storeId));
     }

+ 3 - 2
alien-store-platform/src/main/java/shop/alien/storeplatform/service/StorePlatformBusinessInfoService.java

@@ -2,6 +2,7 @@ package shop.alien.storeplatform.service;
 
 import com.baomidou.mybatisplus.extension.service.IService;
 import shop.alien.entity.store.StoreBusinessInfo;
+import shop.alien.entity.store.vo.StoreBusinessInfoVo;
 
 import java.util.List;
 
@@ -14,11 +15,11 @@ import java.util.List;
 public interface StorePlatformBusinessInfoService extends IService<StoreBusinessInfo> {
 
     /**
-     * 获取门店营业信息
+     * 获取门店营业信息(包含正常营业时间和特殊营业时间,特殊营业时间关联节假日信息)
      *
      * @param storeId 门店信息
      * @return list
      */
-    List<StoreBusinessInfo> getStoreBusinessInfo(Long storeId);
+    List<StoreBusinessInfoVo> getStoreBusinessInfo(Long storeId);
 
 }

+ 51 - 7
alien-store-platform/src/main/java/shop/alien/storeplatform/service/impl/StorePlatformBusinessInfoServiceImpl.java

@@ -2,12 +2,19 @@ package shop.alien.storeplatform.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.BeanUtils;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import shop.alien.entity.store.EssentialHolidayComparison;
 import shop.alien.entity.store.StoreBusinessInfo;
+import shop.alien.entity.store.vo.StoreBusinessInfoVo;
+import shop.alien.mapper.EssentialHolidayComparisonMapper;
 import shop.alien.mapper.StoreBusinessInfoMapper;
 import shop.alien.storeplatform.service.StorePlatformBusinessInfoService;
 
+import java.util.ArrayList;
 import java.util.List;
 
 /**
@@ -16,23 +23,60 @@ import java.util.List;
  * @author ssk
  * @since 2024-12-05
  */
+@Slf4j
 @Transactional
 @Service
+@RequiredArgsConstructor
 public class StorePlatformBusinessInfoServiceImpl extends ServiceImpl<StoreBusinessInfoMapper, StoreBusinessInfo> implements StorePlatformBusinessInfoService {
 
+    private final EssentialHolidayComparisonMapper essentialHolidayComparisonMapper;
+
     /**
-     * 获取门店营业信息
+     * 获取门店营业信息(包含正常营业时间和特殊营业时间,特殊营业时间关联节假日信息)
      *
      * @param storeId 门店信息
      * @return list
      */
     @Override
-    public List<StoreBusinessInfo> getStoreBusinessInfo(Long storeId) {
-        LambdaQueryWrapper<StoreBusinessInfo> lambdaQueryWrapper = new LambdaQueryWrapper<>();
-        lambdaQueryWrapper
-                .eq(StoreBusinessInfo::getStoreId, storeId)
-                .orderByDesc(StoreBusinessInfo::getCreatedTime);
-        return this.list(lambdaQueryWrapper);
+    public List<StoreBusinessInfoVo> getStoreBusinessInfo(Long storeId) {
+        // 查询营业时间(包含正常时间和特殊时间)
+        List<StoreBusinessInfo> storeBusinessInfoList = this.list(
+                new LambdaQueryWrapper<StoreBusinessInfo>()
+                        .eq(StoreBusinessInfo::getStoreId, storeId)
+                        .eq(StoreBusinessInfo::getDeleteFlag, 0)
+                        .orderByAsc(StoreBusinessInfo::getBusinessType) // 先按类型排序:1-正常时间,2-特殊时间
+                        .orderByAsc(StoreBusinessInfo::getBusinessDate) // 再按日期排序
+        );
+
+        // 转换为 VO 并关联节假日信息
+        // store_business_info 的 essential_id 关联 essential_holiday_comparison 的 id
+        List<StoreBusinessInfoVo> resultList = new ArrayList<>();
+        for (StoreBusinessInfo businessInfo : storeBusinessInfoList) {
+            StoreBusinessInfoVo vo = new StoreBusinessInfoVo();
+            // 复制基本信息
+            BeanUtils.copyProperties(businessInfo, vo);
+
+            // 如果有关联的节假日ID(essential_id),查询 essential_holiday_comparison 表的节假日信息
+            if (businessInfo.getEssentialId() != null && !businessInfo.getEssentialId().trim().isEmpty()) {
+                try {
+                    Integer essentialId = Integer.parseInt(businessInfo.getEssentialId().trim());
+                    EssentialHolidayComparison holiday = essentialHolidayComparisonMapper.selectById(essentialId);
+                    if (holiday != null) {
+                        vo.setHolidayInfo(holiday);
+                    } else {
+                        log.warn("门店营业时间关联的节假日信息不存在,storeId={}, essentialId={}", storeId, essentialId);
+                    }
+                } catch (NumberFormatException e) {
+                    log.warn("门店营业时间关联的节假日ID格式错误,storeId={}, essentialId={}", storeId, businessInfo.getEssentialId());
+                } catch (Exception e) {
+                    log.error("查询节假日信息失败,storeId={}, essentialId={}", storeId, businessInfo.getEssentialId(), e);
+                }
+            }
+
+            resultList.add(vo);
+        }
+
+        return resultList;
     }
 
 }

+ 38 - 20
alien-store-platform/src/main/resources/logback-spring.xml

@@ -4,10 +4,11 @@
 
     <!-- 定义全局参数常量 -->
     <property name="log.level" value="debug"/>
-    <property name="log.maxHistory" value="30"/><!-- 30表示30天 -->
+    <property name="log.maxHistory" value="30"/><!-- 30表示保留30个归档 -->
     <springProperty scope="context" name="logging.path" source="logging.path" defaultValue="C:/project/ext/log"/>
-    <!--输出文件前缀-->
-    <property name="FILENAME" value="alien"/>
+    <!--输出文件前缀;各服务日志写入各自子目录 logging.path/FILENAME/ -->
+    <property name="FILENAME" value="alien-store-platform"/>
+    <property name="LOG_DIR" value="${logging.path}/${FILENAME}"/>
 
     <!-- 文件输出格式 -->
     <property name="FILE_LOG_PATTERN" value="[%d{MM/dd HH:mm:ss.SSS}][%-10.10thread][%-5level][%-40.40c{1}:%5line]:[%15method] || %m%n"/>
@@ -28,15 +29,19 @@
     </appender>
 
     <!--2. 输出到文档-->
-    <!-- DEBUG 日志 -->
+    <!-- DEBUG 日志:按 1MB 大小滚动,满 1MB 自动切到下一个文件 -->
     <appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/DEBUG.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_DEBUG.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/DEBUG.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_DEBUG.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>DEBUG</level>
@@ -45,15 +50,19 @@
         </filter>
     </appender>
 
-    <!-- INFO 日志 -->
+    <!-- INFO 日志:按 1MB 大小滚动 -->
     <appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/INFO.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_INFO.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/INFO.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_INFO.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>INFO</level>
@@ -62,15 +71,19 @@
         </filter>
     </appender>
 
-    <!-- WARN 日志 -->
+    <!-- WARN 日志:按 1MB 大小滚动 -->
     <appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/WARN.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_WARN.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/WARN.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_WARN.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>WARN</level>
@@ -79,14 +92,19 @@
         </filter>
     </appender>
 
+    <!-- ERROR 日志:按 1MB 大小滚动 -->
     <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/ERROR.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_ERROR.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/ERROR.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_ERROR.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>ERROR</level>
@@ -97,8 +115,8 @@
 
     <!-- 降噪配置 -->
     <logger name="springfox.documentation.spring.web.readers.operation.CachingOperationNameGenerator" level="WARN"/>
-    <logger name="org.springframework.security.web.DefaultSecurityFilterChain " level="WARN"/>
-    <logger name="com.netflix.config.sources.URLConfigurationSource " level="WARN"/>
+    <logger name="org.springframework.security.web.DefaultSecurityFilterChain" level="WARN"/>
+    <logger name="com.netflix.config.sources.URLConfigurationSource" level="WARN"/>
     <logger name="com.netflix.discovery" level="WARN"/>
     <logger name="org.apache.catalina.core.ContainerBase.[Tomcat].[localhost].[/]" level="WARN"/>
 

+ 18 - 1
alien-store/src/main/java/shop/alien/store/controller/DiningServiceController.java

@@ -11,6 +11,7 @@ import org.springframework.web.multipart.MultipartFile;
 import shop.alien.entity.result.R;
 import shop.alien.entity.store.StoreTable;
 import shop.alien.entity.store.dto.CartDTO;
+import shop.alien.entity.store.dto.ChangeTableDTO;
 import shop.alien.entity.store.vo.*;
 import shop.alien.store.feign.DiningServiceFeign;
 
@@ -170,8 +171,24 @@ public class DiningServiceController {
         }
     }
 
-    @ApiOperation(value = "获取订单详情", notes = "根据订单ID获取订单详细信息")
+    @ApiOperation(value = "换桌", notes = "按桌号换桌,迁移购物车、未完成订单及关联数据。支持点餐未下单场景,只需传原桌号与目标桌号,无需订单号。")
     @ApiOperationSupport(order = 7)
+    @PostMapping("/order/change-table")
+    public R<CartDTO> changeTable(
+            HttpServletRequest request,
+            @ApiParam(value = "换桌参数(原桌号ID、目标桌号ID、换桌原因)", required = true) @RequestBody ChangeTableDTO dto) {
+        try {
+            String authorization = getAuthorization(request);
+            log.info("换桌: fromTableId={}, toTableId={}, changeReason={}", dto.getFromTableId(), dto.getToTableId(), dto.getChangeReason());
+            return diningServiceFeign.changeTable(authorization, dto);
+        } catch (Exception e) {
+            log.error("换桌失败: {}", e.getMessage(), e);
+            return R.fail("换桌失败: " + e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "获取订单详情", notes = "根据订单ID获取订单详细信息")
+    @ApiOperationSupport(order = 8)
     @GetMapping("/order/{orderId}")
     public R<OrderDetailWithChangeLogVO> getOrderDetail(
             HttpServletRequest request,

+ 20 - 0
alien-store/src/main/java/shop/alien/store/controller/ReservationJobController.java

@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 import shop.alien.entity.result.R;
+import shop.alien.store.service.StoreReservationService;
 import shop.alien.store.service.UserReservationService;
 
 /**
@@ -21,6 +22,7 @@ import shop.alien.store.service.UserReservationService;
 public class ReservationJobController {
 
     private final UserReservationService userReservationService;
+    private final StoreReservationService storeReservationService;
 
     @ApiOperation("标记「结束时间已过且订单待使用」的预订为未到店超时/已过期")
     @PostMapping("/markTimeout")
@@ -30,4 +32,22 @@ public class ReservationJobController {
         log.info("reservation job: markTimeout 结束,更新条数={}", count);
         return R.data(count);
     }
+
+    @ApiOperation("到店提醒:订单待使用且距预定开始时间≤30分钟时发送短信提醒")
+    @PostMapping("/sendArrivalReminder")
+    public R<Integer> sendArrivalReminder() {
+        log.info("reservation job: sendArrivalReminder 开始");
+        int count = userReservationService.sendArrivalReminderSms();
+        log.info("reservation job: sendArrivalReminder 结束,发送条数={}", count);
+        return R.data(count);
+    }
+
+    @ApiOperation("重试退款:查询支付状态为退款中的订单并重新发起退款(不发送短信和通知)")
+    @PostMapping("/retryRefundFailed")
+    public R<Integer> retryRefundFailed() {
+        log.info("reservation job: retryRefundFailed 开始");
+        int count = storeReservationService.retryRefundFailedOrders();
+        log.info("reservation job: retryRefundFailed 结束,成功退款条数={}", count);
+        return R.data(count);
+    }
 }

+ 14 - 14
alien-store/src/main/java/shop/alien/store/controller/StoreBookingBusinessHoursController.java

@@ -95,12 +95,12 @@ public class StoreBookingBusinessHoursController {
         if (dto.getSettingsId() == null) {
             return R.fail("设置ID不能为空");
         }
-        if (dto.getBusinessType() == null) {
-            return R.fail("营业类型不能为空");
-        }
-        if (dto.getBookingTimeType() == null) {
-            return R.fail("预订时间类型不能为空");
-        }
+//        if (dto.getBusinessType() == null) {
+//            return R.fail("营业类型不能为空");
+//        }
+//        if (dto.getBookingTimeType() == null) {
+//            return R.fail("预订时间类型不能为空");
+//        }
         
         // 如果是特殊营业(节假日),验证节假日类型
 //        if (dto.getBusinessType() != null && dto.getBusinessType() == 1) {
@@ -110,14 +110,14 @@ public class StoreBookingBusinessHoursController {
 //        }
         
         // 如果选择非全天,必须填写开始时间和结束时间
-        if (dto.getBookingTimeType() != null && dto.getBookingTimeType() == 0) {
-            if (!StringUtils.hasText(dto.getStartTime())) {
-                return R.fail("非全天时必须填写开始时间");
-            }
-            if (!StringUtils.hasText(dto.getEndTime())) {
-                return R.fail("非全天时必须填写结束时间");
-            }
-        }
+//        if (dto.getBookingTimeType() != null && dto.getBookingTimeType() == 0) {
+//            if (!StringUtils.hasText(dto.getStartTime())) {
+//                return R.fail("非全天时必须填写开始时间");
+//            }
+//            if (!StringUtils.hasText(dto.getEndTime())) {
+//                return R.fail("非全天时必须填写结束时间");
+//            }
+//        }
         
         try {
             StoreBookingBusinessHours businessHours = new StoreBookingBusinessHours();

+ 47 - 8
alien-store/src/main/java/shop/alien/store/controller/StoreBookingCategoryController.java

@@ -1,5 +1,6 @@
 package shop.alien.store.controller;
 
+import com.baomidou.mybatisplus.core.metadata.IPage;
 import io.swagger.annotations.*;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -12,8 +13,6 @@ import shop.alien.entity.store.dto.StoreBookingCategoryDTO;
 import shop.alien.entity.store.dto.StoreBookingCategorySortDTO;
 import shop.alien.store.service.StoreBookingCategoryService;
 
-import java.util.List;
-
 /**
  * 预订服务分类管理 前端控制器
  *
@@ -32,21 +31,34 @@ public class StoreBookingCategoryController {
     private final StoreBookingCategoryService storeBookingCategoryService;
 
     @ApiOperationSupport(order = 1)
-    @ApiOperation("查询预订服务分类列表")
+    @ApiOperation("查询预订服务分类列表(分页)")
     @ApiImplicitParams({
+            @ApiImplicitParam(name = "pageNum", value = "页数", dataType = "Integer", paramType = "query", required = false),
+            @ApiImplicitParam(name = "pageSize", value = "页容", dataType = "Integer", paramType = "query", required = false),
             @ApiImplicitParam(name = "storeId", value = "门店ID", dataType = "Integer", paramType = "query", required = true)
     })
     @GetMapping("/list")
-    public R<List<StoreBookingCategory>> getCategoryList(@RequestParam Integer storeId) {
-        log.info("StoreBookingCategoryController.getCategoryList?storeId={}", storeId);
+    public R<IPage<StoreBookingCategory>> getCategoryList(
+            @RequestParam(defaultValue = "1") Integer pageNum,
+            @RequestParam(defaultValue = "10") Integer pageSize,
+            @RequestParam Integer storeId) {
+        log.info("StoreBookingCategoryController.getCategoryList?pageNum={}, pageSize={}, storeId={}", 
+                pageNum, pageSize, storeId);
         
         if (storeId == null) {
             return R.fail("门店ID不能为空");
         }
         
+        if (pageNum == null || pageNum < 1) {
+            pageNum = 1;
+        }
+        if (pageSize == null || pageSize < 1) {
+            pageSize = 10;
+        }
+        
         try {
-            List<StoreBookingCategory> list = storeBookingCategoryService.getCategoryList(storeId);
-            return R.data(list);
+            IPage<StoreBookingCategory> page = storeBookingCategoryService.getCategoryListPage(pageNum, pageSize, storeId);
+            return R.data(page);
         } catch (Exception e) {
             log.error("查询预订服务分类列表失败", e);
             return R.fail("查询失败:" + e.getMessage());
@@ -90,7 +102,7 @@ public class StoreBookingCategoryController {
         } catch (Exception e) {
             log.error("新增预订服务分类失败", e);
             // 如果是名称已存在的错误,直接返回友好提示
-            return R.fail("新增失败:" + e.getMessage());
+            return R.fail(e.getMessage());
         }
         return R.success("新增成功");
     }
@@ -224,4 +236,31 @@ public class StoreBookingCategoryController {
             return R.fail("更新显示状态失败:" + e.getMessage());
         }
     }
+
+    @ApiOperationSupport(order = 8)
+    @ApiOperation("查询分类下是否有预订信息")
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "categoryId", value = "分类ID", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "storeId", value = "门店ID", dataType = "Integer", paramType = "query", required = true)
+    })
+    @GetMapping("/hasReservation")
+    public R<Boolean> hasReservationInCategory(@RequestParam Integer categoryId, @RequestParam Integer storeId) {
+        log.info("StoreBookingCategoryController.hasReservationInCategory?categoryId={}, storeId={}", categoryId, storeId);
+        
+        if (categoryId == null) {
+            return R.fail("分类ID不能为空");
+        }
+        
+        if (storeId == null) {
+            return R.fail("门店ID不能为空");
+        }
+        
+        try {
+            boolean hasReservation = storeBookingCategoryService.hasReservationInCategory(categoryId, storeId);
+            return R.data(hasReservation);
+        } catch (Exception e) {
+            log.error("查询分类下是否有预订信息失败", e);
+            return R.fail("查询失败:" + e.getMessage());
+        }
+    }
 }

+ 29 - 32
alien-store/src/main/java/shop/alien/store/controller/StoreBookingSettingsController.java

@@ -65,9 +65,6 @@ public class StoreBookingSettingsController {
         if (dto.getRetainPositionFlag() == null) {
             return R.fail("未按时到店是否保留位置不能为空");
         }
-        if (dto.getRetentionDuration() == null || dto.getRetentionDuration() <= 0) {
-            return R.fail("保留时长必须大于0");
-        }
         if (dto.getBookingDateDisplayDays() == null || dto.getBookingDateDisplayDays() <= 0) {
             return R.fail("预订日期显示天数必须大于0");
         }
@@ -104,37 +101,37 @@ public class StoreBookingSettingsController {
 //        }
         
         // 验证正常营业时间
-        if (dto.getNormalBusinessHours() != null) {
-            StoreBookingBusinessHoursDTO normalHours = dto.getNormalBusinessHours();
-            if (normalHours.getBookingTimeType() == null) {
-                return R.fail("正常营业时间的预订时间类型不能为空");
-            }
-            if (normalHours.getBookingTimeType() == 0) {
-                if (!StringUtils.hasText(normalHours.getStartTime())) {
-                    return R.fail("正常营业时间非全天时必须填写开始时间");
-                }
-                if (!StringUtils.hasText(normalHours.getEndTime())) {
-                    return R.fail("正常营业时间非全天时必须填写结束时间");
-                }
-            }
-        }
+//        if (dto.getNormalBusinessHours() != null) {
+//            StoreBookingBusinessHoursDTO normalHours = dto.getNormalBusinessHours();
+//            if (normalHours.getBookingTimeType() == null) {
+//                return R.fail("正常营业时间的预订时间类型不能为空");
+//            }
+//            if (normalHours.getBookingTimeType() == 0) {
+//                if (!StringUtils.hasText(normalHours.getStartTime())) {
+//                    return R.fail("正常营业时间非全天时必须填写开始时间");
+//                }
+//                if (!StringUtils.hasText(normalHours.getEndTime())) {
+//                    return R.fail("正常营业时间非全天时必须填写结束时间");
+//                }
+//            }
+//        }
         
         // 验证特殊营业时间列表
-        if (dto.getSpecialBusinessHoursList() != null && !dto.getSpecialBusinessHoursList().isEmpty()) {
-            for (StoreBookingBusinessHoursDTO specialHours : dto.getSpecialBusinessHoursList()) {
-                if (specialHours.getBookingTimeType() == null) {
-                    return R.fail("特殊营业时间的预订时间类型不能为空");
-                }
-                if (specialHours.getBookingTimeType() == 0) {
-                    if (!StringUtils.hasText(specialHours.getStartTime())) {
-                        return R.fail("特殊营业时间非全天时必须填写开始时间");
-                    }
-                    if (!StringUtils.hasText(specialHours.getEndTime())) {
-                        return R.fail("特殊营业时间非全天时必须填写结束时间");
-                    }
-                }
-            }
-        }
+//        if (dto.getSpecialBusinessHoursList() != null && !dto.getSpecialBusinessHoursList().isEmpty()) {
+//            for (StoreBookingBusinessHoursDTO specialHours : dto.getSpecialBusinessHoursList()) {
+//                if (specialHours.getBookingTimeType() == null) {
+//                    return R.fail("特殊营业时间的预订时间类型不能为空");
+//                }
+//                if (specialHours.getBookingTimeType() == 0) {
+//                    if (!StringUtils.hasText(specialHours.getStartTime())) {
+//                        return R.fail("特殊营业时间非全天时必须填写开始时间");
+//                    }
+//                    if (!StringUtils.hasText(specialHours.getEndTime())) {
+//                        return R.fail("特殊营业时间非全天时必须填写结束时间");
+//                    }
+//                }
+//            }
+//        }
         
         // 编辑校验:如果是编辑操作,检查正常营业时间的id是否有值
         // 注意:特殊营业时间列表中的项可以部分有id(编辑)部分没有id(新增)

+ 46 - 6
alien-store/src/main/java/shop/alien/store/controller/StoreBookingTableController.java

@@ -1,5 +1,6 @@
 package shop.alien.store.controller;
 
+import com.baomidou.mybatisplus.core.metadata.IPage;
 import io.swagger.annotations.*;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -32,24 +33,36 @@ public class StoreBookingTableController {
     private final StoreBookingTableService storeBookingTableService;
 
     @ApiOperationSupport(order = 1)
-    @ApiOperation("查询预订服务桌号列表")
+    @ApiOperation("查询预订服务桌号列表(分页)")
     @ApiImplicitParams({
+            @ApiImplicitParam(name = "pageNum", value = "页数", dataType = "Integer", paramType = "query", required = false),
+            @ApiImplicitParam(name = "pageSize", value = "页容", dataType = "Integer", paramType = "query", required = false),
             @ApiImplicitParam(name = "storeId", value = "门店ID", dataType = "Integer", paramType = "query", required = true),
             @ApiImplicitParam(name = "categoryId", value = "分类ID(可选,不传则查询全部)", dataType = "Integer", paramType = "query", required = false)
     })
     @GetMapping("/list")
-    public R<List<StoreBookingTableVo>> getTableList(
+    public R<IPage<StoreBookingTableVo>> getTableList(
+            @RequestParam(defaultValue = "1") Integer pageNum,
+            @RequestParam(defaultValue = "10") Integer pageSize,
             @RequestParam Integer storeId,
             @RequestParam(required = false) Integer categoryId) {
-        log.info("StoreBookingTableController.getTableList?storeId={}, categoryId={}", storeId, categoryId);
+        log.info("StoreBookingTableController.getTableList?pageNum={}, pageSize={}, storeId={}, categoryId={}", 
+                pageNum, pageSize, storeId, categoryId);
         
         if (storeId == null) {
             return R.fail("门店ID不能为空");
         }
         
+        if (pageNum == null || pageNum < 1) {
+            pageNum = 1;
+        }
+        if (pageSize == null || pageSize < 1) {
+            pageSize = 10;
+        }
+        
         try {
-            List<StoreBookingTableVo> list = storeBookingTableService.getTableListWithCategoryName(storeId, categoryId);
-            return R.data(list);
+            IPage<StoreBookingTableVo> page = storeBookingTableService.getTableListPage(pageNum, pageSize, storeId, categoryId);
+            return R.data(page);
         } catch (Exception e) {
             log.error("查询预订服务桌号列表失败", e);
             return R.fail("查询失败:" + e.getMessage());
@@ -122,7 +135,7 @@ public class StoreBookingTableController {
                 e.getMessage().contains("以下桌号已存在不能添加"))) {
                 return R.fail(e.getMessage());
             }
-            return R.fail("新增失败:" + e.getMessage());
+            return R.fail(e.getMessage());
         }
     }
 
@@ -191,4 +204,31 @@ public class StoreBookingTableController {
         }
     }
 
+    @ApiOperationSupport(order = 6)
+    @ApiOperation("查询桌号下是否有预订信息")
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "tableId", value = "桌号ID", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "storeId", value = "门店ID", dataType = "Integer", paramType = "query", required = true)
+    })
+    @GetMapping("/hasReservation")
+    public R<Boolean> hasReservationInTable(@RequestParam Integer tableId, @RequestParam Integer storeId) {
+        log.info("StoreBookingTableController.hasReservationInTable?tableId={}, storeId={}", tableId, storeId);
+        
+        if (tableId == null) {
+            return R.fail("桌号ID不能为空");
+        }
+        
+        if (storeId == null) {
+            return R.fail("门店ID不能为空");
+        }
+        
+        try {
+            boolean hasReservation = storeBookingTableService.hasReservationInTable(tableId, storeId);
+            return R.data(hasReservation);
+        } catch (Exception e) {
+            log.error("查询桌号下是否有预订信息失败", e);
+            return R.fail("查询失败:" + e.getMessage());
+        }
+    }
+
 }

+ 64 - 0
alien-store/src/main/java/shop/alien/store/controller/StoreReservationController.java

@@ -232,4 +232,68 @@ public class StoreReservationController {
             return R.fail("查询失败:" + e.getMessage());
         }
     }
+
+    @ApiOperationSupport(order = 8)
+    @ApiOperation("商家端主动退款(调用支付退款接口并发送通知和短信)")
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "storeId", value = "门店ID", required = true, paramType = "query", dataType = "int"),
+            @ApiImplicitParam(name = "outTradeNo", value = "商户订单号", required = true, paramType = "query", dataType = "String"),
+            @ApiImplicitParam(name = "refundAmount", value = "退款金额(元)", required = true, paramType = "query", dataType = "String"),
+            @ApiImplicitParam(name = "refundType", value = "退款类型 0:用户取消 1:商家退款 2:部分退款等 3.扫码核销成功", required = false, paramType = "query", dataType = "int"),
+            @ApiImplicitParam(name = "refundReason", value = "退款原因", paramType = "query", dataType = "String"),
+            @ApiImplicitParam(name = "payType", value = "支付类型 alipay/wechatPay", paramType = "query", dataType = "String")
+    })
+    @PostMapping("/refundByOrder")
+    public R<String> refundByOrder(
+            @RequestParam Integer storeId,
+            @RequestParam String outTradeNo,
+            @RequestParam String refundAmount,
+            @RequestParam(required = false) Integer refundType,
+            @RequestParam(required = false) String refundReason,
+            @RequestParam(defaultValue = "alipay") String payType) {
+        log.info("StoreReservationController.refundByOrder storeId={}, outTradeNo={}, refundAmount={}, refundType={}, refundReason={}, payType={}", 
+                storeId, outTradeNo, refundAmount, refundType, refundReason, payType);
+
+        if (storeId == null) {
+            return R.fail("门店ID不能为空");
+        }
+        if (outTradeNo == null || outTradeNo.trim().isEmpty()) {
+            return R.fail("商户订单号不能为空");
+        }
+        if (refundAmount == null || refundAmount.trim().isEmpty()) {
+            return R.fail("退款金额不能为空");
+        }
+
+        try {
+            R<String> result = storeReservationService.refundByOrder(storeId, outTradeNo, refundAmount, refundReason, refundType, payType);
+            return result;
+        } catch (Exception e) {
+            log.error("商家端主动退款失败", e);
+            return R.fail("退款失败:" + e.getMessage());
+        }
+    }
+
+    @ApiOperationSupport(order = 9)
+    @ApiOperation("通过订单ID退款(根据订单自动带出门店、商户订单号、金额、支付方式,成功时发送通知和短信)")
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "orderId", value = "预订订单ID(user_reservation_order.id)", required = true, paramType = "query", dataType = "int"),
+            @ApiImplicitParam(name = "refundReason", value = "退款原因", required = false, paramType = "query", dataType = "String"),
+            @ApiImplicitParam(name = "refundType", value = "退款类型 0:用户取消 1:商家退款 2:部分退款等 3.扫码核销成功", required = false, paramType = "query", dataType = "int")
+    })
+    @PostMapping("/refundByOrderId")
+    public R<String> refundByOrderId(
+            @RequestParam Integer orderId,
+            @RequestParam(required = false) String refundReason,
+            @RequestParam(required = false) Integer refundType) {
+        log.info("StoreReservationController.refundByOrderId?orderId={}, refundReason={}, refundType={}", orderId, refundReason, refundType);
+        if (orderId == null) {
+            return R.fail("订单ID不能为空");
+        }
+        try {
+            return storeReservationService.refundByOrderId(orderId, refundReason, refundType);
+        } catch (Exception e) {
+            log.error("通过订单ID退款失败", e);
+            return R.fail("退款失败:" + e.getMessage());
+        }
+    }
 }

+ 13 - 0
alien-store/src/main/java/shop/alien/store/feign/DiningServiceFeign.java

@@ -9,6 +9,7 @@ import org.springframework.web.multipart.MultipartFile;
 import shop.alien.entity.result.R;
 import shop.alien.entity.store.StoreTable;
 import shop.alien.entity.store.dto.CartDTO;
+import shop.alien.entity.store.dto.ChangeTableDTO;
 import shop.alien.entity.store.vo.*;
 
 import java.util.List;
@@ -126,6 +127,18 @@ public interface DiningServiceFeign {
             @PathVariable("tableId") Integer tableId);
 
     /**
+     * 换桌(迁移购物车、未完成订单及关联数据,支持点餐未下单场景,无需订单号)
+     *
+     * @param authorization 请求头 Authorization
+     * @param dto            原桌号ID、目标桌号ID、换桌原因
+     * @return R.data 为迁移后的购物车 CartDTO
+     */
+    @PostMapping("/store/order/change-table")
+    R<CartDTO> changeTable(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestBody ChangeTableDTO dto);
+
+    /**
      * 获取订单详情
      *
      * @param authorization 请求头 Authorization

+ 25 - 0
alien-store/src/main/java/shop/alien/store/service/ArrivalReminderNoticeService.java

@@ -0,0 +1,25 @@
+package shop.alien.store.service;
+
+/**
+ * 预订到店提醒:向用户发送站内通知(与短信独立,供定时任务调用)
+ */
+public interface ArrivalReminderNoticeService {
+
+    /**
+     * 写入一条「到店提醒」站内通知(LifeNotice)
+     * <p>标题:到店提醒</p>
+     * <p>内容:您在HH:mm预订了{店铺名}{桌号}的桌位,请您及时到店</p>
+     *
+     * @param userPhone      预约人手机号(用于 receiverId=user_{phone})
+     * @param orderId        预订订单 id(与 context 中 orderId 一致)
+     * @param businessOrderId 业务主键,写入 life_notice.business_id
+     * @param reservationId  预约 id
+     * @param startTimeRaw   预约开始时间原始串,如 2026-01-01 14:00
+     * @param storeName      店铺名称
+     * @param tableNumbers   桌号文案,多桌逗号拼接
+     * @return 落库成功返回 true
+     */
+    boolean sendArrivalReminderNotice(String userPhone, Integer orderId, Integer businessOrderId,
+                                      Integer reservationId, String startTimeRaw,
+                                      String storeName, String tableNumbers);
+}

+ 8 - 0
alien-store/src/main/java/shop/alien/store/service/MerchantPaymentOrderService.java

@@ -29,6 +29,14 @@ public interface MerchantPaymentOrderService extends IService<MerchantPaymentOrd
     MerchantPaymentOrder getUnpaidByOrderId(Integer orderId);
 
     /**
+     * 根据业务订单ID查询已支付支付单(pay_status=1),用于按订单退款时获取 payType 等
+     *
+     * @param orderId user_reservation_order.id
+     * @return 已支付单,不存在返回 null
+     */
+    MerchantPaymentOrder getPaidByOrderId(Integer orderId);
+
+    /**
      * 查询近期创建的待支付单(用于无异步回调时的后端轮询同步)
      *
      * @param withinMinutes 在最近多少分钟内创建的

+ 32 - 0
alien-store/src/main/java/shop/alien/store/service/ReservationNoticeAsyncService.java

@@ -0,0 +1,32 @@
+package shop.alien.store.service;
+
+/**
+ * 预约相关通知异步发送,避免阻塞主流程(查询+落库均在异步中执行)
+ */
+public interface ReservationNoticeAsyncService {
+
+    /**
+     * 异步根据订单ID查询预约/店铺/桌号并给商家发送订单取消提醒(LifeNotice)
+     *
+     * @param orderId 预订订单ID
+     */
+    void sendCancelReminderToStore(Integer orderId);
+
+    /**
+     * 异步给商家发送订单修改提醒(LifeNotice),需主线程传入原/新时间与桌位文案,异步内仅查店铺手机号并落库
+     *
+     * @param reservationId  预约ID
+     * @param oldDateTime   原预订时间,如 2026-01-01 14:00
+     * @param oldTableNumber 原桌位,如 A01
+     * @param newDateTime   修改后时间
+     * @param newTableNumber 修改后桌位,如 A02
+     */
+    void sendUpdateReminderToStore(Integer reservationId, String oldDateTime, String oldTableNumber, String newDateTime, String newTableNumber);
+
+    /**
+     * 异步根据预约ID查询预约/店铺/桌号并给商家发送订单过期提醒(LifeNotice)
+     *
+     * @param reservationId 预约ID
+     */
+    void sendExpiredReminderToStore(Integer reservationId);
+}

+ 20 - 0
alien-store/src/main/java/shop/alien/store/service/StoreBookingCategoryService.java

@@ -1,5 +1,6 @@
 package shop.alien.store.service;
 
+import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.service.IService;
 import shop.alien.entity.store.StoreBookingCategory;
 
@@ -22,6 +23,16 @@ public interface StoreBookingCategoryService extends IService<StoreBookingCatego
     List<StoreBookingCategory> getCategoryList(Integer storeId);
 
     /**
+     * 分页查询预订服务分类列表(按排序字段排序)
+     *
+     * @param pageNum  页数
+     * @param pageSize 页容
+     * @param storeId  门店ID
+     * @return IPage<StoreBookingCategory>
+     */
+    IPage<StoreBookingCategory> getCategoryListPage(Integer pageNum, Integer pageSize, Integer storeId);
+
+    /**
      * 新增预订服务分类
      *
      * @param category 分类对象
@@ -62,4 +73,13 @@ public interface StoreBookingCategoryService extends IService<StoreBookingCatego
      * @return boolean
      */
     boolean updateDisplayStatus(Integer id, Integer isDisplay);
+
+    /**
+     * 查询分类下是否有预订信息
+     *
+     * @param categoryId 分类ID
+     * @param storeId    门店ID
+     * @return true-有预订信息, false-没有预订信息
+     */
+    boolean hasReservationInCategory(Integer categoryId, Integer storeId);
 }

+ 21 - 0
alien-store/src/main/java/shop/alien/store/service/StoreBookingTableService.java

@@ -1,5 +1,6 @@
 package shop.alien.store.service;
 
+import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.service.IService;
 import shop.alien.entity.store.StoreBookingTable;
 import shop.alien.entity.store.vo.StoreBookingTableVo;
@@ -33,6 +34,17 @@ public interface StoreBookingTableService extends IService<StoreBookingTable> {
     List<StoreBookingTableVo> getTableListWithCategoryName(Integer storeId, Integer categoryId);
 
     /**
+     * 分页查询预订服务桌号列表(包含分类名称)
+     *
+     * @param pageNum   页数
+     * @param pageSize  页容
+     * @param storeId   门店ID
+     * @param categoryId 分类ID(可选,null表示查询全部)
+     * @return IPage<StoreBookingTableVo>
+     */
+    IPage<StoreBookingTableVo> getTableListPage(Integer pageNum, Integer pageSize, Integer storeId, Integer categoryId);
+
+    /**
      * 新增预订服务桌号
      *
      * @param table 桌号对象
@@ -65,4 +77,13 @@ public interface StoreBookingTableService extends IService<StoreBookingTable> {
      * @return boolean
      */
     boolean deleteTable(Integer id);
+
+    /**
+     * 查询桌号下是否有预订信息
+     *
+     * @param tableId 桌号ID
+     * @param storeId 门店ID
+     * @return true-有预订信息, false-没有预订信息
+     */
+    boolean hasReservationInTable(Integer tableId, Integer storeId);
 }

+ 31 - 0
alien-store/src/main/java/shop/alien/store/service/StoreReservationService.java

@@ -72,4 +72,35 @@ public interface StoreReservationService {
      * @return 是否成功
      */
     boolean refundReservation(Integer reservationId);
+
+    /**
+     * 商家端主动退款(调用支付退款接口并发送通知和短信)
+     *
+     * @param storeId 门店ID
+     * @param outTradeNo 商户订单号
+     * @param refundAmount 退款金额(元)
+     * @param refundReason 退款原因
+     * @param refundType 退款类型 0:用户取消 1:商家退款 2:部分退款等 3.扫码核销成功
+     * @param payType 支付类型 alipay/wechatPay
+     * @return 退款结果
+     */
+    shop.alien.entity.result.R<String> refundByOrder(Integer storeId, String outTradeNo, String refundAmount,
+                                                       String refundReason, Integer refundType, String payType);
+
+    /**
+     * 通过预订订单ID退款(根据订单查出门店、商户订单号、金额、支付方式后调用支付退款,成功时发送通知和短信)
+     *
+     * @param orderId     预订订单ID(user_reservation_order.id)
+     * @param refundReason 退款原因,可选
+     * @param refundType   退款类型,可选,默认 1-商家退款
+     * @return 退款结果
+     */
+    shop.alien.entity.result.R<String> refundByOrderId(Integer orderId, String refundReason, Integer refundType);
+
+    /**
+     * 定时任务:查询支付状态为「退款中」的订单并重试退款(不发送短信和通知)
+     *
+     * @return 本次退款成功的订单数
+     */
+    int retryRefundFailedOrders();
 }

+ 9 - 0
alien-store/src/main/java/shop/alien/store/service/UserReservationOrderService.java

@@ -3,6 +3,8 @@ package shop.alien.store.service;
 import com.baomidou.mybatisplus.extension.service.IService;
 import shop.alien.entity.store.UserReservationOrder;
 
+import java.util.List;
+
 /**
  * 用户预订订单表 服务类
  *
@@ -40,4 +42,11 @@ public interface UserReservationOrderService extends IService<UserReservationOrd
      * @return 删除行数
      */
     int physicalDeleteByReservationId(Integer reservationId);
+
+    /**
+     * 查询支付状态为「退款中」的付费订单ID列表,用于定时任务重试退款
+     *
+     * @return 订单ID列表
+     */
+    List<Integer> listOrderIdsForRefundRetry();
 }

+ 16 - 0
alien-store/src/main/java/shop/alien/store/service/UserReservationService.java

@@ -190,4 +190,20 @@ public interface UserReservationService extends IService<UserReservation> {
      * @return 本次更新的预约数量(即更新的 order 数量)
      */
     int markReservationTimeoutByEndTime();
+
+    /**
+     * 定时任务:订单状态为待使用且当前时间距离预定开始时间小于等于30分钟时,发送到店提醒短信。
+     * 短信名称:到店提醒。内容:您在14:00预订了xxx(店铺名称)A01(桌号或名称)的桌位,请您及时到店
+     *
+     * @return 本次发送短信条数
+     */
+    int sendArrivalReminderSms();
+
+    /**
+     * 为单个订单发送到店提醒短信(供定时任务或 Redis 过期回调调用,不校验 Redis 防重)
+     *
+     * @param orderId 订单ID
+     * @return true 发送成功,false 未发送或发送失败
+     */
+    boolean sendArrivalReminderSmsForOrder(Integer orderId);
 }

+ 79 - 0
alien-store/src/main/java/shop/alien/store/service/impl/ArrivalReminderNoticeServiceImpl.java

@@ -0,0 +1,79 @@
+package shop.alien.store.service.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+import shop.alien.entity.store.LifeNotice;
+import shop.alien.mapper.LifeNoticeMapper;
+import shop.alien.store.service.ArrivalReminderNoticeService;
+
+/**
+ * 到店提醒用户站内通知:独立实现,便于维护与测试
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ArrivalReminderNoticeServiceImpl implements ArrivalReminderNoticeService {
+
+    private static final String TITLE = "到店提醒";
+    private static final int NOTICE_TYPE_ORDER = 2;
+
+    private final LifeNoticeMapper lifeNoticeMapper;
+
+    @Override
+    public boolean sendArrivalReminderNotice(String userPhone, Integer orderId, Integer businessOrderId,
+                                             Integer reservationId, String startTimeRaw,
+                                             String storeName, String tableNumbers) {
+        if (StringUtils.isBlank(userPhone) || orderId == null) {
+            return false;
+        }
+        String phone = userPhone.trim();
+        String hm = extractHourMinute(startTimeRaw);
+        String shop = StringUtils.isNotBlank(storeName) ? storeName.trim() : "未知店铺";
+        String tables = StringUtils.isNotBlank(tableNumbers) ? tableNumbers.trim() : "未知桌号";
+        String message = "您在" + hm + "预订了" + shop + tables + "的桌位,请您及时到店";
+
+        try {
+            JSONObject ctx = new JSONObject();
+            ctx.put("message", message);
+            ctx.put("orderId", orderId);
+            ctx.put("reservationId", reservationId);
+
+            LifeNotice notice = new LifeNotice();
+            notice.setSenderId("system");
+            notice.setReceiverId("user_" + phone);
+            notice.setBusinessId(businessOrderId);
+            notice.setTitle(TITLE);
+            notice.setContext(ctx.toJSONString());
+            notice.setNoticeType(NOTICE_TYPE_ORDER);
+            notice.setIsRead(0);
+            lifeNoticeMapper.insert(notice);
+            log.info("到店提醒站内通知已写入,orderId={}, receiverId=user_{}", orderId, phone);
+            return true;
+        } catch (Exception e) {
+            log.error("到店提醒站内通知写入失败,orderId={}", orderId, e);
+            return false;
+        }
+    }
+
+    /**
+     * 从预约开始时间解析 HH:mm;无法解析时返回「未知时间」
+     */
+    private static String extractHourMinute(String startTimeRaw) {
+        if (StringUtils.isBlank(startTimeRaw)) {
+            return "未知时间";
+        }
+        String s = startTimeRaw.trim();
+        int space = s.lastIndexOf(' ');
+        if (space >= 0 && space + 1 < s.length()) {
+            String tail = s.substring(space + 1).trim();
+            if (tail.length() >= 5 && tail.charAt(2) == ':') {
+                return tail.substring(0, 5);
+            }
+            return tail.length() > 5 ? tail.substring(0, 5) : tail;
+        }
+        return s.length() >= 5 ? s.substring(0, 5) : s;
+    }
+}

+ 1 - 1
alien-store/src/main/java/shop/alien/store/service/impl/CommonCommentServiceImpl.java

@@ -112,7 +112,7 @@ public class CommonCommentServiceImpl extends ServiceImpl<CommonCommentMapper, C
         commentWrapper.eq("cc.source_id", sourceId)
                 .eq("cc.parent_id", 0)
                 .eq("cc.delete_flag", CommonConstant.DELETE_FLAG_UNDELETE)
-                .orderByDesc("cc.create_time");
+                .orderByDesc("cc.created_time");
         Page<CommonCommentVo> page = null;
         if( null != pageNum && null != pageSize){
             page = new Page<>(pageNum, pageSize);

+ 43 - 42
alien-store/src/main/java/shop/alien/store/service/impl/LicenseAuditAsyncService.java

@@ -126,47 +126,6 @@ public class LicenseAuditAsyncService {
             log.info("{}证照审核结果,门店ID:{},图片URL:{},is_valid={},expiry_date={},is_expired={},remaining_days={},license_type={}",
                     licenseTypeName, storeId, imageUrl, isValid, expiryDateStr, isExpired, remainingDays, licenseType);
 
-            // 如果是营业执照,解析并存入到期时间
-            if (licenseStatus == 1 && StringUtils.isNotEmpty(expiryDateStr)) {
-                try {
-                    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
-                    Date expiryDate = sdf.parse(expiryDateStr);
-
-                    StoreInfo updateStoreInfo = new StoreInfo();
-                    updateStoreInfo.setId(storeId);
-                    updateStoreInfo.setBusinessLicenseExpirationTime(expiryDate);
-
-                    // 同步更新门店到期时间(expirationTime)
-                    // 门店到期时间 = min(合同到期时间, 营业执照到期时间)
-                    // 由于合同到期时间未单独存储,需要根据旧值推断
-                    StoreInfo currentStore = storeInfoMapper.selectById(storeId);
-                    if (currentStore != null) {
-                        Date currentExpiration = currentStore.getExpirationTime();
-                        Date oldBizExpiration = currentStore.getBusinessLicenseExpirationTime();
-                        if (currentExpiration != null && oldBizExpiration != null
-                                && currentExpiration.before(oldBizExpiration)) {
-                            // 合同到期时间 < 旧营业执照到期时间,说明合同是瓶颈
-                            // 新的门店到期时间 = min(合同到期时间, 新营业执照到期时间)
-                            updateStoreInfo.setExpirationTime(
-                                    expiryDate.before(currentExpiration) ? expiryDate : currentExpiration
-                            );
-                        } else {
-                            // 营业执照是瓶颈(或两者相等、或旧值为空)
-                            // 直接用新的营业执照到期时间更新
-                            updateStoreInfo.setExpirationTime(expiryDate);
-                        }
-                    } else {
-                        updateStoreInfo.setExpirationTime(expiryDate);
-                    }
-
-                    storeInfoMapper.updateById(updateStoreInfo);
-                    log.info("营业执照到期时间已更新,门店ID:{},营业执照到期:{},门店到期:{}",
-                            storeId, expiryDateStr, updateStoreInfo.getExpirationTime());
-                } catch (Exception e) {
-                    log.error("解析营业执照到期时间失败,门店ID:{},expiryDate:{}", storeId, expiryDateStr, e);
-                }
-            }
-
             // 判断审核结果
             boolean needReject = false;
             boolean needApprove = false;
@@ -322,8 +281,50 @@ public class LicenseAuditAsyncService {
                     storeInfoMapper.update(null, new LambdaUpdateWrapper<StoreInfo>()
                             .eq(StoreInfo::getId, storeId)
                             .set(StoreInfo::getBusinessLicenseStatus, 1)
+                            .set(StoreInfo::getUpdateBusinessLicenseTime, new Date())
                     );
-                    
+
+                    // 审核通过后,解析并存入营业执照到期时间
+                    if (StringUtils.isNotEmpty(expiryDateStr)) {
+                        try {
+                            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+                            Date expiryDate = sdf.parse(expiryDateStr);
+
+                            StoreInfo updateStoreInfo = new StoreInfo();
+                            updateStoreInfo.setId(storeId);
+                            updateStoreInfo.setBusinessLicenseExpirationTime(expiryDate);
+
+                            // 同步更新门店到期时间(expirationTime)
+                            // 门店到期时间 = min(合同到期时间, 营业执照到期时间)
+                            // 由于合同到期时间未单独存储,需要根据旧值推断
+                            StoreInfo currentStore = storeInfoMapper.selectById(storeId);
+                            if (currentStore != null) {
+                                Date currentExpiration = currentStore.getExpirationTime();
+                                Date oldBizExpiration = currentStore.getBusinessLicenseExpirationTime();
+                                if (currentExpiration != null && oldBizExpiration != null
+                                        && currentExpiration.before(oldBizExpiration)) {
+                                    // 合同到期时间 < 旧营业执照到期时间,说明合同是瓶颈
+                                    // 新的门店到期时间 = min(合同到期时间, 新营业执照到期时间)
+                                    updateStoreInfo.setExpirationTime(
+                                            expiryDate.before(currentExpiration) ? expiryDate : currentExpiration
+                                    );
+                                } else {
+                                    // 营业执照是瓶颈(或两者相等、或旧值为空)
+                                    // 直接用新的营业执照到期时间更新
+                                    updateStoreInfo.setExpirationTime(expiryDate);
+                                }
+                            } else {
+                                updateStoreInfo.setExpirationTime(expiryDate);
+                            }
+
+                            storeInfoMapper.updateById(updateStoreInfo);
+                            log.info("营业执照到期时间已更新,门店ID:{},营业执照到期:{},门店到期:{}",
+                                    storeId, expiryDateStr, updateStoreInfo.getExpirationTime());
+                        } catch (Exception e) {
+                            log.error("解析营业执照到期时间失败,门店ID:{},expiryDate:{}", storeId, expiryDateStr, e);
+                        }
+                    }
+
                     // 审核通过后,先逻辑删除旧的营业执照记录
                     LambdaUpdateWrapper<StoreImg> deleteOldImgWrapper = new LambdaUpdateWrapper<>();
                     deleteOldImgWrapper.eq(StoreImg::getStoreId, storeId)

+ 2 - 2
alien-store/src/main/java/shop/alien/store/service/impl/LifeUserLearningVideoServiceImpl.java

@@ -24,8 +24,8 @@ public class LifeUserLearningVideoServiceImpl extends ServiceImpl<LifeUserLearni
     @Override
     public R<String> add(LifeUserLearningVideo lifeUserLearningVideo) throws IOException, InterruptedException {
         log.info("LifeUserLearningVideoServiceImpl.add, param={}", lifeUserLearningVideo);
-        long videoDuration = VideoDurationFFmpeg.getVideoDuration(lifeUserLearningVideo.getVideoUrl());
-        lifeUserLearningVideo.setVideoDuration(String.valueOf(videoDuration));
+//        long videoDuration = VideoDurationFFmpeg.getVideoDuration(lifeUserLearningVideo.getVideoUrl());
+        lifeUserLearningVideo.setVideoDuration(String.valueOf(lifeUserLearningVideo.getVideoDuration()));
         boolean result = this.save(lifeUserLearningVideo);
         if (result) {
             return R.success("新增成功");

+ 13 - 0
alien-store/src/main/java/shop/alien/store/service/impl/MerchantPaymentOrderServiceImpl.java

@@ -70,6 +70,19 @@ public class MerchantPaymentOrderServiceImpl extends ServiceImpl<MerchantPayment
     }
 
     @Override
+    public MerchantPaymentOrder getPaidByOrderId(Integer orderId) {
+        if (orderId == null) {
+            return null;
+        }
+        LambdaQueryWrapper<MerchantPaymentOrder> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(MerchantPaymentOrder::getOrderId, orderId);
+        wrapper.eq(MerchantPaymentOrder::getPayStatus, 1);
+        wrapper.eq(MerchantPaymentOrder::getOrderType, "reservation_order");
+        wrapper.last("LIMIT 1");
+        return this.getOne(wrapper);
+    }
+
+    @Override
     public MerchantPaymentOrder getUnpaidByOrderId(Integer orderId) {
         if (orderId == null) {
             return null;

+ 206 - 0
alien-store/src/main/java/shop/alien/store/service/impl/ReservationNoticeAsyncServiceImpl.java

@@ -0,0 +1,206 @@
+package shop.alien.store.service.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import shop.alien.entity.store.LifeNotice;
+import shop.alien.entity.store.StoreBookingTable;
+import shop.alien.entity.store.StoreUser;
+import shop.alien.entity.store.UserReservation;
+import shop.alien.entity.store.UserReservationOrder;
+import shop.alien.entity.store.UserReservationTable;
+import shop.alien.mapper.LifeNoticeMapper;
+import shop.alien.mapper.StoreUserMapper;
+import shop.alien.mapper.UserReservationMapper;
+import shop.alien.mapper.UserReservationOrderMapper;
+import shop.alien.mapper.UserReservationTableMapper;
+import shop.alien.store.service.ReservationNoticeAsyncService;
+import shop.alien.store.service.StoreBookingTableService;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 预约相关通知异步发送实现:查询通知参数 + 落库均在异步中执行
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ReservationNoticeAsyncServiceImpl implements ReservationNoticeAsyncService {
+
+    private final LifeNoticeMapper lifeNoticeMapper;
+    private final UserReservationOrderMapper userReservationOrderMapper;
+    private final UserReservationMapper userReservationMapper;
+    private final StoreUserMapper storeUserMapper;
+    private final UserReservationTableMapper userReservationTableMapper;
+    private final StoreBookingTableService storeBookingTableService;
+
+    @Async("taskExecutor")
+    @Override
+    public void sendCancelReminderToStore(Integer orderId) {
+        if (orderId == null) {
+            return;
+        }
+        try {
+            UserReservationOrder order = userReservationOrderMapper.selectById(orderId);
+            if (order == null || order.getReservationId() == null) {
+                return;
+            }
+            Integer reservationId = order.getReservationId();
+            UserReservation reservation = userReservationMapper.selectById(reservationId);
+            if (reservation == null) {
+                return;
+            }
+            StoreUser storeUser = storeUserMapper.selectOne(new LambdaQueryWrapper<StoreUser>()
+                    .eq(StoreUser::getStoreId, reservation.getStoreId())
+                    .eq(StoreUser::getDeleteFlag, 0)
+                    .last("LIMIT 1"));
+            if (storeUser == null || storeUser.getPhone() == null || storeUser.getPhone().trim().isEmpty()) {
+                return;
+            }
+            String storePhone = storeUser.getPhone().trim();
+            String dateTime = reservation.getStartTime() != null && !reservation.getStartTime().trim().isEmpty()
+                    ? reservation.getStartTime().trim() : "未知时间";
+            List<UserReservationTable> reservationTables = userReservationTableMapper.selectList(
+                    new LambdaQueryWrapper<UserReservationTable>()
+                            .eq(UserReservationTable::getReservationId, reservationId)
+                            .eq(UserReservationTable::getDeleteFlag, 0)
+                            .orderByAsc(UserReservationTable::getSort));
+            String tableNumber = "未知桌号";
+            if (reservationTables != null && !reservationTables.isEmpty()) {
+                List<String> tableNumbers = reservationTables.stream()
+                        .map(rt -> {
+                            StoreBookingTable table = storeBookingTableService.getById(rt.getTableId());
+                            return table != null && table.getTableNumber() != null ? table.getTableNumber() : null;
+                        })
+                        .filter(tn -> tn != null && !tn.trim().isEmpty())
+                        .collect(Collectors.toList());
+                if (!tableNumbers.isEmpty()) {
+                    tableNumber = String.join(",", tableNumbers);
+                }
+            }
+            String message = "用户取消了" + dateTime + "预订的" + tableNumber + "的桌位";
+
+            JSONObject contextJson = new JSONObject();
+            contextJson.put("message", message);
+            contextJson.put("reservationId", reservationId);
+            LifeNotice lifeNotice = new LifeNotice();
+            lifeNotice.setSenderId("system");
+            lifeNotice.setReceiverId("store_" + storePhone);
+            lifeNotice.setBusinessId(reservationId);
+            lifeNotice.setTitle("订单取消提醒");
+            lifeNotice.setContext(contextJson.toJSONString());
+            lifeNotice.setNoticeType(2);
+            lifeNotice.setIsRead(0);
+            lifeNoticeMapper.insert(lifeNotice);
+            log.info("用户取消预订已通知商家,reservationId={}, receiverId=store_{}", reservationId, storePhone);
+        } catch (Exception e) {
+            log.error("发送订单取消提醒通知异常,orderId={}", orderId, e);
+        }
+    }
+
+    @Async("taskExecutor")
+    @Override
+    public void sendUpdateReminderToStore(Integer reservationId, String oldDateTime, String oldTableNumber, String newDateTime, String newTableNumber) {
+        if (reservationId == null) {
+            return;
+        }
+        try {
+            UserReservation reservation = userReservationMapper.selectById(reservationId);
+            if (reservation == null) {
+                return;
+            }
+            StoreUser storeUser = storeUserMapper.selectOne(new LambdaQueryWrapper<StoreUser>()
+                    .eq(StoreUser::getStoreId, reservation.getStoreId())
+                    .eq(StoreUser::getDeleteFlag, 0)
+                    .last("LIMIT 1"));
+            if (storeUser == null || storeUser.getPhone() == null || storeUser.getPhone().trim().isEmpty()) {
+                return;
+            }
+            String storePhone = storeUser.getPhone().trim();
+            String oTime = oldDateTime != null && !oldDateTime.trim().isEmpty() ? oldDateTime.trim() : "未知时间";
+            String oTable = oldTableNumber != null && !oldTableNumber.trim().isEmpty() ? oldTableNumber.trim() : "未知桌号";
+            String nTime = newDateTime != null && !newDateTime.trim().isEmpty() ? newDateTime.trim() : "未知时间";
+            String nTable = newTableNumber != null && !newTableNumber.trim().isEmpty() ? newTableNumber.trim() : "未知桌号";
+            String message = "用户修改了预订信息,原预订信息为:" + oTime + "," + oTable + "桌位,修改后为:" + nTime + "," + nTable + "桌位";
+
+            JSONObject contextJson = new JSONObject();
+            contextJson.put("message", message);
+            contextJson.put("reservationId", reservationId);
+            LifeNotice lifeNotice = new LifeNotice();
+            lifeNotice.setSenderId("system");
+            lifeNotice.setReceiverId("store_" + storePhone);
+            lifeNotice.setBusinessId(reservationId);
+            lifeNotice.setTitle("订单修改提醒");
+            lifeNotice.setContext(contextJson.toJSONString());
+            lifeNotice.setNoticeType(2);
+            lifeNotice.setIsRead(0);
+            lifeNoticeMapper.insert(lifeNotice);
+            log.info("用户修改预订已通知商家,reservationId={}, receiverId=store_{}", reservationId, storePhone);
+        } catch (Exception e) {
+            log.error("发送订单修改提醒通知异常,reservationId={}", reservationId, e);
+        }
+    }
+
+    @Async("taskExecutor")
+    @Override
+    public void sendExpiredReminderToStore(Integer reservationId) {
+        if (reservationId == null) {
+            return;
+        }
+        try {
+            UserReservation reservation = userReservationMapper.selectById(reservationId);
+            if (reservation == null) {
+                return;
+            }
+            StoreUser storeUser = storeUserMapper.selectOne(new LambdaQueryWrapper<StoreUser>()
+                    .eq(StoreUser::getStoreId, reservation.getStoreId())
+                    .eq(StoreUser::getDeleteFlag, 0)
+                    .last("LIMIT 1"));
+            if (storeUser == null || storeUser.getPhone() == null || storeUser.getPhone().trim().isEmpty()) {
+                return;
+            }
+            String storePhone = storeUser.getPhone().trim();
+            String dateTime = reservation.getStartTime() != null && !reservation.getStartTime().trim().isEmpty()
+                    ? reservation.getStartTime().trim() : "未知时间";
+            List<UserReservationTable> reservationTables = userReservationTableMapper.selectList(
+                    new LambdaQueryWrapper<UserReservationTable>()
+                            .eq(UserReservationTable::getReservationId, reservationId)
+                            .eq(UserReservationTable::getDeleteFlag, 0)
+                            .orderByAsc(UserReservationTable::getSort));
+            String tableNumber = "未知桌号";
+            if (reservationTables != null && !reservationTables.isEmpty()) {
+                List<String> tableNumbers = reservationTables.stream()
+                        .map(rt -> {
+                            StoreBookingTable table = storeBookingTableService.getById(rt.getTableId());
+                            return table != null && table.getTableNumber() != null ? table.getTableNumber() : null;
+                        })
+                        .filter(tn -> tn != null && !tn.trim().isEmpty())
+                        .collect(Collectors.toList());
+                if (!tableNumbers.isEmpty()) {
+                    tableNumber = String.join(",", tableNumbers);
+                }
+            }
+            String message = "用户预订了" + dateTime + "的" + tableNumber + "的桌位,已超过预订时间";
+
+            JSONObject contextJson = new JSONObject();
+            contextJson.put("message", message);
+            contextJson.put("reservationId", reservationId);
+            LifeNotice lifeNotice = new LifeNotice();
+            lifeNotice.setSenderId("system");
+            lifeNotice.setReceiverId("store_" + storePhone);
+            lifeNotice.setBusinessId(reservationId);
+            lifeNotice.setTitle("订单过期提醒");
+            lifeNotice.setContext(contextJson.toJSONString());
+            lifeNotice.setNoticeType(2);
+            lifeNotice.setIsRead(0);
+            lifeNoticeMapper.insert(lifeNotice);
+            log.info("订单过期已通知商家,reservationId={}, receiverId=store_{}", reservationId, storePhone);
+        } catch (Exception e) {
+            log.error("发送订单过期提醒通知异常,reservationId={}", reservationId, e);
+        }
+    }
+}

+ 31 - 31
alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingBusinessHoursServiceImpl.java

@@ -86,14 +86,14 @@ public class StoreBookingBusinessHoursServiceImpl extends ServiceImpl<StoreBooki
             log.warn("保存营业时间失败:设置ID不能为空");
             throw new RuntimeException("设置ID不能为空");
         }
-        if (businessHours.getBusinessType() == null) {
-            log.warn("保存营业时间失败:营业类型不能为空");
-            throw new RuntimeException("营业类型不能为空");
-        }
-        if (businessHours.getBookingTimeType() == null) {
-            log.warn("保存营业时间失败:预订时间类型不能为空");
-            throw new RuntimeException("预订时间类型不能为空");
-        }
+//        if (businessHours.getBusinessType() == null) {
+//            log.warn("保存营业时间失败:营业类型不能为空");
+//            throw new RuntimeException("营业类型不能为空");
+//        }
+//        if (businessHours.getBookingTimeType() == null) {
+//            log.warn("保存营业时间失败:预订时间类型不能为空");
+//            throw new RuntimeException("预订时间类型不能为空");
+//        }
         
         // 如果是特殊营业(节假日),验证节假日类型
 //        if (businessHours.getBusinessType() != null && businessHours.getBusinessType() == 2) {
@@ -104,31 +104,31 @@ public class StoreBookingBusinessHoursServiceImpl extends ServiceImpl<StoreBooki
 //        }
         
         // 如果选择非全天,必须填写开始时间和结束时间
-        if (businessHours.getBookingTimeType() != null && businessHours.getBookingTimeType() == 0) {
-            if (!StringUtils.hasText(businessHours.getStartTime())) {
-                log.warn("保存营业时间失败:非全天时必须填写开始时间");
-                throw new RuntimeException("非全天时必须填写开始时间");
-            }
-            if (!StringUtils.hasText(businessHours.getEndTime())) {
-                log.warn("保存营业时间失败:非全天时必须填写结束时间");
-                throw new RuntimeException("非全天时必须填写结束时间");
-            }
-            // 验证时间格式
-            if (!TIME_PATTERN.matcher(businessHours.getStartTime()).matches()) {
-                log.warn("保存营业时间失败:开始时间格式不正确,应为HH:mm格式");
-                throw new RuntimeException("开始时间格式不正确,应为HH:mm格式");
-            }
-            if (!TIME_PATTERN.matcher(businessHours.getEndTime()).matches()) {
-                log.warn("保存营业时间失败:结束时间格式不正确,应为HH:mm格式");
-                throw new RuntimeException("结束时间格式不正确,应为HH:mm格式");
-            }
+//        if (businessHours.getBookingTimeType() != null && businessHours.getBookingTimeType() == 0) {
+//            if (!StringUtils.hasText(businessHours.getStartTime())) {
+//                log.warn("保存营业时间失败:非全天时必须填写开始时间");
+//                throw new RuntimeException("非全天时必须填写开始时间");
+//            }
+//            if (!StringUtils.hasText(businessHours.getEndTime())) {
+//                log.warn("保存营业时间失败:非全天时必须填写结束时间");
+//                throw new RuntimeException("非全天时必须填写结束时间");
+//            }
+//            // 验证时间格式
+//            if (!TIME_PATTERN.matcher(businessHours.getStartTime()).matches()) {
+//                log.warn("保存营业时间失败:开始时间格式不正确,应为HH:mm格式");
+//                throw new RuntimeException("开始时间格式不正确,应为HH:mm格式");
+//            }
+//            if (!TIME_PATTERN.matcher(businessHours.getEndTime()).matches()) {
+//                log.warn("保存营业时间失败:结束时间格式不正确,应为HH:mm格式");
+//                throw new RuntimeException("结束时间格式不正确,应为HH:mm格式");
+//            }
             // 验证开始时间必须小于结束时间(支持跨天,如22:00到次日02:00)
             // 这里只做基本格式验证,跨天逻辑由业务层处理
-        } else {
-            // 如果是全天,清空开始时间和结束时间
-            businessHours.setStartTime(null);
-            businessHours.setEndTime(null);
-        }
+//        } else {
+//            // 如果是全天,清空开始时间和结束时间
+//            businessHours.setStartTime(null);
+//            businessHours.setEndTime(null);
+//        }
         
         // 设置排序,如果为空则默认为0
         if (businessHours.getSort() == null) {

+ 48 - 1
alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingCategoryServiceImpl.java

@@ -3,6 +3,8 @@ package shop.alien.store.service.impl;
 import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -11,7 +13,9 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
 import shop.alien.entity.store.StoreBookingCategory;
 import shop.alien.entity.store.StoreBookingTable;
+import shop.alien.entity.store.UserReservation;
 import shop.alien.mapper.StoreBookingCategoryMapper;
+import shop.alien.mapper.UserReservationMapper;
 import shop.alien.store.service.StoreBookingCategoryService;
 import shop.alien.store.service.StoreBookingTableService;
 import shop.alien.util.common.JwtUtil;
@@ -31,6 +35,7 @@ import java.util.List;
 public class StoreBookingCategoryServiceImpl extends ServiceImpl<StoreBookingCategoryMapper, StoreBookingCategory> implements StoreBookingCategoryService {
 
     private final StoreBookingTableService storeBookingTableService;
+    private final UserReservationMapper userReservationMapper;
 
     @Override
     public List<StoreBookingCategory> getCategoryList(Integer storeId) {
@@ -45,6 +50,24 @@ public class StoreBookingCategoryServiceImpl extends ServiceImpl<StoreBookingCat
     }
 
     @Override
+    public IPage<StoreBookingCategory> getCategoryListPage(Integer pageNum, Integer pageSize, Integer storeId) {
+        log.info("StoreBookingCategoryServiceImpl.getCategoryListPage?pageNum={}, pageSize={}, storeId={}", 
+                pageNum, pageSize, storeId);
+        
+        // 构建分页对象
+        Page<StoreBookingCategory> page = new Page<>(pageNum, pageSize);
+        
+        // 构建查询条件
+        LambdaQueryWrapper<StoreBookingCategory> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(StoreBookingCategory::getStoreId, storeId)
+                .orderByAsc(StoreBookingCategory::getSort) // 按排序字段升序
+                .orderByDesc(StoreBookingCategory::getCreatedTime); // 如果排序字段相同,按创建时间倒序
+        
+        // 执行分页查询
+        return this.page(page, wrapper);
+    }
+
+    @Override
     public boolean addCategory(StoreBookingCategory category) {
         log.info("StoreBookingCategoryServiceImpl.addCategory?category={}", category);
         
@@ -150,7 +173,7 @@ public class StoreBookingCategoryServiceImpl extends ServiceImpl<StoreBookingCat
                 if (duplicateCategory != null) {
                     log.warn("更新预订服务分类失败:当前门店下分类名称已存在,storeId={}, categoryName={}, id={}", 
                             existingCategory.getStoreId(), categoryName, category.getId());
-                    throw new RuntimeException("此名称已存在不能编辑");
+                    throw new RuntimeException("此名称已存在");
                 }
             }
         }
@@ -291,6 +314,30 @@ public class StoreBookingCategoryServiceImpl extends ServiceImpl<StoreBookingCat
         return this.update(updateWrapper);
     }
 
+    @Override
+    public boolean hasReservationInCategory(Integer categoryId, Integer storeId) {
+        log.info("StoreBookingCategoryServiceImpl.hasReservationInCategory?categoryId={}, storeId={}", categoryId, storeId);
+        
+        if (categoryId == null || storeId == null) {
+            log.warn("查询分类下是否有预订信息失败:分类ID或门店ID不能为空");
+            return false;
+        }
+        
+        // 查询该门店该分类下是否有预订信息
+        LambdaQueryWrapper<UserReservation> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(UserReservation::getStoreId, storeId)
+                .eq(UserReservation::getCategoryId, categoryId);
+        // @TableLogic 会自动过滤已删除的记录(delete_flag=0)
+        
+        long count = userReservationMapper.selectCount(wrapper);
+        boolean hasReservation = count > 0;
+        
+        log.info("查询分类下是否有预订信息完成,categoryId={}, storeId={}, count={}, hasReservation={}", 
+                categoryId, storeId, count, hasReservation);
+        
+        return hasReservation;
+    }
+
     /**
      * 从JWT获取当前登录用户ID
      *

+ 236 - 4
alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingSettingsServiceImpl.java

@@ -13,10 +13,12 @@ import org.springframework.util.StringUtils;
 import shop.alien.entity.store.EssentialHolidayComparison;
 import shop.alien.entity.store.StoreBookingBusinessHours;
 import shop.alien.entity.store.StoreBookingSettings;
+import shop.alien.entity.store.StoreBusinessInfo;
 import shop.alien.entity.store.dto.StoreBookingBusinessHoursDTO;
 import shop.alien.entity.store.dto.StoreBookingSettingsDTO;
 import shop.alien.mapper.EssentialHolidayComparisonMapper;
 import shop.alien.mapper.StoreBookingSettingsMapper;
+import shop.alien.mapper.StoreBusinessInfoMapper;
 import shop.alien.store.service.StoreBookingBusinessHoursService;
 import shop.alien.store.service.StoreBookingSettingsService;
 import shop.alien.util.common.JwtUtil;
@@ -40,6 +42,7 @@ public class StoreBookingSettingsServiceImpl extends ServiceImpl<StoreBookingSet
 
     private final StoreBookingBusinessHoursService storeBookingBusinessHoursService;
     private final EssentialHolidayComparisonMapper essentialHolidayComparisonMapper;
+    private final StoreBusinessInfoMapper storeBusinessInfoMapper;
 
     // 时间格式正则:HH:mm
     private static final Pattern TIME_PATTERN = Pattern.compile("^([0-1][0-9]|2[0-3]):[0-5][0-9]$");
@@ -76,10 +79,7 @@ public class StoreBookingSettingsServiceImpl extends ServiceImpl<StoreBookingSet
             log.warn("保存预订服务信息设置失败:未按时到店是否保留位置不能为空");
             throw new RuntimeException("未按时到店是否保留位置不能为空");
         }
-        if (settings.getRetentionDuration() == null || settings.getRetentionDuration() <= 0) {
-            log.warn("保存预订服务信息设置失败:保留时长必须大于0");
-            throw new RuntimeException("保留时长必须大于0");
-        }
+
         if (settings.getBookingDateDisplayDays() == null || settings.getBookingDateDisplayDays() <= 0) {
             log.warn("保存预订服务信息设置失败:预订日期显示天数必须大于0");
             throw new RuntimeException("预订日期显示天数必须大于0");
@@ -224,13 +224,21 @@ public class StoreBookingSettingsServiceImpl extends ServiceImpl<StoreBookingSet
         List<StoreBookingBusinessHours> businessHoursList = new ArrayList<>();
         
         // 3.1 处理正常营业时间(business_type=1)
+        // 如果 normalBusinessHours 为空,则不保存正常营业时间
         if (dto.getNormalBusinessHours() != null) {
+            // 校验正常营业时间:预约时间的正常时间不能大于门店营业时间的正常时间
+            validateNormalBusinessHours(dto.getStoreId(), dto.getNormalBusinessHours());
+            
             StoreBookingBusinessHours normalHours = convertToBusinessHours(dto.getNormalBusinessHours(), settingsId, 1);
             businessHoursList.add(normalHours);
         }
         
         // 3.2 处理特殊营业时间列表(business_type=2)
+        // 如果 specialBusinessHoursList 为空或空列表,则不保存特殊营业时间
         if (dto.getSpecialBusinessHoursList() != null && !dto.getSpecialBusinessHoursList().isEmpty()) {
+            // 校验特殊营业时间:预约时间的特殊时间不能大于门店营业时间的特殊时间
+            validateSpecialBusinessHours(dto.getStoreId(), dto.getSpecialBusinessHoursList());
+            
             int sort = 1;
             for (StoreBookingBusinessHoursDTO specialDto : dto.getSpecialBusinessHoursList()) {
                 StoreBookingBusinessHours specialHours = convertToBusinessHours(specialDto, settingsId, 2);
@@ -240,12 +248,15 @@ public class StoreBookingSettingsServiceImpl extends ServiceImpl<StoreBookingSet
         }
         
         // 4. 批量保存营业时间(如果列表不为空)
+        // 如果 normalBusinessHours 和 specialBusinessHoursList 都为空,则不保存任何营业时间
         if (!businessHoursList.isEmpty()) {
             boolean businessHoursResult = storeBookingBusinessHoursService.batchSaveBusinessHours(settingsId, businessHoursList);
             if (!businessHoursResult) {
                 log.error("保存营业时间失败");
                 throw new RuntimeException("保存营业时间失败");
             }
+        } else {
+            log.info("normalBusinessHours 和 specialBusinessHoursList 都为空,跳过营业时间保存,settingsId={}", settingsId);
         }
         
         return true;
@@ -361,6 +372,227 @@ public class StoreBookingSettingsServiceImpl extends ServiceImpl<StoreBookingSet
     }
 
     /**
+     * 校验正常营业时间:预约时间的正常时间不能大于门店营业时间的正常时间
+     *
+     * @param storeId 门店ID
+     * @param normalBusinessHours 预约时间的正常营业时间
+     */
+    private void validateNormalBusinessHours(Integer storeId, StoreBookingBusinessHoursDTO normalBusinessHours) {
+        log.info("开始校验正常营业时间,storeId={}", storeId);
+        
+        if (storeId == null || normalBusinessHours == null) {
+            return;
+        }
+        
+        // 如果是全天营业,跳过校验
+        if (normalBusinessHours.getBookingTimeType() != null && normalBusinessHours.getBookingTimeType() == 1) {
+            return;
+        }
+        
+        // 如果没有开始时间和结束时间,跳过校验
+        if (!StringUtils.hasText(normalBusinessHours.getStartTime()) || 
+            !StringUtils.hasText(normalBusinessHours.getEndTime())) {
+            return;
+        }
+        
+        // 查询门店的正常营业时间(store_business_info,business_type=1)
+        List<StoreBusinessInfo> storeNormalBusinessHours = storeBusinessInfoMapper.selectList(
+                new LambdaQueryWrapper<StoreBusinessInfo>()
+                        .eq(StoreBusinessInfo::getStoreId, storeId)
+                        .eq(StoreBusinessInfo::getBusinessType, 1) // 正常营业时间
+                        .eq(StoreBusinessInfo::getDeleteFlag, 0)
+        );
+        
+        if (storeNormalBusinessHours == null || storeNormalBusinessHours.isEmpty()) {
+            log.warn("门店未配置正常营业时间,无法校验,storeId={}", storeId);
+            return;
+        }
+        
+        // 查找匹配的门店正常营业时间(正常营业时间通常只有一条,或者按 business_date 匹配)
+        StoreBusinessInfo matchedStoreHours = null;
+        
+        // 如果有 holiday_date,尝试通过 business_date 匹配
+        if (StringUtils.hasText(normalBusinessHours.getHolidayDate())) {
+            matchedStoreHours = storeNormalBusinessHours.stream()
+                    .filter(h -> normalBusinessHours.getHolidayDate().equals(h.getBusinessDate()))
+                    .findFirst()
+                    .orElse(null);
+        }
+        
+        // 如果没有匹配到,使用第一条正常营业时间(通常正常营业时间只有一条)
+        if (matchedStoreHours == null && !storeNormalBusinessHours.isEmpty()) {
+            matchedStoreHours = storeNormalBusinessHours.get(0);
+        }
+        
+        // 如果没有匹配到门店正常营业时间,跳过校验
+        if (matchedStoreHours == null) {
+            log.warn("未找到匹配的门店正常营业时间,跳过校验");
+            return;
+        }
+        
+        // 如果门店正常营业时间没有开始时间和结束时间,跳过校验
+        if (!StringUtils.hasText(matchedStoreHours.getStartTime()) || 
+            !StringUtils.hasText(matchedStoreHours.getEndTime())) {
+            return;
+        }
+        
+        // 比较时间范围
+        String bookingStartTime = normalBusinessHours.getStartTime().trim();
+        String bookingEndTime = normalBusinessHours.getEndTime().trim();
+        String storeStartTime = matchedStoreHours.getStartTime().trim();
+        String storeEndTime = matchedStoreHours.getEndTime().trim();
+        
+        // 比较开始时间:预约时间的开始时间不能早于门店营业时间的开始时间
+        if (compareTime(bookingStartTime, storeStartTime) < 0) {
+            log.error("预约时间的正常时间开始时间早于门店营业时间的正常时间,storeId={}, bookingStartTime={}, storeStartTime={}", 
+                    storeId, bookingStartTime, storeStartTime);
+            throw new RuntimeException("预订时间与营业时间冲突,请重新设置");
+        }
+        
+        // 比较结束时间:预约时间的结束时间不能晚于门店营业时间的结束时间
+        if (compareTime(bookingEndTime, storeEndTime) > 0) {
+            log.error("预约时间的正常时间结束时间晚于门店营业时间的正常时间,storeId={}, bookingEndTime={}, storeEndTime={}", 
+                    storeId, bookingEndTime, storeEndTime);
+            throw new RuntimeException("预订时间与营业时间冲突,请重新设置");
+        }
+        
+        log.info("正常营业时间校验通过,storeId={}", storeId);
+    }
+    
+    /**
+     * 校验特殊营业时间:预约时间的特殊时间不能大于门店营业时间的特殊时间
+     *
+     * @param storeId 门店ID
+     * @param specialBusinessHoursList 预约时间的特殊营业时间列表
+     */
+    private void validateSpecialBusinessHours(Integer storeId, List<StoreBookingBusinessHoursDTO> specialBusinessHoursList) {
+        log.info("开始校验特殊营业时间,storeId={}, specialBusinessHoursList.size={}", 
+                storeId, specialBusinessHoursList != null ? specialBusinessHoursList.size() : 0);
+        
+        if (storeId == null || specialBusinessHoursList == null || specialBusinessHoursList.isEmpty()) {
+            return;
+        }
+        
+        // 查询门店的特殊营业时间(store_business_info,business_type=2)
+        List<StoreBusinessInfo> storeSpecialBusinessHours = storeBusinessInfoMapper.selectList(
+                new LambdaQueryWrapper<StoreBusinessInfo>()
+                        .eq(StoreBusinessInfo::getStoreId, storeId)
+                        .eq(StoreBusinessInfo::getBusinessType, 2) // 特殊营业时间
+                        .eq(StoreBusinessInfo::getDeleteFlag, 0)
+        );
+        
+        if (storeSpecialBusinessHours == null || storeSpecialBusinessHours.isEmpty()) {
+            log.warn("门店未配置特殊营业时间,无法校验,storeId={}", storeId);
+            return;
+        }
+        
+        // 遍历预约时间的特殊营业时间,与门店特殊营业时间进行匹配和比较
+        for (StoreBookingBusinessHoursDTO bookingSpecialHours : specialBusinessHoursList) {
+            // 如果是全天营业,跳过校验
+            if (bookingSpecialHours.getBookingTimeType() != null && bookingSpecialHours.getBookingTimeType() == 1) {
+                continue;
+            }
+            
+            // 如果没有开始时间和结束时间,跳过校验
+            if (!StringUtils.hasText(bookingSpecialHours.getStartTime()) || 
+                !StringUtils.hasText(bookingSpecialHours.getEndTime())) {
+                continue;
+            }
+            
+            // 查找匹配的门店特殊营业时间
+            StoreBusinessInfo matchedStoreHours = null;
+            
+            // 优先通过 essential_id 匹配
+            if (bookingSpecialHours.getEssentialId() != null) {
+                matchedStoreHours = storeSpecialBusinessHours.stream()
+                        .filter(h -> {
+                            if (h.getEssentialId() == null || h.getEssentialId().trim().isEmpty()) {
+                                return false;
+                            }
+                            try {
+                                Integer essentialId = Integer.parseInt(h.getEssentialId().trim());
+                                return essentialId.equals(bookingSpecialHours.getEssentialId());
+                            } catch (NumberFormatException e) {
+                                return false;
+                            }
+                        })
+                        .findFirst()
+                        .orElse(null);
+            }
+            
+            // 如果没有通过 essential_id 匹配到,尝试通过 holiday_date 匹配
+            if (matchedStoreHours == null && StringUtils.hasText(bookingSpecialHours.getHolidayDate())) {
+                matchedStoreHours = storeSpecialBusinessHours.stream()
+                        .filter(h -> bookingSpecialHours.getHolidayDate().equals(h.getBusinessDate()))
+                        .findFirst()
+                        .orElse(null);
+            }
+            
+            // 如果没有匹配到门店特殊营业时间,跳过校验(可能是新增的特殊时间)
+            if (matchedStoreHours == null) {
+                log.warn("未找到匹配的门店特殊营业时间,跳过校验,essentialId={}, holidayDate={}", 
+                        bookingSpecialHours.getEssentialId(), bookingSpecialHours.getHolidayDate());
+                continue;
+            }
+            
+            // 如果门店特殊营业时间没有开始时间和结束时间,跳过校验
+            if (!StringUtils.hasText(matchedStoreHours.getStartTime()) || 
+                !StringUtils.hasText(matchedStoreHours.getEndTime())) {
+                continue;
+            }
+            
+            // 比较时间范围
+            String bookingStartTime = bookingSpecialHours.getStartTime().trim();
+            String bookingEndTime = bookingSpecialHours.getEndTime().trim();
+            String storeStartTime = matchedStoreHours.getStartTime().trim();
+            String storeEndTime = matchedStoreHours.getEndTime().trim();
+            
+            // 比较开始时间:预约时间的开始时间不能早于门店营业时间的开始时间
+            if (compareTime(bookingStartTime, storeStartTime) < 0) {
+                log.error("预约时间的特殊时间开始时间早于门店营业时间的特殊时间,storeId={}, bookingStartTime={}, storeStartTime={}", 
+                        storeId, bookingStartTime, storeStartTime);
+                throw new RuntimeException("预订时间与营业时间冲突,请重新设置");
+            }
+            
+            // 比较结束时间:预约时间的结束时间不能晚于门店营业时间的结束时间
+            if (compareTime(bookingEndTime, storeEndTime) > 0) {
+                log.error("预约时间的特殊时间结束时间晚于门店营业时间的特殊时间,storeId={}, bookingEndTime={}, storeEndTime={}", 
+                        storeId, bookingEndTime, storeEndTime);
+                throw new RuntimeException("预订时间与营业时间冲突,请重新设置");
+            }
+        }
+        
+        log.info("特殊营业时间校验通过,storeId={}", storeId);
+    }
+    
+    /**
+     * 比较两个时间(HH:mm格式)
+     * 
+     * @param time1 时间1
+     * @param time2 时间2
+     * @return 负数表示time1 < time2,0表示相等,正数表示time1 > time2
+     */
+    private int compareTime(String time1, String time2) {
+        try {
+            String[] parts1 = time1.split(":");
+            String[] parts2 = time2.split(":");
+            
+            int hour1 = Integer.parseInt(parts1[0]);
+            int minute1 = Integer.parseInt(parts1[1]);
+            int hour2 = Integer.parseInt(parts2[0]);
+            int minute2 = Integer.parseInt(parts2[1]);
+            
+            int totalMinutes1 = hour1 * 60 + minute1;
+            int totalMinutes2 = hour2 * 60 + minute2;
+            
+            return totalMinutes1 - totalMinutes2;
+        } catch (Exception e) {
+            log.error("比较时间失败,time1={}, time2={}", time1, time2, e);
+            throw new RuntimeException("时间格式错误");
+        }
+    }
+    
+    /**
      * 从JWT获取当前登录用户ID
      *
      * @return 用户ID,如果未登录返回null

+ 88 - 4
alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingTableServiceImpl.java

@@ -3,6 +3,8 @@ package shop.alien.store.service.impl;
 import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.annotation.Lazy;
@@ -12,8 +14,12 @@ import org.springframework.util.StringUtils;
 import org.springframework.beans.BeanUtils;
 import shop.alien.entity.store.StoreBookingCategory;
 import shop.alien.entity.store.StoreBookingTable;
+import shop.alien.entity.store.UserReservation;
+import shop.alien.entity.store.UserReservationTable;
 import shop.alien.entity.store.vo.StoreBookingTableVo;
 import shop.alien.mapper.StoreBookingTableMapper;
+import shop.alien.mapper.UserReservationMapper;
+import shop.alien.mapper.UserReservationTableMapper;
 import shop.alien.store.service.StoreBookingCategoryService;
 import shop.alien.store.service.StoreBookingTableService;
 import shop.alien.util.common.JwtUtil;
@@ -35,9 +41,15 @@ import java.util.stream.Collectors;
 public class StoreBookingTableServiceImpl extends ServiceImpl<StoreBookingTableMapper, StoreBookingTable> implements StoreBookingTableService {
 
     private final StoreBookingCategoryService storeBookingCategoryService;
+    private final UserReservationTableMapper userReservationTableMapper;
+    private final UserReservationMapper userReservationMapper;
 
-    public StoreBookingTableServiceImpl(@Lazy StoreBookingCategoryService storeBookingCategoryService) {
+    public StoreBookingTableServiceImpl(@Lazy StoreBookingCategoryService storeBookingCategoryService,
+                                        UserReservationTableMapper userReservationTableMapper,
+                                        UserReservationMapper userReservationMapper) {
         this.storeBookingCategoryService = storeBookingCategoryService;
+        this.userReservationTableMapper = userReservationTableMapper;
+        this.userReservationMapper = userReservationMapper;
     }
 
     @Override
@@ -96,6 +108,34 @@ public class StoreBookingTableServiceImpl extends ServiceImpl<StoreBookingTableM
                 })
                 .collect(Collectors.toList());
     }
+
+    @Override
+    public IPage<StoreBookingTableVo> getTableListPage(Integer pageNum, Integer pageSize, Integer storeId, Integer categoryId) {
+        log.info("StoreBookingTableServiceImpl.getTableListPage?pageNum={}, pageSize={}, storeId={}, categoryId={}", 
+                pageNum, pageSize, storeId, categoryId);
+        
+        // 先查询所有数据(因为需要自定义排序)
+        List<StoreBookingTableVo> allList = getTableListWithCategoryName(storeId, categoryId);
+        
+        // 计算分页信息
+        int total = allList.size();
+        int start = (pageNum - 1) * pageSize;
+        int end = Math.min(start + pageSize, total);
+        
+        // 手动分页
+        List<StoreBookingTableVo> pageList;
+        if (start >= total) {
+            pageList = java.util.Collections.emptyList();
+        } else {
+            pageList = allList.subList(start, end);
+        }
+        
+        // 构建分页对象
+        IPage<StoreBookingTableVo> page = new Page<>(pageNum, pageSize, total);
+        page.setRecords(pageList);
+        
+        return page;
+    }
     
     /**
      * 解析桌号用于排序
@@ -196,7 +236,7 @@ public class StoreBookingTableServiceImpl extends ServiceImpl<StoreBookingTableM
         if (existingTable != null) {
             log.warn("新增预订服务桌号失败:同一分类下桌号已存在,storeId={}, categoryId={}, tableNumber={}", 
                     table.getStoreId(), table.getCategoryId(), tableNumber);
-            throw new RuntimeException("该分类下桌号已存在不能添加");
+            throw new RuntimeException("此桌号已存在");
         }
         
         table.setCreatedUserId(userId);
@@ -275,9 +315,9 @@ public class StoreBookingTableServiceImpl extends ServiceImpl<StoreBookingTableM
             log.warn("批量新增预订服务桌号失败:同一分类下桌号已存在,storeId={}, categoryId={}, existingNumbers={}", 
                     storeId, categoryId, existingNumbers);
             if (existingNumbers.size() == 1) {
-                throw new RuntimeException("该分类下桌号已存在不能添加");
+                throw new RuntimeException("此桌号已存在");
             } else {
-                throw new RuntimeException("该分类下以下桌号已存在不能添加:" + String.join("、", existingNumbers));
+                throw new RuntimeException("此桌号已存在:" + String.join("、", existingNumbers));
             }
         }
         
@@ -371,6 +411,50 @@ public class StoreBookingTableServiceImpl extends ServiceImpl<StoreBookingTableM
         return this.removeById(id);
     }
 
+    @Override
+    public boolean hasReservationInTable(Integer tableId, Integer storeId) {
+        log.info("StoreBookingTableServiceImpl.hasReservationInTable?tableId={}, storeId={}", tableId, storeId);
+        
+        if (tableId == null || storeId == null) {
+            log.warn("查询桌号下是否有预订信息失败:桌号ID或门店ID不能为空");
+            return false;
+        }
+        
+        // 查询该桌号下是否有预订信息
+        // 通过 user_reservation_table 关联 user_reservation 表查询
+        // 条件:table_id = 桌号ID,且 user_reservation.store_id = 门店ID,且两个表的 delete_flag = 0
+        LambdaQueryWrapper<UserReservationTable> tableWrapper = new LambdaQueryWrapper<>();
+        tableWrapper.eq(UserReservationTable::getTableId, tableId);
+        // @TableLogic 会自动过滤已删除的记录(delete_flag=0)
+        
+        List<UserReservationTable> reservationTables = userReservationTableMapper.selectList(tableWrapper);
+        
+        if (reservationTables == null || reservationTables.isEmpty()) {
+            log.info("查询桌号下是否有预订信息完成,tableId={}, storeId={}, count=0, hasReservation=false", 
+                    tableId, storeId);
+            return false;
+        }
+        
+        // 检查这些预订是否属于该门店
+        List<Integer> reservationIds = reservationTables.stream()
+                .map(UserReservationTable::getReservationId)
+                .distinct()
+                .collect(Collectors.toList());
+        
+        LambdaQueryWrapper<UserReservation> reservationWrapper = new LambdaQueryWrapper<>();
+        reservationWrapper.in(UserReservation::getId, reservationIds)
+                .eq(UserReservation::getStoreId, storeId);
+        // @TableLogic 会自动过滤已删除的记录(delete_flag=0)
+        
+        long count = userReservationMapper.selectCount(reservationWrapper);
+        boolean hasReservation = count > 0;
+        
+        log.info("查询桌号下是否有预订信息完成,tableId={}, storeId={}, count={}, hasReservation={}", 
+                tableId, storeId, count, hasReservation);
+        
+        return hasReservation;
+    }
+
     /**
      * 从JWT获取当前登录用户ID
      *

+ 152 - 0
alien-store/src/main/java/shop/alien/store/service/impl/StoreReservationServiceImpl.java

@@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import shop.alien.entity.store.MerchantPaymentOrder;
 import shop.alien.entity.store.StoreBookingTable;
 import shop.alien.entity.store.StoreInfo;
 import shop.alien.entity.store.UserReservation;
@@ -23,6 +24,7 @@ import shop.alien.store.config.BaseRedisService;
 import shop.alien.store.listener.RedisKeyExpirationHandler;
 import shop.alien.store.service.StoreBookingTableService;
 import shop.alien.store.service.StoreInfoService;
+import shop.alien.store.service.MerchantPaymentOrderService;
 import shop.alien.store.service.StoreReservationService;
 import shop.alien.store.service.UserReservationOrderService;
 import shop.alien.store.service.StoreBookingBusinessHoursService;
@@ -34,8 +36,11 @@ import com.alibaba.fastjson.JSONObject;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import shop.alien.entity.store.LifeNotice;
 import shop.alien.entity.store.LifeUser;
+import shop.alien.entity.store.StoreUser;
 import shop.alien.mapper.LifeNoticeMapper;
 import shop.alien.mapper.LifeUserMapper;
+import shop.alien.mapper.StoreUserMapper;
+import shop.alien.util.common.JwtUtil;
 import org.springframework.util.StringUtils;
 import javax.annotation.PostConstruct;
 import java.text.ParseException;
@@ -67,10 +72,12 @@ public class StoreReservationServiceImpl extends ServiceImpl<StoreReservationMap
     private final UserReservationTableMapper userReservationTableMapper;
     private final LifeNoticeMapper lifeNoticeMapper;
     private final LifeUserMapper lifeUserMapper;
+    private final StoreUserMapper storeUserMapper;
     private final MerchantPaymentStrategyFactory merchantPaymentStrategyFactory;
     private final StoreBookingBusinessHoursService storeBookingBusinessHoursService;
     private final StoreBookingSettingsService storeBookingSettingsService;
     private final EssentialHolidayComparisonMapper essentialHolidayComparisonMapper;
+    private final MerchantPaymentOrderService merchantPaymentOrderService;
 
     /** 预约状态:已取消 */
     private static final int STATUS_CANCELLED = 3;
@@ -623,6 +630,19 @@ public class StoreReservationServiceImpl extends ServiceImpl<StoreReservationMap
             throw new RuntimeException("预约不存在或已被删除");
         }
 
+        // 校验门店:核销码必须属于当前登录的门店
+        Integer currentStoreId = getCurrentStoreId();
+        if (currentStoreId == null) {
+            log.warn("无法获取当前登录门店ID,跳过门店校验");
+        } else {
+            Integer reservationStoreId = reservation.getStoreId();
+            if (reservationStoreId == null || !currentStoreId.equals(reservationStoreId)) {
+                log.error("核销码不属于本门店,当前门店ID={}, 预约门店ID={}, verificationCode={}", 
+                        currentStoreId, reservationStoreId, verificationCode);
+                throw new RuntimeException("此预订码不属于本门店,不予核销");
+            }
+        }
+
         // 校验预约状态(必须是已确认状态才能核销)
         if (reservation.getStatus() == null || reservation.getStatus() != 1) {
             throw new RuntimeException("预约状态不正确,只有已确认的预约才能核销");
@@ -1276,4 +1296,136 @@ public class StoreReservationServiceImpl extends ServiceImpl<StoreReservationMap
             default: return "未知";
         }
     }
+
+    @Override
+    public shop.alien.entity.result.R<String> refundByOrder(Integer storeId, String outTradeNo, String refundAmount, 
+                                                             String refundReason, Integer refundType, String payType) {
+        log.info("StoreReservationServiceImpl.refundByOrder storeId={}, outTradeNo={}, refundAmount={}, refundType={}, refundReason={}, payType={}", 
+                storeId, outTradeNo, refundAmount, refundType, refundReason, payType);
+
+        try {
+            // 1. 调用支付退款接口
+            MerchantPaymentStrategy strategy = merchantPaymentStrategyFactory.getStrategy(payType);
+            shop.alien.entity.result.R<String> refundResult = strategy.refund(storeId, outTradeNo, refundAmount, refundReason, refundType);
+            
+            // 2. 如果退款成功,发送通知和短信
+            if (refundResult != null && shop.alien.entity.result.R.isSuccess(refundResult)) {
+                log.info("支付退款成功,开始发送通知和短信,outTradeNo={}", outTradeNo);
+                
+                // 通过 outTradeNo 查询订单
+                shop.alien.entity.store.UserReservationOrder order = userReservationOrderService.getByOutTradeNo(outTradeNo);
+                if (order == null) {
+                    log.warn("未找到订单信息,无法发送通知和短信,outTradeNo={}", outTradeNo);
+                    return refundResult;
+                }
+                
+                // 通过订单的 reservationId 获取预约信息
+                Integer reservationId = order.getReservationId();
+                if (reservationId == null) {
+                    log.warn("订单未关联预约信息,无法发送通知和短信,outTradeNo={}, orderId={}", outTradeNo, order.getId());
+                    return refundResult;
+                }
+                
+                UserReservation reservation = this.getById(reservationId);
+                if (reservation == null) {
+                    log.warn("预约信息不存在,无法发送通知和短信,reservationId={}", reservationId);
+                    return refundResult;
+                }
+                
+                // 发送短信和通知
+                sendDepositRefundSmsAndNotice(reservation, order);
+                log.info("退款通知和短信发送完成,outTradeNo={}, reservationId={}", outTradeNo, reservationId);
+            } else {
+                log.warn("支付退款失败,不发送通知和短信,outTradeNo={}, result={}", outTradeNo, refundResult);
+            }
+            
+            return refundResult;
+        } catch (Exception e) {
+            log.error("商家端主动退款异常,outTradeNo={}", outTradeNo, e);
+            return shop.alien.entity.result.R.fail("退款失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 获取当前登录的门店ID
+     * 从JWT中获取用户ID,然后查询StoreUser获取门店ID
+     *
+     * @return 门店ID,如果未登录或查询失败返回null
+     */
+    private Integer getCurrentStoreId() {
+        try {
+            JSONObject userInfo = JwtUtil.getCurrentUserInfo();
+            if (userInfo != null) {
+                Integer userId = userInfo.getInteger("userId");
+                if (userId != null) {
+                    StoreUser storeUser = storeUserMapper.selectById(userId);
+                    if (storeUser != null) {
+                        return storeUser.getStoreId();
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("获取当前登录门店ID失败: {}", e.getMessage());
+        }
+        return null;
+    }
+
+    @Override
+    public shop.alien.entity.result.R<String> refundByOrderId(Integer orderId, String refundReason, Integer refundType) {
+        log.info("StoreReservationServiceImpl.refundByOrderId?orderId={}, refundReason={}, refundType={}", orderId, refundReason, refundType);
+        if (orderId == null) {
+            return shop.alien.entity.result.R.fail("订单ID不能为空");
+        }
+        UserReservationOrder order = userReservationOrderService.getById(orderId);
+        if (order == null) {
+            return shop.alien.entity.result.R.fail("预订订单不存在");
+        }
+        if (order.getPaymentStatus() == null || order.getPaymentStatus() != 1) {
+            return shop.alien.entity.result.R.fail("订单未支付或已退款,无法退款");
+        }
+        if (order.getOrderCostType() == null || order.getOrderCostType() != 1) {
+            return shop.alien.entity.result.R.fail("免费订单无需退款");
+        }
+        if (order.getOutTradeNo() == null || order.getOutTradeNo().trim().isEmpty()) {
+            return shop.alien.entity.result.R.fail("订单无商户订单号,无法退款");
+        }
+        MerchantPaymentOrder paymentOrder = merchantPaymentOrderService.getPaidByOrderId(orderId);
+        if (paymentOrder == null || paymentOrder.getPayType() == null || paymentOrder.getPayType().trim().isEmpty()) {
+            return shop.alien.entity.result.R.fail("未找到已支付记录或支付方式,无法退款");
+        }
+        String amount = order.getDepositAmount() != null ? order.getDepositAmount().toString() : null;
+        if (amount == null || amount.trim().isEmpty()) {
+            return shop.alien.entity.result.R.fail("订单订金金额异常,无法退款");
+        }
+        String reason = refundReason != null && !refundReason.trim().isEmpty() ? refundReason.trim() : "按订单退款";
+        int type = refundType != null ? refundType : 1;
+        try {
+            MerchantPaymentStrategy strategy = merchantPaymentStrategyFactory.getStrategy(paymentOrder.getPayType());
+            return strategy.refund(order.getStoreId(), order.getOutTradeNo(), amount, reason, type);
+        } catch (Exception e) {
+            log.error("通过订单ID退款异常,orderId={}", orderId, e);
+            return shop.alien.entity.result.R.fail("退款失败:" + e.getMessage());
+        }
+    }
+
+    @Override
+    public int retryRefundFailedOrders() {
+        java.util.List<Integer> orderIds = userReservationOrderService.listOrderIdsForRefundRetry();
+        if (orderIds == null || orderIds.isEmpty()) {
+            return 0;
+        }
+        int successCount = 0;
+        for (Integer orderId : orderIds) {
+            try {
+                shop.alien.entity.result.R<String> result = refundByOrderId(orderId, "定时重试退款", 1);
+                if (result != null && shop.alien.entity.result.R.isSuccess(result)) {
+                    successCount++;
+                    log.info("定时重试退款成功,orderId={}", orderId);
+                }
+            } catch (Exception e) {
+                log.error("定时重试退款异常,orderId={}", orderId, e);
+            }
+        }
+        return successCount;
+    }
 }

+ 1 - 0
alien-store/src/main/java/shop/alien/store/service/impl/StoreStaffConfigServiceImpl.java

@@ -346,6 +346,7 @@ public class StoreStaffConfigServiceImpl implements StoreStaffConfigService {
         LambdaQueryWrapper<StoreStaffConfig> queryWrapper = new LambdaQueryWrapper<>();
         queryWrapper.eq(StoreStaffConfig::getStoreId, storeId)
                 .eq(StoreStaffConfig::getDeleteFlag, 0)
+                .eq(StoreStaffConfig::getStatus, 1)
                 // 只查询staff_position不为空的记录
                 .isNotNull(StoreStaffConfig::getStaffPosition)
                 .ne(StoreStaffConfig::getStaffPosition, "")

+ 6 - 0
alien-store/src/main/java/shop/alien/store/service/impl/UserReservationOrderServiceImpl.java

@@ -14,6 +14,7 @@ import shop.alien.store.service.UserReservationOrderService;
 
 import java.text.SimpleDateFormat;
 import java.util.Date;
+import java.util.List;
 import java.util.concurrent.ThreadLocalRandom;
 
 /**
@@ -65,4 +66,9 @@ public class UserReservationOrderServiceImpl extends ServiceImpl<UserReservation
         }
         return baseMapper.physicalDeleteByReservationId(reservationId);
     }
+
+    @Override
+    public List<Integer> listOrderIdsForRefundRetry() {
+        return baseMapper.listOrderIdsForRefundRetry();
+    }
 }

+ 185 - 13
alien-store/src/main/java/shop/alien/store/service/impl/UserReservationServiceImpl.java

@@ -1,7 +1,6 @@
 package shop.alien.store.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -26,7 +25,9 @@ import shop.alien.store.vo.ReservationOrderDetailVo;
 import shop.alien.store.vo.ReservationOrderPageVo;
 import shop.alien.mapper.UserReservationMapper;
 import shop.alien.mapper.UserReservationTableMapper;
+import shop.alien.store.config.BaseRedisService;
 import shop.alien.store.service.*;
+import shop.alien.store.util.ali.AliSms;
 import shop.alien.util.common.UniqueRandomNumGenerator;
 
 import java.math.BigDecimal;
@@ -67,6 +68,11 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
 
     private final EssentialHolidayComparisonMapper essentialHolidayComparisonMapper;
 
+    private final BaseRedisService baseRedisService;
+    private final AliSms aliSms;
+    private final ReservationNoticeAsyncService reservationNoticeAsyncService;
+    private final ArrivalReminderNoticeService arrivalReminderNoticeService;
+
     private ReservationOrderPageService reservationOrderPageService;
 
     @Autowired
@@ -90,6 +96,11 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
     private static final int MAX_DAYS_TO_CHECK = 366;
     /** 全天预订时使用的结束分钟数(24*60,即到次日0点) */
     private static final int MINUTES_DAY_END = 24 * 60;
+    /** 到店提醒:短信防重(保持与原 key 一致,避免已标记订单重复发短信) */
+    private static final String REDIS_KEY_ARRIVAL_SMS_PREFIX = "reservation:arrival:reminder:order:";
+    /** 到店提醒:站内通知防重(与短信独立,短信失败可多次重试) */
+    private static final String REDIS_KEY_ARRIVAL_NOTICE_PREFIX = "reservation:arrival:notice:order:";
+    private static final long ARRIVAL_REMINDER_REDIS_TTL_SECONDS = 30 * 60;
 
     @Override
     public Integer add(UserReservationDTO dto) {
@@ -192,6 +203,11 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
             throw new RuntimeException("预约不存在");
         }
 
+        // 修改前先取原时间与桌位(用于商家订单修改提醒)
+        String oldDateTime = existing.getStartTime() != null && !existing.getStartTime().trim().isEmpty()
+                ? existing.getStartTime().trim() : "未知时间";
+        String oldTableNumber = tableIdsToTableNumberString(listTableIdsByReservationId(existing.getId()));
+
         UserReservation entity = new UserReservation();
         BeanUtils.copyProperties(dto, entity, "tableIds", "reservationNo");
         entity.setId(existing.getId());
@@ -200,6 +216,12 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
 
         saveReservationTables(existing.getId(), dto.getTableIds());
 
+        // 修改后文案,并异步给商家发送订单修改提醒
+        String newDateTime = dto.getStartTime() != null && !dto.getStartTime().trim().isEmpty()
+                ? dto.getStartTime().trim() : "未知时间";
+        String newTableNumber = tableIdsToTableNumberString(dto.getTableIds());
+        reservationNoticeAsyncService.sendUpdateReminderToStore(existing.getId(), oldDateTime, oldTableNumber, newDateTime, newTableNumber);
+
         // 与 add 一致:同步 user_reservation_order(无则创建,有则仅待支付时按门店配置刷新)
         UserReservation updatedReservation = this.getById(existing.getId());
         LambdaQueryWrapper<UserReservationOrder> orderWrapper = new LambdaQueryWrapper<>();
@@ -228,28 +250,37 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
 
     @Override
     public boolean removeReservation(Integer id) {
-
-        UserReservationOrder one = userReservationOrderService.getOne(new LambdaUpdateWrapper<UserReservationOrder>()
+        UserReservationOrder one = userReservationOrderService.getOne(new LambdaQueryWrapper<UserReservationOrder>()
                 .eq(UserReservationOrder::getId, id));
         if (one == null) {
             throw new RuntimeException("预约不存在");
         }
+        Integer reservationId = one.getReservationId();
 
         // 当订单为未支付时,订单状态变为已关闭
         int orderStatus = 4;
-        if(one.getPaymentStatus()!= null && one.getPaymentStatus() == 0){
+        if (one.getPaymentStatus() != null && one.getPaymentStatus() == 0) {
             orderStatus = 5;
         }
 
-        // 订单状态置为已取消(4)
+        // 订单状态置为已取消(4)或已关闭(5)
         userReservationOrderService.update(
                 new LambdaUpdateWrapper<UserReservationOrder>()
                         .eq(UserReservationOrder::getId, one.getId())
                         .set(UserReservationOrder::getOrderStatus, orderStatus));
         // 预约不再逻辑删除,仅将 status 置为已取消(3)
-        return this.update(new LambdaUpdateWrapper<UserReservation>()
-                .eq(UserReservation::getId, one.getReservationId())
+        boolean updated = reservationId != null && this.update(new LambdaUpdateWrapper<UserReservation>()
+                .eq(UserReservation::getId, reservationId)
                 .set(UserReservation::getStatus, STATUS_CANCELLED));
+        if (reservationId == null) {
+            updated = true;
+        }
+
+        // 异步查询通知参数并给商家发送订单取消提醒(查询+落库均在异步中)
+        if (updated) {
+            reservationNoticeAsyncService.sendCancelReminderToStore(id);
+        }
+        return updated;
     }
 
     @Override
@@ -332,6 +363,11 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
             if (reservation.getStatus() != null && reservation.getStatus() == STATUS_CANCELLED) {
                 continue;
             }
+
+            if (reservation.getStatus() != null && reservation.getStatus() == 2) {
+                continue;
+            }
+
             if (calReservation != null && reservation.getReservationDate() != null) {
                 Calendar cal = calendarOf(reservation.getReservationDate());
                 if (cal.get(Calendar.YEAR) != calReservation.get(Calendar.YEAR)
@@ -439,7 +475,11 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
         
         List<StoreBookingTable> storeBookingTables = storeBookingTableService.list(new LambdaQueryWrapper<StoreBookingTable>().eq(StoreBookingTable::getStoreId, storeId));
         list.put("storeBookingTables", storeBookingTables);
-        List<StoreBookingCategory> storeBookingCategorys = storeBookingCategoryService.list(new LambdaQueryWrapper<StoreBookingCategory>().eq(StoreBookingCategory::getStoreId, storeId));
+        List<StoreBookingCategory> storeBookingCategorys = storeBookingCategoryService.list(
+                new LambdaQueryWrapper<StoreBookingCategory>()
+                        .eq(StoreBookingCategory::getStoreId, storeId)
+                        .eq(StoreBookingCategory::getIsDisplay, 1)
+                        .orderByDesc(StoreBookingCategory::getSort));
         list.put("storeBookingCategorys", storeBookingCategorys);
         StoreMainInfoVo storeInfo = storeInfoService.getStoreInfo(storeId);
         list.put("storeInfo", storeInfo);
@@ -462,13 +502,18 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
     }
 
     /**
-     * 将 "HH:mm" 解析为当日 0 点起的分钟数,解析失败返回 -1。
+     * 将 "HH:mm" 或 "yyyy-MM-dd HH:mm" / "yyyy-MM-dd HH:mm:ss" 解析为当日 0 点起的分钟数,解析失败返回 -1。
+     * 若带年月日(含空格),先去掉日期部分再按时分计算。
      */
     private static int timeToMinutes(String hhmm) {
         if (hhmm == null) {
             return -1;
         }
-        String[] parts = hhmm.trim().split(":");
+        hhmm = hhmm.trim();
+        if (hhmm.contains(" ")) {
+            hhmm = hhmm.substring(hhmm.indexOf(" ") + 1).trim();
+        }
+        String[] parts = hhmm.split(":");
         if (parts.length < 2) {
             return -1;
         }
@@ -493,20 +538,26 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
      */
     private int[] getBookingRangeMinutes(Integer storeId) {
         int[] range = new int[]{0, MINUTES_DAY_END};
+        // 先从 store_booking_settings 按 storeId 查设置,再用 settingsId 关联
         List<StoreBookingSettings> list = storeBookingSettingsService.list(
                 new LambdaQueryWrapper<StoreBookingSettings>().eq(StoreBookingSettings::getStoreId, storeId));
         if (!list.isEmpty()) {
             StoreBookingSettings settings = list.get(0);
-            if (settings.getBookingTimeType() != null && settings.getBookingTimeType() == 1) {
+            List<StoreBookingBusinessHours> businessHoursList = storeBookingBusinessHoursService.getListBySettingsId(settings.getId());
+            if (!businessHoursList.isEmpty()) {
+                StoreBookingBusinessHours businessHours = businessHoursList.get(0);
+
+            if (businessHours.getBookingTimeType() != null && businessHours.getBookingTimeType() == 1) {
                 return range;
             }
-            int start = timeToMinutes(settings.getBookingStartTime());
-            int end = timeToMinutes(settings.getBookingEndTime());
+            int start = timeToMinutes(businessHours.getStartTime());
+            int end = timeToMinutes(businessHours.getEndTime());
             if (start >= 0 && end > start) {
                 range[0] = start;
                 range[1] = end;
                 return range;
             }
+            }
         }
         // 预订开始/结束时间为空或无效时,取商户运营时间(营业时间)
         StoreMainInfoVo storeInfo = storeInfoService.getStoreInfo(storeId);
@@ -793,6 +844,21 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
         return list.stream().map(UserReservationTable::getTableId).collect(Collectors.toList());
     }
 
+    /** 将桌位ID列表转为桌号文案,如 "A01,A02",空或查不到为 "未知桌号" */
+    private String tableIdsToTableNumberString(List<Integer> tableIds) {
+        if (tableIds == null || tableIds.isEmpty()) {
+            return "未知桌号";
+        }
+        List<String> numbers = tableIds.stream()
+                .map(tableId -> {
+                    StoreBookingTable t = storeBookingTableService.getById(tableId);
+                    return t != null && t.getTableNumber() != null ? t.getTableNumber().trim() : null;
+                })
+                .filter(n -> n != null && !n.isEmpty())
+                .collect(Collectors.toList());
+        return numbers.isEmpty() ? "未知桌号" : String.join(",", numbers);
+    }
+
     /**
      * 保存预约与桌号关联:先物理删除该预约下原有关联再插入
      */
@@ -1111,10 +1177,116 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
                 .eq(UserReservationOrder::getOrderStatus, ORDER_STATUS_TO_USE)
                 .set(UserReservationOrder::getOrderStatus, ORDER_STATUS_EXPIRED)
                 .set(UserReservationOrder::getUpdatedTime, now));
+        // 3. 异步给商家发送订单过期提醒
+        for (Integer reservationId : toUpdateReservationIds) {
+            reservationNoticeAsyncService.sendExpiredReminderToStore(reservationId);
+        }
         log.info("预订未到店超时定时任务:更新 reservationIds={} 条为未到店超时/订单已过期", toUpdateReservationIds.size());
         return toUpdateReservationIds.size();
     }
 
+    @Override
+    public int sendArrivalReminderSms() {
+        List<Integer> orderIds = userReservationOrderMapper.listOrderIdsForArrivalReminder();
+        if (orderIds == null || orderIds.isEmpty()) {
+            return 0;
+        }
+        int smsSuccessCount = 0;
+        for (Integer orderId : orderIds) {
+            try {
+                String noticeKey = REDIS_KEY_ARRIVAL_NOTICE_PREFIX + orderId;
+                String smsKey = REDIS_KEY_ARRIVAL_SMS_PREFIX + orderId;
+                if (baseRedisService.hasKey(noticeKey) && baseRedisService.hasKey(smsKey)) {
+                    continue;
+                }
+                if (sendArrivalReminderSmsForOrder(orderId)) {
+                    smsSuccessCount++;
+                }
+            } catch (Exception e) {
+                log.error("到店提醒任务处理异常,orderId={}", orderId, e);
+            }
+        }
+        log.info("到店提醒定时任务结束,符合条件订单数={}, 短信成功数={}", orderIds.size(), smsSuccessCount);
+        return smsSuccessCount;
+    }
+
+    /**
+     * 单订单到店提醒:先写站内通知(与短信是否成功无关,各自 Redis 防重),再发短信。
+     *
+     * @return 本次是否短信发送成功(用于统计)
+     */
+    @Override
+    public boolean sendArrivalReminderSmsForOrder(Integer orderId) {
+        if (orderId == null) {
+            return false;
+        }
+        UserReservationOrder order = userReservationOrderService.getById(orderId);
+        if (order == null || order.getReservationId() == null) {
+            return false;
+        }
+        UserReservation reservation = this.getById(order.getReservationId());
+        if (reservation == null) {
+            return false;
+        }
+        if (order.getOrderStatus() == null || order.getOrderStatus() != ORDER_STATUS_TO_USE) {
+            return false;
+        }
+        String phone = reservation.getReservationUserPhone();
+        if (phone == null || phone.trim().isEmpty()) {
+            log.warn("到店提醒跳过:预约人电话为空,orderId={}", orderId);
+            return false;
+        }
+
+        String startTimeForSms = reservation.getStartTime() != null ? reservation.getStartTime().trim() : "";
+        if (startTimeForSms.isEmpty()) {
+            startTimeForSms = "未知时间";
+        }
+        StoreInfo storeInfo = storeInfoService.getById(reservation.getStoreId());
+        String storeName = storeInfo != null && storeInfo.getStoreName() != null ? storeInfo.getStoreName() : "未知店铺";
+        String tableNumbers = resolveReservationTableNumbers(reservation.getId());
+
+        String noticeKey = REDIS_KEY_ARRIVAL_NOTICE_PREFIX + orderId;
+        if (!baseRedisService.hasKey(noticeKey)) {
+            boolean noticeOk = arrivalReminderNoticeService.sendArrivalReminderNotice(
+                    phone, orderId, order.getId(), reservation.getId(),
+                    reservation.getStartTime(), storeName, tableNumbers);
+            if (noticeOk) {
+                baseRedisService.setString(noticeKey, "1", Long.valueOf(ARRIVAL_REMINDER_REDIS_TTL_SECONDS));
+            }
+        }
+
+        String smsKey = REDIS_KEY_ARRIVAL_SMS_PREFIX + orderId;
+        if (baseRedisService.hasKey(smsKey)) {
+            return false;
+        }
+        Integer smsResult = aliSms.sendArrivalReminderSms(phone, startTimeForSms, storeName, tableNumbers);
+        if (smsResult != null && smsResult == 1) {
+            log.info("到店提醒短信发送成功,orderId={}, orderSn={}, phone={}", orderId, order.getOrderSn(), phone);
+            baseRedisService.setString(smsKey, "1", Long.valueOf(ARRIVAL_REMINDER_REDIS_TTL_SECONDS));
+            return true;
+        }
+        return false;
+    }
+
+    private String resolveReservationTableNumbers(Integer reservationId) {
+        LambdaQueryWrapper<UserReservationTable> w = new LambdaQueryWrapper<>();
+        w.eq(UserReservationTable::getReservationId, reservationId)
+                .eq(UserReservationTable::getDeleteFlag, 0)
+                .orderByAsc(UserReservationTable::getSort);
+        List<UserReservationTable> links = userReservationTableMapper.selectList(w);
+        if (links == null || links.isEmpty()) {
+            return "未知桌号";
+        }
+        List<String> nums = links.stream()
+                .map(rt -> {
+                    StoreBookingTable t = storeBookingTableService.getById(rt.getTableId());
+                    return t != null && t.getTableNumber() != null ? t.getTableNumber().trim() : null;
+                })
+                .filter(n -> n != null && !n.isEmpty())
+                .collect(Collectors.toList());
+        return nums.isEmpty() ? "未知桌号" : String.join(",", nums);
+    }
+
     /** 将页面 VO 字段复制到详情 VO,供前端订单详情页与 page 接口一致展示 */
     private void copyPageVoToDetailVo(ReservationOrderPageVo page, ReservationOrderDetailVo detail) {
         detail.setOrderId(page.getOrderId());

+ 56 - 3
alien-store/src/main/java/shop/alien/store/strategy/merchantPayment/impl/MerchantAlipayPaymentStrategyImpl.java

@@ -62,11 +62,16 @@ public class MerchantAlipayPaymentStrategyImpl implements MerchantPaymentStrateg
     private static final String REDIS_PREPAY_DEBOUNCE_KEY_PREFIX = "merchant:alipay:prepay:debounce:order:";
     /** 防抖有效期(秒),期内重复调用返回请勿重复提交 */
     private static final long REDIS_PREPAY_DEBOUNCE_SECONDS = 5;
+    /** 支付状态:退款中 */
+    private static final int PAY_STATUS_REFUNDING = 4;
+    /** 退款记录状态:退款失败 */
+    private static final String REFUND_STATUS_FAIL = "FAIL";
 
     private final StorePaymentConfigService storePaymentConfigService;
     private final UserReservationOrderService userReservationOrderService;
     private final MerchantPaymentOrderService merchantPaymentOrderService;
     private final RefundRecordAsyncService refundRecordAsyncService;
+    private final RefundRecordService refundRecordService;
     private final ReservationOrderPaymentTimeoutService reservationOrderPaymentTimeoutService;
     private final UserReservationService userReservationService;
     private final StringRedisTemplate stringRedisTemplate;
@@ -324,7 +329,9 @@ public class MerchantAlipayPaymentStrategyImpl implements MerchantPaymentStrateg
             request.setBizModel(model);
             AlipayTradeRefundResponse response = client.certificateExecute(request);
             if (!response.isSuccess()) {
-                return R.fail("退款失败:" + response.getSubMsg());
+                String failMsg = "退款失败:" + response.getSubMsg();
+                markRefundFailed(order, paymentOrder, failMsg, refundReason, storeId, outTradeNo);
+                return R.fail(failMsg);
             }
             JSONObject responseBody = JSONObject.parseObject(response.getBody());
             JSONObject refundResponse = responseBody != null ? responseBody.getJSONObject("alipay_trade_refund_response") : null;
@@ -344,6 +351,13 @@ public class MerchantAlipayPaymentStrategyImpl implements MerchantPaymentStrateg
             order.setRefundType(refundType);
             userReservationOrderService.updateById(order);
 
+            //修改用户预约信息表状态
+            UserReservation reservation = userReservationService.getById(order.getReservationId());
+            if (reservation != null) {
+                reservation.setStatus(3);
+                userReservationService.updateById(reservation);
+            }
+
             RefundRecord record = new RefundRecord();
             record.setPayType(PaymentEnum.ALIPAY.getType());
             record.setOutTradeNo(outTradeNo);
@@ -365,10 +379,49 @@ public class MerchantAlipayPaymentStrategyImpl implements MerchantPaymentStrateg
             return R.data("退款成功");
         } catch (AlipayApiException e) {
             log.error("商户预订订单退款异常,outTradeNo={}", outTradeNo, e);
-            return R.fail("退款失败:" + e.getErrMsg());
+            String errMsg = "退款失败:" + e.getErrMsg();
+            MerchantPaymentOrder payOrder = merchantPaymentOrderService.getByOutTradeNo(outTradeNo);
+            UserReservationOrder resOrder = payOrder != null ? userReservationOrderService.getById(payOrder.getOrderId()) : null;
+            if (resOrder != null && payOrder != null) {
+                markRefundFailed(resOrder, payOrder, errMsg, refundReason, storeId, outTradeNo);
+            }
+            return R.fail(errMsg);
         } catch (Exception e) {
             log.error("构建支付宝配置异常,storeId={}", storeId, e);
-            return R.fail("退款失败:" + e.getMessage());
+            String errMsg = "退款失败:" + e.getMessage();
+            MerchantPaymentOrder payOrder = merchantPaymentOrderService.getByOutTradeNo(outTradeNo);
+            UserReservationOrder resOrder = payOrder != null ? userReservationOrderService.getById(payOrder.getOrderId()) : null;
+            if (resOrder != null && payOrder != null) {
+                markRefundFailed(resOrder, payOrder, errMsg, refundReason, storeId, outTradeNo);
+            }
+            return R.fail(errMsg);
+        }
+    }
+
+    /** 退款失败时:订单与支付单置为退款中,并写入退款失败记录 */
+    private void markRefundFailed(UserReservationOrder order, MerchantPaymentOrder paymentOrder,
+                                  String errorMsg, String refundReason, Integer storeId, String outTradeNo) {
+        try {
+            Date now = new Date();
+            order.setPaymentStatus(PAY_STATUS_REFUNDING);
+            order.setUpdatedTime(now);
+            userReservationOrderService.updateById(order);
+            paymentOrder.setPayStatus(PAY_STATUS_REFUNDING);
+            paymentOrder.setUpdatedTime(now);
+            merchantPaymentOrderService.updateById(paymentOrder);
+            RefundRecord record = new RefundRecord();
+            record.setOutTradeNo(outTradeNo);
+            record.setPayType(PaymentEnum.ALIPAY.getType());
+            record.setRefundStatus(REFUND_STATUS_FAIL);
+            record.setOrderId(String.valueOf(order.getId()));
+            record.setStoreId(storeId);
+            record.setUserId(order.getUserId());
+            record.setRefundReason(StringUtils.isNotBlank(refundReason) ? refundReason : "退款失败");
+            record.setErrorMsg(errorMsg != null ? errorMsg : "退款失败");
+            record.setDeleteFlag(0);
+            refundRecordService.save(record);
+        } catch (Exception ex) {
+            log.error("标记退款失败状态异常,outTradeNo={}", outTradeNo, ex);
         }
     }
 

+ 58 - 4
alien-store/src/main/java/shop/alien/store/strategy/merchantPayment/impl/MerchantWechatPaymentStrategyImpl.java

@@ -16,6 +16,7 @@ import shop.alien.entity.store.UserReservation;
 import shop.alien.entity.store.UserReservationOrder;
 import shop.alien.store.service.MerchantPaymentOrderService;
 import shop.alien.store.service.RefundRecordAsyncService;
+import shop.alien.store.service.RefundRecordService;
 import shop.alien.store.service.StorePaymentConfigService;
 import shop.alien.store.service.ReservationOrderPaymentTimeoutService;
 import shop.alien.store.service.UserReservationOrderService;
@@ -53,6 +54,10 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
     private static final String REDIS_PREPAY_DEBOUNCE_KEY_PREFIX = "merchant:wechat:prepay:debounce:order:";
     /** 防抖有效期(秒),期内重复调用返回请勿重复提交 */
     private static final long REDIS_PREPAY_DEBOUNCE_SECONDS = 5;
+    /** 支付状态:退款中 */
+    private static final int PAY_STATUS_REFUNDING = 4;
+    /** 退款记录状态:退款失败 */
+    private static final String REFUND_STATUS_FAIL = "FAIL";
 
     @Value("${payment.wechatPay.host:https://api.mch.weixin.qq.com}")
     private String wechatPayApiHost;
@@ -71,6 +76,7 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
     private final UserReservationOrderService userReservationOrderService;
     private final MerchantPaymentOrderService merchantPaymentOrderService;
     private final RefundRecordAsyncService refundRecordAsyncService;
+    private final RefundRecordService refundRecordService;
     private final ReservationOrderPaymentTimeoutService reservationOrderPaymentTimeoutService;
     private final UserReservationService userReservationService;
     private final StringRedisTemplate stringRedisTemplate;
@@ -346,6 +352,7 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
                             Thread.sleep(delayMs);
                         } catch (InterruptedException ie) {
                             Thread.currentThread().interrupt();
+                            markRefundFailed(order, paymentOrder, "操作过于频繁,请稍后再试", refundReason, storeId, outTradeNo);
                             return R.fail("操作过于频繁,请稍后再试");
                         }
                     } else {
@@ -360,11 +367,14 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
                 String msg = lastApiException != null && lastApiException.getStatusCode() == 429
                         ? (StringUtils.isNotBlank(lastApiException.getErrorMessage()) ? lastApiException.getErrorMessage() : "操作过于频繁,请稍后再试")
                         : "退款失败";
+                markRefundFailed(order, paymentOrder, msg, refundReason, storeId, outTradeNo);
                 return R.fail(msg);
             }
             String status = response.status != null ? response.status.name() : "";
             if (!"SUCCESS".equals(status) && !"PROCESSING".equals(status)) {
-                return R.fail("退款失败:" + status);
+                String failMsg = "退款失败:" + status;
+                markRefundFailed(order, paymentOrder, failMsg, refundReason, storeId, outTradeNo);
+                return R.fail(failMsg);
             }
 
             Date now = new Date();
@@ -381,6 +391,13 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
             order.setRefundType(refundType);
             userReservationOrderService.updateById(order);
 
+            //修改用户预约信息表状态
+            UserReservation reservation = userReservationService.getById(order.getReservationId());
+            if (reservation != null) {
+                reservation.setStatus(3);
+                userReservationService.updateById(reservation);
+            }
+
             RefundRecord record = new RefundRecord();
             record.setPayType(PaymentEnum.WECHAT_PAY.getType());
             record.setOutTradeNo(outTradeNo);
@@ -403,16 +420,53 @@ public class MerchantWechatPaymentStrategyImpl implements MerchantPaymentStrateg
             return R.data("退款成功");
         } catch (WXPayUtility.ApiException e) {
             log.error("商户预订订单微信退款异常,outTradeNo={}", outTradeNo, e);
-            if (e.getStatusCode() == 429) {
-                return R.fail(StringUtils.isNotBlank(e.getErrorMessage()) ? e.getErrorMessage() : "操作过于频繁,请稍后再试");
+            String errMsg = e.getStatusCode() == 429
+                    ? (StringUtils.isNotBlank(e.getErrorMessage()) ? e.getErrorMessage() : "操作过于频繁,请稍后再试")
+                    : "退款失败:" + e.getMessage();
+            MerchantPaymentOrder payOrder = merchantPaymentOrderService.getByOutTradeNo(outTradeNo);
+            UserReservationOrder resOrder = payOrder != null ? userReservationOrderService.getById(payOrder.getOrderId()) : null;
+            if (resOrder != null && payOrder != null) {
+                markRefundFailed(resOrder, payOrder, errMsg, refundReason, storeId, outTradeNo);
             }
-            return R.fail("退款失败:" + e.getMessage());
+            return R.fail(errMsg);
         } catch (Exception e) {
             log.error("商户微信退款异常,storeId={}", storeId, e);
+            MerchantPaymentOrder payOrder = merchantPaymentOrderService.getByOutTradeNo(outTradeNo);
+            UserReservationOrder resOrder = payOrder != null ? userReservationOrderService.getById(payOrder.getOrderId()) : null;
+            if (resOrder != null && payOrder != null) {
+                markRefundFailed(resOrder, payOrder, "退款失败:" + e.getMessage(), refundReason, storeId, outTradeNo);
+            }
             return R.fail("退款失败:" + e.getMessage());
         }
     }
 
+    /** 退款失败时:订单与支付单置为退款中,并写入退款失败记录 */
+    private void markRefundFailed(UserReservationOrder order, MerchantPaymentOrder paymentOrder,
+                                  String errorMsg, String refundReason, Integer storeId, String outTradeNo) {
+        try {
+            Date now = new Date();
+            order.setPaymentStatus(PAY_STATUS_REFUNDING);
+            order.setUpdatedTime(now);
+            userReservationOrderService.updateById(order);
+            paymentOrder.setPayStatus(PAY_STATUS_REFUNDING);
+            paymentOrder.setUpdatedTime(now);
+            merchantPaymentOrderService.updateById(paymentOrder);
+            RefundRecord record = new RefundRecord();
+            record.setOutTradeNo(outTradeNo);
+            record.setPayType(PaymentEnum.WECHAT_PAY.getType());
+            record.setRefundStatus(REFUND_STATUS_FAIL);
+            record.setOrderId(String.valueOf(order.getId()));
+            record.setStoreId(storeId);
+            record.setUserId(order.getUserId());
+            record.setRefundReason(StringUtils.isNotBlank(refundReason) ? refundReason : "退款失败");
+            record.setErrorMsg(errorMsg != null ? errorMsg : "退款失败");
+            record.setDeleteFlag(0);
+            refundRecordService.save(record);
+        } catch (Exception ex) {
+            log.error("标记退款失败状态异常,outTradeNo={}", outTradeNo, ex);
+        }
+    }
+
     @Override
     public String getType() {
         return PaymentEnum.WECHAT_PAY.getType();

+ 79 - 0
alien-store/src/main/java/shop/alien/store/util/ali/AliSms.java

@@ -66,6 +66,10 @@ public class AliSms {
     @Value("${ali.sms.templatereRefundCode:}")
     private String templatereRefundCode;
 
+    /** 到店提醒短信模板(内容:您在${time}预订了${storeName}${tableNumber}的桌位,请您及时到店) */
+    @Value("${ali.sms.templateArrivalReminderCode:}")
+    private String templateArrivalReminderCode;
+
 
 
 
@@ -545,4 +549,79 @@ public class AliSms {
         }
     }
 
+    /**
+     * 发送到店提醒短信
+     * 名称:到店提醒。内容:您在${time}预订了${storeName}${tableNumber}的桌位,请您及时到店
+     *
+     * @param phone       用户手机号
+     * @param timeStr     预订开始时间(如 14:00)
+     * @param storeName   店铺名称
+     * @param tableNumber 桌号或名称(如 A01)
+     * @return 1-发送成功, null-发送失败
+     */
+    public Integer sendArrivalReminderSms(String phone, String timeStr, String storeName, String tableNumber) {
+        log.info("AliSms.sendArrivalReminderSms?phone={}, timeStr={}, storeName={}, tableNumber={}",
+                phone, timeStr, storeName, tableNumber);
+        try {
+            if (phone == null || phone.trim().isEmpty()) {
+                log.warn("发送到店提醒短信失败:手机号不能为空");
+                return null;
+            }
+            if (timeStr == null || timeStr.trim().isEmpty()) {
+                log.warn("发送到店提醒短信失败:预订时间不能为空");
+                return null;
+            }
+            if (storeName == null) {
+                storeName = "";
+            }
+            if (tableNumber == null) {
+                tableNumber = "";
+            }
+
+            String templateCodeArrival = templateArrivalReminderCode;
+            if (templateCodeArrival == null || templateCodeArrival.trim().isEmpty()) {
+                log.warn("发送到店提醒短信失败:模板代码未配置 ali.sms.templateArrivalReminderCode");
+                return null;
+            }
+
+            String templateParam = String.format("{\"time\":\"%s\",\"storeName\":\"%s\",\"tableNumber\":\"%s\"}",
+                    escapeJsonString(timeStr),
+                    escapeJsonString(storeName),
+                    escapeJsonString(tableNumber));
+
+            SendSmsRequest sendSmsRequest = new SendSmsRequest()
+                    .setSignName(signName)
+                    .setTemplateCode(templateCodeArrival)
+                    .setPhoneNumbers(phone)
+                    .setTemplateParam(templateParam);
+
+            Config config = new Config()
+                    .setEndpoint(endPoint)
+                    .setAccessKeyId(accessKeyId)
+                    .setAccessKeySecret(accessKeySecret);
+            RuntimeOptions runtime = new RuntimeOptions();
+            Client client = new Client(config);
+            SendSmsResponse sendSmsResponse = client.sendSmsWithOptions(sendSmsRequest, runtime);
+
+            String responseCode = sendSmsResponse.getBody().getCode();
+            String responseMessage = sendSmsResponse.getBody().getMessage();
+            String bizId = sendSmsResponse.getBody().getBizId();
+
+            log.info("AliSms.sendArrivalReminderSms API响应,phone={}, responseCode={}, responseMessage={}, bizId={}",
+                    phone, responseCode, responseMessage, bizId);
+
+            if (!"OK".equals(responseCode)) {
+                log.error("AliSms.sendArrivalReminderSms 短信发送失败,phone={}, templateParam={}, responseCode={}, responseMessage={}",
+                        phone, templateParam, responseCode, responseMessage);
+                return null;
+            }
+            log.info("AliSms.sendArrivalReminderSms 短信发送成功,phone={}, bizId={}", phone, bizId);
+            return 1;
+        } catch (Exception e) {
+            log.error("AliSms.sendArrivalReminderSms ERROR phone={}, timeStr={}, storeName={}, tableNumber={}, Msg={}",
+                    phone, timeStr, storeName, tableNumber, e.getMessage(), e);
+            return null;
+        }
+    }
+
 }

+ 38 - 20
alien-store/src/main/resources/logback-spring.xml

@@ -4,10 +4,11 @@
 
     <!-- 定义全局参数常量 -->
     <property name="log.level" value="debug"/>
-    <property name="log.maxHistory" value="30"/><!-- 30表示30天 -->
+    <property name="log.maxHistory" value="30"/><!-- 30表示保留30个归档 -->
     <springProperty scope="context" name="logging.path" source="logging.path" defaultValue="C:/project/ext/log"/>
-    <!--输出文件前缀-->
-    <property name="FILENAME" value="alien"/>
+    <!--输出文件前缀;各服务日志写入各自子目录 logging.path/FILENAME/ -->
+    <property name="FILENAME" value="alien-store"/>
+    <property name="LOG_DIR" value="${logging.path}/${FILENAME}"/>
 
     <!-- 文件输出格式 -->
     <property name="FILE_LOG_PATTERN" value="[%d{MM/dd HH:mm:ss.SSS}][%-10.10thread][%-5level][%-40.40c{1}:%5line]:[%15method] || %m%n"/>
@@ -28,15 +29,19 @@
     </appender>
 
     <!--2. 输出到文档-->
-    <!-- DEBUG 日志 -->
+    <!-- DEBUG 日志:按 1MB 大小滚动,满 1MB 自动切到下一个文件 -->
     <appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/DEBUG.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_DEBUG.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/DEBUG.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_DEBUG.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>DEBUG</level>
@@ -45,15 +50,19 @@
         </filter>
     </appender>
 
-    <!-- INFO 日志 -->
+    <!-- INFO 日志:按 1MB 大小滚动 -->
     <appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/INFO.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_INFO.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/INFO.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_INFO.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>INFO</level>
@@ -62,15 +71,19 @@
         </filter>
     </appender>
 
-    <!-- WARN 日志 -->
+    <!-- WARN 日志:按 1MB 大小滚动 -->
     <appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/WARN.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_WARN.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/WARN.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_WARN.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>WARN</level>
@@ -79,14 +92,19 @@
         </filter>
     </appender>
 
+    <!-- ERROR 日志:按 1MB 大小滚动 -->
     <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
-        <file>${logging.path}/ERROR.log</file>
-        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>${logging.path}/%d{yyyy-MM-dd}_${FILENAME}_ERROR.log.gz</fileNamePattern>
+        <file>${LOG_DIR}/ERROR.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+            <fileNamePattern>${LOG_DIR}/%d{yyyy-MM-dd}_ERROR.%i.log.gz</fileNamePattern>
+            <maxFileSize>1MB</maxFileSize>
             <maxHistory>${log.maxHistory}</maxHistory>
+            <totalSizeCap>500MB</totalSizeCap>
+            <cleanHistoryOnStart>true</cleanHistoryOnStart>
         </rollingPolicy>
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>${FILE_LOG_PATTERN}</pattern>
+            <charset>UTF-8</charset>
         </encoder>
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
             <level>ERROR</level>
@@ -97,8 +115,8 @@
 
     <!-- 降噪配置 -->
     <logger name="springfox.documentation.spring.web.readers.operation.CachingOperationNameGenerator" level="WARN"/>
-    <logger name="org.springframework.security.web.DefaultSecurityFilterChain " level="WARN"/>
-    <logger name="com.netflix.config.sources.URLConfigurationSource " level="WARN"/>
+    <logger name="org.springframework.security.web.DefaultSecurityFilterChain" level="WARN"/>
+    <logger name="com.netflix.config.sources.URLConfigurationSource" level="WARN"/>
     <logger name="com.netflix.discovery" level="WARN"/>
     <logger name="org.apache.catalina.core.ContainerBase.[Tomcat].[localhost].[/]" level="WARN"/>
 

+ 5 - 6
store_order_change_log.sql

@@ -1,5 +1,5 @@
 -- =============================================
--- 订单变更记录表(记录每次下单/加餐的商品变化)
+-- 订单变更记录表(记录每次下单/更新订单的商品变化)
 -- 用于展示每次操作都加了什么商品
 -- =============================================
 
@@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS `store_order_change_log` (
   `order_id` int(11) NOT NULL COMMENT '订单ID',
   `order_no` varchar(64) NOT NULL COMMENT '订单号',
   `batch_no` varchar(64) NOT NULL COMMENT '批次号(同一时间点的操作使用同一批次号,用于分组展示)',
-  `operation_type` tinyint(4) NOT NULL DEFAULT '1' COMMENT '操作类型(1:首次下单, 2:加餐, 3:更新订单)',
+  `operation_type` tinyint(4) NOT NULL DEFAULT '1' COMMENT '操作类型(1:首次下单, 3:更新订单)',
   `cuisine_id` int(11) NOT NULL COMMENT '菜品ID',
   `cuisine_name` varchar(200) NOT NULL COMMENT '菜品名称',
   `cuisine_type` tinyint(4) DEFAULT NULL COMMENT '菜品类型(1:单品, 2:套餐)',
@@ -37,7 +37,7 @@ CREATE TABLE IF NOT EXISTS `store_order_change_log` (
   KEY `idx_operator_user_id` (`operator_user_id`),
   KEY `idx_order_batch` (`order_id`, `batch_no`),
   KEY `idx_order_time` (`order_id`, `operation_time`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单变更记录表(记录每次下单/加餐的商品变化)';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单变更记录表(记录每次下单/更新订单的商品变化)';
 
 -- =============================================
 -- 表结构说明:
@@ -49,8 +49,7 @@ CREATE TABLE IF NOT EXISTS `store_order_change_log` (
 -- 
 -- 2. operation_type(操作类型):
 --    - 1: 首次下单(创建订单时)
---    - 2: 加餐(通过加餐接口添加)
---    - 3: 更新订单(更新订单时重新下单)
+--    - 3: 更新订单(更新订单时重新下单,只记录新增或数量增加的商品)
 -- 
 -- 3. quantity_change(数量变化):
 --    - 正数:新增的数量
@@ -60,7 +59,7 @@ CREATE TABLE IF NOT EXISTS `store_order_change_log` (
 -- 4. quantity_before / quantity_after(变化前后数量):
 --    - 用于展示变化前后对比
 --    - 首次下单时,quantity_before = 0, quantity_after = quantity_change
---    - 加餐时,quantity_before = 原数量, quantity_after = 原数量 + quantity_change
+--    - 更新订单时,quantity_before = 原数量, quantity_after = 当前数量
 -- 
 -- 5. 使用场景:
 --    - 查询某个订单的所有变更记录:SELECT * FROM store_order_change_log WHERE order_id = ? ORDER BY operation_time