Эх сурвалжийг харах

添加线下预约逻辑 并且支付完成后 开启定时任务 20分钟后 标记预约为用餐结束

lutong 3 долоо хоног өмнө
parent
commit
bd09d54d93
27 өөрчлөгдсөн 532 нэмэгдсэн , 17 устгасан
  1. 21 0
      alien-dining/src/main/java/shop/alien/dining/controller/DiningController.java
  2. 14 0
      alien-dining/src/main/java/shop/alien/dining/service/DiningWalkInReservationService.java
  3. 101 0
      alien-dining/src/main/java/shop/alien/dining/service/impl/DiningWalkInReservationServiceImpl.java
  4. 39 0
      alien-dining/src/main/java/shop/alien/dining/service/impl/StoreOrderServiceImpl.java
  5. 4 0
      alien-entity/src/main/java/shop/alien/entity/store/StoreOrder.java
  6. 1 1
      alien-entity/src/main/java/shop/alien/entity/store/UserReservation.java
  7. 3 0
      alien-entity/src/main/java/shop/alien/entity/store/dto/CreateOrderDTO.java
  8. 54 0
      alien-entity/src/main/java/shop/alien/entity/store/dto/DiningWalkInReservationDTO.java
  9. 1 1
      alien-entity/src/main/java/shop/alien/entity/store/dto/UserReservationDTO.java
  10. 3 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/OrderDetailWithChangeLogVO.java
  11. 3 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/OrderInfoVO.java
  12. 1 1
      alien-entity/src/main/java/shop/alien/entity/store/vo/StoreReservationListVo.java
  13. 1 1
      alien-entity/src/main/java/shop/alien/entity/store/vo/UserReservationVo.java
  14. 1 1
      alien-entity/src/main/java/shop/alien/mapper/StoreReservationMapper.java
  15. 1 1
      alien-entity/src/main/java/shop/alien/mapper/UserReservationMapper.java
  16. 1 0
      alien-entity/src/main/resources/mapper/StoreReservationMapper.xml
  17. 1 0
      alien-entity/src/main/resources/mapper/UserReservationMapper.xml
  18. 31 0
      alien-job/src/main/java/shop/alien/job/store/ReservationDiningFinishedDelayJob.java
  19. 158 0
      alien-job/src/main/java/shop/alien/job/store/ReservationDiningFinishedDelayService.java
  20. 1 1
      alien-job/src/main/java/shop/alien/job/store/ReservationTimeoutJob.java
  21. 25 5
      alien-store/src/main/java/shop/alien/store/controller/StoreReservationController.java
  22. 4 2
      alien-store/src/main/java/shop/alien/store/controller/dining/DiningCouponPathProxyController.java
  23. 2 0
      alien-store/src/main/java/shop/alien/store/feign/DiningServiceFeign.java
  24. 10 1
      alien-store/src/main/java/shop/alien/store/service/StoreReservationService.java
  25. 1 1
      alien-store/src/main/java/shop/alien/store/service/UserReservationService.java
  26. 38 0
      alien-store/src/main/java/shop/alien/store/service/impl/StoreReservationServiceImpl.java
  27. 12 1
      alien-store/src/main/java/shop/alien/store/service/impl/UserReservationServiceImpl.java

+ 21 - 0
alien-dining/src/main/java/shop/alien/dining/controller/DiningController.java

@@ -6,11 +6,14 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.util.StringUtils;
 import org.springframework.web.bind.annotation.*;
 import shop.alien.entity.result.R;
+import shop.alien.entity.store.dto.DiningWalkInReservationDTO;
 import shop.alien.entity.store.vo.TableDiningStatusVO;
 import shop.alien.entity.store.vo.*;
 import shop.alien.dining.service.DiningService;
+import shop.alien.dining.service.DiningWalkInReservationService;
 import shop.alien.dining.util.TokenUtil;
 
+import javax.validation.Valid;
 import java.util.List;
 
 /**
@@ -28,6 +31,24 @@ import java.util.List;
 public class DiningController {
 
     private final DiningService diningService;
+    private final DiningWalkInReservationService diningWalkInReservationService;
+
+    @ApiOperation(value = "提交到店就餐信息", notes = "选完人数并填写姓名/电话/时段后调用:写入 user_reservation,状态为已到店(2),并关联当前桌;返回预约ID,下单时 CreateOrderDTO.userReservationId 传入该值")
+    @PostMapping("/walk-in/reservation")
+    public R<Integer> createWalkInReservation(@Valid @RequestBody DiningWalkInReservationDTO dto) {
+        log.info("DiningController.createWalkInReservation?tableId={}", dto != null ? dto.getTableId() : null);
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            Integer id = diningWalkInReservationService.createWalkInReservation(dto, userId);
+            return R.data(id);
+        } catch (Exception e) {
+            log.error("提交到店就餐信息失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
 
     @ApiOperation(value = "查询餐桌是否处于就餐中", notes = "免登录可调用,用于前端判断是否跳过选择用餐人数。就餐中(status=1)、加餐(status=3) 均视为就餐状态,且 diner_count 有值 时 inDining=true")
     @GetMapping("/table-dining-status")

+ 14 - 0
alien-dining/src/main/java/shop/alien/dining/service/DiningWalkInReservationService.java

@@ -0,0 +1,14 @@
+package shop.alien.dining.service;
+
+import shop.alien.entity.store.dto.DiningWalkInReservationDTO;
+
+/**
+ * 点餐到店填写的预约(无预订订单,状态直接已到店)
+ */
+public interface DiningWalkInReservationService {
+
+    /**
+     * 创建预约并关联当前桌位,状态为已到店(2),返回 user_reservation.id 供下单绑定
+     */
+    Integer createWalkInReservation(DiningWalkInReservationDTO dto, Integer userId);
+}

+ 101 - 0
alien-dining/src/main/java/shop/alien/dining/service/impl/DiningWalkInReservationServiceImpl.java

@@ -0,0 +1,101 @@
+package shop.alien.dining.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+import shop.alien.dining.service.DiningWalkInReservationService;
+import shop.alien.entity.store.StoreTable;
+import shop.alien.entity.store.UserReservation;
+import shop.alien.entity.store.UserReservationTable;
+import shop.alien.entity.store.dto.DiningWalkInReservationDTO;
+import shop.alien.mapper.StoreTableMapper;
+import shop.alien.mapper.UserReservationMapper;
+import shop.alien.mapper.UserReservationTableMapper;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.Date;
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * 点餐流程:填写就餐信息 → 仅写 user_reservation + user_reservation_table,状态已到店,不产生 user_reservation_order
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class DiningWalkInReservationServiceImpl extends ServiceImpl<UserReservationMapper, UserReservation>
+        implements DiningWalkInReservationService {
+
+    private final StoreTableMapper storeTableMapper;
+    private final UserReservationTableMapper userReservationTableMapper;
+
+    private static final int STATUS_ARRIVED = 2;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Integer createWalkInReservation(DiningWalkInReservationDTO dto, Integer userId) {
+        if (userId == null) {
+            throw new RuntimeException("用户未登录");
+        }
+        StoreTable table = storeTableMapper.selectById(dto.getTableId());
+        if (table == null) {
+            throw new RuntimeException("桌号不存在");
+        }
+
+        Date reservationDate = dto.getReservationDate();
+        if (reservationDate == null) {
+            ZoneId shanghai = ZoneId.of("Asia/Shanghai");
+            reservationDate = Date.from(LocalDate.now(shanghai).atStartOfDay(shanghai).toInstant());
+        }
+
+        Date now = new Date();
+        UserReservation entity = new UserReservation();
+        entity.setReservationNo(generateReservationNo());
+        entity.setUserId(userId);
+        entity.setStoreId(table.getStoreId());
+        entity.setReservationDate(reservationDate);
+        entity.setStartTime(trimOrNull(dto.getStartTime()));
+        entity.setEndTime(trimOrNull(dto.getEndTime()));
+        entity.setGuestCount(dto.getGuestCount());
+        entity.setCategoryId(table.getCategoryId());
+        entity.setStatus(STATUS_ARRIVED);
+        entity.setActualArrivalTime(now);
+        entity.setRemark(trimOrNull(dto.getRemark()));
+        entity.setReservationUserName(trimOrNull(dto.getReservationUserName()));
+        entity.setReservationUserGender(dto.getReservationUserGender());
+        entity.setReservationUserPhone(trimOrNull(dto.getReservationUserPhone()));
+        entity.setCreatedUserId(userId);
+        entity.setUpdatedUserId(userId);
+        entity.setCreatedTime(now);
+        entity.setUpdatedTime(now);
+
+        this.save(entity);
+
+        UserReservationTable link = new UserReservationTable();
+        link.setReservationId(entity.getId());
+        link.setTableId(table.getId());
+        link.setSort(0);
+        link.setCreatedUserId(userId);
+        link.setUpdatedUserId(userId);
+        userReservationTableMapper.insert(link);
+
+        log.info("点餐到店预约已创建 reservationId={} tableId={} userId={} status=已到店",
+                entity.getId(), table.getId(), userId);
+        return entity.getId();
+    }
+
+    private static String trimOrNull(String s) {
+        if (!StringUtils.hasText(s)) {
+            return null;
+        }
+        String t = s.trim();
+        return t.isEmpty() ? null : t;
+    }
+
+    private static String generateReservationNo() {
+        return "RV" + System.currentTimeMillis() + ThreadLocalRandom.current().nextInt(1000, 9999);
+    }
+}

+ 39 - 0
alien-dining/src/main/java/shop/alien/dining/service/impl/StoreOrderServiceImpl.java

@@ -16,6 +16,8 @@ import shop.alien.dining.service.StoreOrderService;
 import shop.alien.dining.util.TokenUtil;
 import shop.alien.entity.store.*;
 import shop.alien.entity.store.dto.CartDTO;
+import shop.alien.mapper.UserReservationMapper;
+import shop.alien.mapper.UserReservationTableMapper;
 import shop.alien.entity.store.dto.CartItemDTO;
 import shop.alien.entity.store.dto.CreateOrderDTO;
 import shop.alien.entity.store.vo.OrderChangeLogBatchVO;
@@ -58,6 +60,8 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
     private final StoreOrderChangeLogMapper orderChangeLogMapper;
     private final shop.alien.dining.service.SseService sseService;
     private final shop.alien.dining.service.OrderLockService orderLockService;
+    private final UserReservationMapper userReservationMapper;
+    private final UserReservationTableMapper userReservationTableMapper;
 
     @Override
     public StoreOrder createOrder(CreateOrderDTO dto) {
@@ -208,6 +212,10 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                 order.setDiscountAmount(discountAmount);
                 order.setPayAmount(payAmount);
                 order.setRemark(dto.getRemark());
+                if (dto.getUserReservationId() != null) {
+                    validateUserReservationForOrder(dto.getUserReservationId(), table);
+                    order.setUserReservationId(dto.getUserReservationId());
+                }
                 order.setUpdatedUserId(userId);
                 order.setUpdatedTime(now);
                 this.updateById(order);
@@ -231,6 +239,10 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         
         // 如果没有订单需要更新,创建新订单
         if (!isUpdate) {
+            if (dto.getUserReservationId() == null) {
+                throw new RuntimeException("请先完成就餐信息登记后再下单(需传入预约ID)");
+            }
+            validateUserReservationForOrder(dto.getUserReservationId(), table);
             // 生成订单号
             orderNo = generateOrderNo();
             
@@ -257,6 +269,7 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             // payType在支付时设置
             order.setPayStatus(0); // 未支付
             order.setRemark(dto.getRemark());
+            order.setUserReservationId(dto.getUserReservationId());
             order.setCreatedUserId(userId);
             order.setUpdatedUserId(userId);
             // 手动设置创建时间和更新时间(临时方案,避免自动填充未生效导致 created_time 为 null)
@@ -1471,6 +1484,7 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         vo.setStoreType(storeType);
         vo.setBusinessStatus(businessStatus);
         vo.setTableNumber(order.getTableNumber());
+        vo.setUserReservationId(order.getUserReservationId());
         vo.setDinerCount(order.getDinerCount());
         vo.setContactPhone(order.getContactPhone());
         vo.setRemark(order.getRemark());
@@ -1625,6 +1639,7 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         vo.setOrderNo(order.getOrderNo());
         vo.setStoreName(storeName);
         vo.setTableNumber(order.getTableNumber());
+        vo.setUserReservationId(order.getUserReservationId());
         vo.setDinerCount(order.getDinerCount());
         vo.setContactPhone(order.getContactPhone());
         vo.setRemark(order.getRemark());
@@ -1767,6 +1782,30 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
     }
 
     /**
+     * 校验预约存在、归属门店一致,且当前桌在该预约占用桌位中;不限制预约状态(延迟任务仅在已到店时更新)。
+     */
+    private void validateUserReservationForOrder(Integer reservationId, StoreTable table) {
+        if (reservationId == null || table == null) {
+            return;
+        }
+        UserReservation r = userReservationMapper.selectById(reservationId);
+        if (r == null) {
+            throw new RuntimeException("预约不存在");
+        }
+        if (r.getStoreId() == null || !r.getStoreId().equals(table.getStoreId())) {
+            throw new RuntimeException("预约与门店不匹配");
+        }
+        long cnt = userReservationTableMapper.selectCount(
+                new LambdaQueryWrapper<UserReservationTable>()
+                        .eq(UserReservationTable::getReservationId, reservationId)
+                        .eq(UserReservationTable::getTableId, table.getId())
+                        .eq(UserReservationTable::getDeleteFlag, 0));
+        if (cnt == 0) {
+            throw new RuntimeException("当前桌位不在该预约占用桌位中");
+        }
+    }
+
+    /**
      * 生成订单号
      */
     private String generateOrderNo() {

+ 4 - 0
alien-entity/src/main/java/shop/alien/entity/store/StoreOrder.java

@@ -38,6 +38,10 @@ public class StoreOrder {
     @TableField("table_id")
     private Integer tableId;
 
+    @ApiModelProperty(value = "关联用户预约ID(user_reservation.id,散客订单为空)")
+    @TableField("user_reservation_id")
+    private Integer userReservationId;
+
     @ApiModelProperty(value = "桌号")
     @TableField("table_number")
     private String tableNumber;

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

@@ -57,7 +57,7 @@ public class UserReservation {
     @TableField("category_id")
     private Integer categoryId;
 
-    @ApiModelProperty(value = "预约状态 0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时")
+    @ApiModelProperty(value = "预约状态 0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时 5:用餐结束")
     @TableField("status")
     private Integer status;
 

+ 3 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/CreateOrderDTO.java

@@ -20,6 +20,9 @@ public class CreateOrderDTO {
     @NotNull(message = "桌号ID不能为空")
     private Integer tableId;
 
+    @ApiModelProperty(value = "关联用户预约ID(user_reservation.id,从预订到店扫码等场景传入;散客不传)")
+    private Integer userReservationId;
+
     @ApiModelProperty(value = "就餐人数")
     private Integer dinerCount;
 

+ 54 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/DiningWalkInReservationDTO.java

@@ -0,0 +1,54 @@
+package shop.alien.entity.store.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.Min;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
+import java.util.Date;
+
+/**
+ * 点餐场景:选完人数后填写「就餐信息」,落到 user_reservation(状态已到店),不与 user_reservation_order 关联。
+ */
+@Data
+@ApiModel(value = "DiningWalkInReservationDTO", description = "点餐-到店就餐信息(写入预约表)")
+public class DiningWalkInReservationDTO {
+
+    @ApiModelProperty(value = "桌号ID(store_table.id)", required = true)
+    @NotNull(message = "桌号ID不能为空")
+    private Integer tableId;
+
+    @ApiModelProperty(value = "就餐/预约人数", required = true)
+    @NotNull(message = "人数不能为空")
+    @Min(value = 1, message = "人数至少为1")
+    private Integer guestCount;
+
+    @ApiModelProperty(value = "预约日期,不传则默认当天(上海时区)")
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    private Date reservationDate;
+
+    @ApiModelProperty(value = "开始时间 HH:mm", required = true)
+    @NotBlank(message = "开始时间不能为空")
+    private String startTime;
+
+    @ApiModelProperty(value = "结束时间 HH:mm,可次日,选填")
+    private String endTime;
+
+    @ApiModelProperty(value = "就餐人姓名")
+    private String reservationUserName;
+
+    @ApiModelProperty(value = "性别 0:男 1:女")
+    private String reservationUserGender;
+
+    @ApiModelProperty(value = "手机号", required = true)
+    @NotBlank(message = "手机号不能为空")
+    private String reservationUserPhone;
+
+    @ApiModelProperty(value = "备注,限30字")
+    @Size(max = 30, message = "备注不超过30字")
+    private String remark;
+}

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

@@ -42,7 +42,7 @@ public class UserReservationDTO {
     @ApiModelProperty(value = "偏好区域/分类ID(如大厅/包间)")
     private Integer categoryId;
 
-    @ApiModelProperty(value = "预约状态 0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时")
+    @ApiModelProperty(value = "预约状态 0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时 5:用餐结束")
     private Integer status;
 
     @ApiModelProperty(value = "备注")

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

@@ -30,6 +30,9 @@ public class OrderDetailWithChangeLogVO {
     @ApiModelProperty(value = "桌号")
     private String tableNumber;
 
+    @ApiModelProperty(value = "关联用户预约ID(user_reservation.id)")
+    private Integer userReservationId;
+
     @ApiModelProperty(value = "就餐人数")
     private Integer dinerCount;
 

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

@@ -49,6 +49,9 @@ public class OrderInfoVO {
     @ApiModelProperty(value = "桌号")
     private String tableNumber;
 
+    @ApiModelProperty(value = "关联用户预约ID(user_reservation.id)")
+    private Integer userReservationId;
+
     @ApiModelProperty(value = "就餐人数")
     private Integer dinerCount;
 

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

@@ -67,7 +67,7 @@ public class StoreReservationListVo {
     @ApiModelProperty(value = "取消原因(商家端取消)")
     private String reason;
 
-    @ApiModelProperty(value = "预约状态 0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时")
+    @ApiModelProperty(value = "预约状态 0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时 5:用餐结束")
     private Integer status;
 
     @ApiModelProperty(value = "状态文本(待使用、已完成、退款等)")

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

@@ -45,7 +45,7 @@ public class UserReservationVo {
     @ApiModelProperty(value = "偏好区域/分类ID")
     private Integer categoryId;
 
-    @ApiModelProperty(value = "预约状态 0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时")
+    @ApiModelProperty(value = "预约状态 0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时 5:用餐结束")
     private Integer status;
 
     @ApiModelProperty(value = "实际到店时间")

+ 1 - 1
alien-entity/src/main/java/shop/alien/mapper/StoreReservationMapper.java

@@ -20,7 +20,7 @@ public interface StoreReservationMapper extends BaseMapper<UserReservation> {
      * 查询商家端预约信息列表
      *
      * @param storeId    门店ID(必填)
-     * @param status     预约状态(可选,0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时)
+     * @param status     预约状态(可选,0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时 5:用餐结束
      * @param dateFrom   预约日期起(可选)
      * @param dateTo     预约日期止(可选)
      * @param orderStatus 订单状态(可选,0:待支付 1:待使用 2:已完成 3:已过期 4:已取消 5:已关闭 6:退款中 7:已退款 8:商家预订)

+ 1 - 1
alien-entity/src/main/java/shop/alien/mapper/UserReservationMapper.java

@@ -19,7 +19,7 @@ public interface UserReservationMapper extends BaseMapper<UserReservation> {
      * 查询商家端预约信息列表
      *
      * @param storeId    门店ID(必填)
-     * @param status     预约状态(可选,0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时)
+     * @param status     预约状态(可选,0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时 5:用餐结束
      * @param dateFrom   预约日期起(可选)
      * @param dateTo     预约日期止(可选)
      * @param orderStatus 订单状态(可选,0:待支付 1:待使用 2:已完成 3:已过期 4:已取消 5:已关闭 6:退款中 7:已退款 8:商家预订)

+ 1 - 0
alien-entity/src/main/resources/mapper/StoreReservationMapper.xml

@@ -69,6 +69,7 @@
                 WHEN 2 THEN '已完成'
                 WHEN 3 THEN '已取消'
                 WHEN 4 THEN '未到店'
+                WHEN 5 THEN '用餐结束'
                 ELSE '未知'
             END AS status_text,
             uro.refund_amount,

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

@@ -86,6 +86,7 @@
                 WHEN 2 THEN '已完成'
                 WHEN 3 THEN '已取消'
                 WHEN 4 THEN '未到店'
+                WHEN 5 THEN '用餐结束'
                 ELSE '未知'
             END AS status_text,
             uro.refund_amount,

+ 31 - 0
alien-job/src/main/java/shop/alien/job/store/ReservationDiningFinishedDelayJob.java

@@ -0,0 +1,31 @@
+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;
+
+/**
+ * 点餐支付成功后延迟 20 分钟,由定时任务将「已到店」预约更新为「用餐结束」。
+ * <p>请在 XXL-JOB 控制台配置执行器任务,JobHandler 名称为 {@code reservationDiningFinishedDelayJob},
+ * 建议调度周期 1~5 分钟。</p>
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ReservationDiningFinishedDelayJob {
+
+    private final ReservationDiningFinishedDelayService reservationDiningFinishedDelayService;
+
+    @XxlJob("reservationDiningFinishedDelayJob")
+    public void reservationDiningFinishedDelayJob() {
+        log.info("【定时任务】点餐支付延迟标记用餐结束:开始执行");
+        try {
+            int n = reservationDiningFinishedDelayService.processEligibleOrders();
+            log.info("【定时任务】点餐支付延迟标记用餐结束:执行完成,本次更新预约条数={}", n);
+        } catch (Exception e) {
+            log.error("【定时任务】点餐支付延迟标记用餐结束:执行异常", e);
+            throw e;
+        }
+    }
+}

+ 158 - 0
alien-job/src/main/java/shop/alien/job/store/ReservationDiningFinishedDelayService.java

@@ -0,0 +1,158 @@
+package shop.alien.job.store;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import shop.alien.entity.store.StoreOrder;
+import shop.alien.entity.store.UserReservation;
+import shop.alien.entity.store.UserReservationTable;
+import shop.alien.mapper.StoreOrderMapper;
+import shop.alien.mapper.UserReservationMapper;
+import shop.alien.mapper.UserReservationTableMapper;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TimeZone;
+import java.util.stream.Collectors;
+
+/**
+ * 点餐订单支付成功后,延迟将「已到店」预约改为「用餐结束」,由定时任务触发(非支付回调即时更新)。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ReservationDiningFinishedDelayService {
+
+    private final StoreOrderMapper storeOrderMapper;
+    private final UserReservationTableMapper userReservationTableMapper;
+    private final UserReservationMapper userReservationMapper;
+
+    /** 支付成功起算延迟多少分钟后再把预约置为用餐结束 */
+    private static final int DELAY_MINUTES = 20;
+    /** 仅扫描近 N 天内已支付订单,避免历史数据全表扫描 */
+    private static final int PAY_TIME_LOOKBACK_DAYS = 30;
+
+    private static final int USER_RES_STATUS_ARRIVED = 2;
+    private static final int USER_RES_STATUS_DINING_FINISHED = 5;
+
+    /**
+     * 处理:已支付超过 {@link #DELAY_MINUTES} 分钟、且未在历史窗口之外的 store_order。
+     * 若订单含 {@code user_reservation_id},直接更新该预约(须门店一致且状态为已到店);
+     * 否则沿用桌位+「预约日=支付日」推断(兼容旧数据与散客未绑预约订单)。
+     *
+     * @return 本次成功更新的预约条数
+     */
+    public int processEligibleOrders() {
+        Date now = new Date();
+        TimeZone shanghai = TimeZone.getTimeZone("Asia/Shanghai");
+        Calendar cal = Calendar.getInstance(shanghai);
+        cal.setTime(now);
+        cal.add(Calendar.MINUTE, -DELAY_MINUTES);
+        Date cutoff = cal.getTime();
+
+        cal.setTime(now);
+        cal.add(Calendar.DAY_OF_MONTH, -PAY_TIME_LOOKBACK_DAYS);
+        Date oldest = cal.getTime();
+
+        List<StoreOrder> orders = storeOrderMapper.selectList(
+                new LambdaQueryWrapper<StoreOrder>()
+                        .eq(StoreOrder::getPayStatus, 1)
+                        .eq(StoreOrder::getDeleteFlag, 0)
+                        .isNotNull(StoreOrder::getPayTime)
+                        .isNotNull(StoreOrder::getStoreId)
+                        .and(w -> w.isNotNull(StoreOrder::getUserReservationId)
+                                .or()
+                                .isNotNull(StoreOrder::getTableId))
+                        .le(StoreOrder::getPayTime, cutoff)
+                        .ge(StoreOrder::getPayTime, oldest));
+
+        if (orders == null || orders.isEmpty()) {
+            return 0;
+        }
+
+        SimpleDateFormat dayFmt = new SimpleDateFormat("yyyy-MM-dd");
+        dayFmt.setTimeZone(shanghai);
+
+        int updated = 0;
+        for (StoreOrder order : orders) {
+            try {
+                if (order.getPayTime() == null) {
+                    continue;
+                }
+                if (order.getUserReservationId() != null) {
+                    updated += finishReservationByOrderBinding(order, now);
+                } else if (order.getTableId() != null) {
+                    String payDayStr = dayFmt.format(order.getPayTime());
+                    updated += syncUserReservationDiningFinished(
+                            order.getStoreId(), order.getTableId(), payDayStr, now, dayFmt);
+                }
+            } catch (Exception e) {
+                log.warn("延迟标记用餐结束失败 orderId={} err={}", order.getId(), e.getMessage());
+            }
+        }
+        return updated;
+    }
+
+    /** 订单已绑定 user_reservation_id:仅校验门店与已到店状态后置为用餐结束 */
+    private int finishReservationByOrderBinding(StoreOrder order, Date now) {
+        UserReservation r = userReservationMapper.selectById(order.getUserReservationId());
+        if (r == null || r.getStoreId() == null || !r.getStoreId().equals(order.getStoreId())) {
+            return 0;
+        }
+        if (r.getStatus() == null || r.getStatus() != USER_RES_STATUS_ARRIVED) {
+            return 0;
+        }
+        r.setStatus(USER_RES_STATUS_DINING_FINISHED);
+        r.setUpdatedTime(now);
+        userReservationMapper.updateById(r);
+        log.info("延迟任务:按订单绑定更新预约为用餐结束 reservationId={} orderId={}",
+                order.getUserReservationId(), order.getId());
+        return 1;
+    }
+
+    private int syncUserReservationDiningFinished(Integer storeId, Integer storeTableId,
+                                                  String reservationDayYyyyMmDd, Date now,
+                                                  SimpleDateFormat dayFmt) {
+        List<UserReservationTable> links = userReservationTableMapper.selectList(
+                new LambdaQueryWrapper<UserReservationTable>()
+                        .eq(UserReservationTable::getTableId, storeTableId)
+                        .eq(UserReservationTable::getDeleteFlag, 0));
+        if (links == null || links.isEmpty()) {
+            return 0;
+        }
+        Set<Integer> reservationIds = links.stream()
+                .map(UserReservationTable::getReservationId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toCollection(LinkedHashSet::new));
+
+        int n = 0;
+        for (Integer reservationId : reservationIds) {
+            UserReservation r = userReservationMapper.selectById(reservationId);
+            if (r == null || r.getStoreId() == null || !r.getStoreId().equals(storeId)) {
+                continue;
+            }
+            if (r.getStatus() == null || r.getStatus() != USER_RES_STATUS_ARRIVED) {
+                continue;
+            }
+            if (r.getReservationDate() == null) {
+                continue;
+            }
+            if (!reservationDayYyyyMmDd.equals(dayFmt.format(r.getReservationDate()))) {
+                continue;
+            }
+            r.setStatus(USER_RES_STATUS_DINING_FINISHED);
+            r.setUpdatedTime(now);
+            userReservationMapper.updateById(r);
+            n++;
+            log.info("延迟任务:预约已更新为用餐结束 reservationId={} tableId={} matchDay={}",
+                    reservationId, storeTableId, reservationDayYyyyMmDd);
+        }
+        return n;
+    }
+}

+ 1 - 1
alien-job/src/main/java/shop/alien/job/store/ReservationTimeoutJob.java

@@ -11,7 +11,7 @@ import shop.alien.job.feign.AlienStoreFeign;
  * 预订未到店超时定时任务
  * retain_position_flag=1:start_time + retention_duration(分钟)早于当前时间;
  * 否则仅 start_time 早于当前时间;且 user_reservation_order 为「待使用」的预订:
- * - user_reservation.status 置为 4(未到店超时)
+ * - user_reservation.status 置为 4(未到店超时;不处理用餐结束 5
  * - user_reservation_order.order_status 置为 3(已过期)
  */
 @Slf4j

+ 25 - 5
alien-store/src/main/java/shop/alien/store/controller/StoreReservationController.java

@@ -36,7 +36,7 @@ public class StoreReservationController {
     @ApiOperation("查询商家端预约信息列表")
     @ApiImplicitParams({
             @ApiImplicitParam(name = "storeId", value = "门店ID", dataType = "Integer", paramType = "query", required = true),
-            @ApiImplicitParam(name = "status", value = "预约状态(可选,0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时)", dataType = "Integer", paramType = "query", required = false),
+            @ApiImplicitParam(name = "status", value = "预约状态(可选,0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时 5:用餐结束)", dataType = "Integer", paramType = "query", required = false),
             @ApiImplicitParam(name = "dateFrom", value = "预约日期起 yyyy-MM-dd", dataType = "String", paramType = "query", required = false),
             @ApiImplicitParam(name = "dateTo", value = "预约日期止 yyyy-MM-dd", dataType = "String", paramType = "query", required = false),
             @ApiImplicitParam(name = "orderStatus", value = "订单状态(可选,0:待支付 1:待使用 2:已完成 3:已过期 4:已取消 5:已关闭 6:退款中 7:已退款 8:商家预订;不传则默认查询:待使用、已完成、已退款三种状态)", dataType = "Integer", paramType = "query", required = false),
@@ -189,6 +189,26 @@ public class StoreReservationController {
     }
 
     @ApiOperationSupport(order = 6)
+    @ApiOperation("商家端标记用餐结束")
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "reservationId", value = "预约ID(须为已到店状态)", dataType = "Integer", paramType = "query", required = true)
+    })
+    @PostMapping("/finishDining")
+    public R<String> finishDining(@RequestParam Integer reservationId) {
+        log.info("StoreReservationController.finishDining?reservationId={}", reservationId);
+        if (reservationId == null) {
+            return R.fail("预约ID不能为空");
+        }
+        try {
+            boolean ok = storeReservationService.finishDiningByStore(reservationId);
+            return ok ? R.success("已标记为用餐结束") : R.fail("操作失败");
+        } catch (Exception e) {
+            log.error("商家端标记用餐结束失败", e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperationSupport(order = 7)
     @ApiOperation("商家端退款(预留)")
     @ApiImplicitParams({
             @ApiImplicitParam(name = "reservationId", value = "预约ID", dataType = "Integer", paramType = "query", required = true)
@@ -214,7 +234,7 @@ public class StoreReservationController {
         }
     }
 
-    @ApiOperationSupport(order = 7)
+    @ApiOperationSupport(order = 8)
     @ApiOperation("通过预订设置ID查询线上预订营业时间")
     @ApiImplicitParams({
             @ApiImplicitParam(name = "settingsId", value = "预订设置ID(store_booking_settings表的id)", dataType = "Integer", paramType = "query", required = true)
@@ -236,7 +256,7 @@ public class StoreReservationController {
         }
     }
 
-    @ApiOperationSupport(order = 8)
+    @ApiOperationSupport(order = 9)
     @ApiOperation("商家端主动退款(调用支付退款接口并发送通知和短信)")
     @ApiImplicitParams({
             @ApiImplicitParam(name = "storeId", value = "门店ID", required = true, paramType = "query", dataType = "int"),
@@ -276,7 +296,7 @@ public class StoreReservationController {
         }
     }
 
-    @ApiOperationSupport(order = 9)
+    @ApiOperationSupport(order = 10)
     @ApiOperation("通过订单ID退款(根据订单自动带出门店、商户订单号、金额、支付方式,成功时发送通知和短信)")
     @ApiImplicitParams({
             @ApiImplicitParam(name = "orderId", value = "预订订单ID(user_reservation_order.id)", required = true, paramType = "query", dataType = "int"),
@@ -300,7 +320,7 @@ public class StoreReservationController {
         }
     }
 
-    @ApiOperationSupport(order = 10)
+    @ApiOperationSupport(order = 11)
     @ApiOperation("发送订金退款短信和通知")
     @ApiImplicitParams({
             @ApiImplicitParam(name = "reservationId", value = "预约ID", dataType = "Integer", paramType = "query", required = true)

+ 4 - 2
alien-store/src/main/java/shop/alien/store/controller/dining/DiningCouponPathProxyController.java

@@ -82,10 +82,11 @@ public class DiningCouponPathProxyController {
 
     @GetMapping("/userOwned")
     public R<List<LifeDiscountCouponVo>> getUserOwnedCoupons(
+            HttpServletRequest request,
             @RequestParam(required = false) String storeId,
             @RequestParam(required = false) BigDecimal amount) {
         try {
-            return diningServiceFeign.couponUserOwned(storeId, amount);
+            return diningServiceFeign.couponUserOwned(auth(request), storeId, amount);
         } catch (Exception e) {
             log.error("userOwned: {}", e.getMessage(), e);
             return R.fail(e.getMessage());
@@ -107,10 +108,11 @@ public class DiningCouponPathProxyController {
 
     @GetMapping("/userOwnedByStore")
     public R<List<LifeDiscountCouponVo>> getUserOwnedCouponsByStore(
+            HttpServletRequest request,
             @RequestParam(required = false) String storeId,
             @RequestParam(required = false) Integer couponType) {
         try {
-            return diningServiceFeign.couponUserOwnedByStore(storeId, couponType);
+            return diningServiceFeign.couponUserOwnedByStore(auth(request), storeId, couponType);
         } catch (Exception e) {
             log.error("userOwnedByStore: {}", e.getMessage(), e);
             return R.fail(e.getMessage());

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

@@ -411,6 +411,7 @@ public interface DiningServiceFeign {
 
     @GetMapping("/dining/coupon/userOwned")
     R<List<LifeDiscountCouponVo>> couponUserOwned(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
             @RequestParam(value = "storeId", required = false) String storeId,
             @RequestParam(value = "amount", required = false) BigDecimal amount);
 
@@ -422,6 +423,7 @@ public interface DiningServiceFeign {
 
     @GetMapping("/dining/coupon/userOwnedByStore")
     R<List<LifeDiscountCouponVo>> couponUserOwnedByStore(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
             @RequestParam(value = "storeId", required = false) String storeId,
             @RequestParam(value = "couponType", required = false) Integer couponType);
 

+ 10 - 1
alien-store/src/main/java/shop/alien/store/service/StoreReservationService.java

@@ -17,7 +17,7 @@ public interface StoreReservationService {
      * 查询商家端预约信息列表
      *
      * @param storeId    门店ID(必填)
-     * @param status     预约状态(可选,0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时)
+     * @param status     预约状态(可选,0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时 5:用餐结束
      * @param dateFrom   预约日期起(可选)
      * @param dateTo     预约日期止(可选)
      * @param orderStatus 订单状态(可选,0:待支付 1:待使用 2:已完成 3:已过期 4:已取消 5:已关闭 6:退款中 7:已退款 8:商家预订)
@@ -66,6 +66,15 @@ public interface StoreReservationService {
     boolean verifyReservationByCode(String verificationCode);
 
     /**
+     * 商家端标记用餐结束
+     * 仅当预约状态为已到店(2)时可操作;更新为 5:用餐结束(不占约满统计)。
+     *
+     * @param reservationId 预约ID
+     * @return 是否成功
+     */
+    boolean finishDiningByStore(Integer reservationId);
+
+    /**
      * 商家端退款
      * 根据预约ID进行退款处理
      *

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

@@ -99,7 +99,7 @@ public interface UserReservationService extends IService<UserReservation> {
      * 查询商家端预约信息列表
      *
      * @param storeId    门店ID(必填)
-     * @param status     预约状态(可选,0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时)
+     * @param status     预约状态(可选,0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时 5:用餐结束
      * @param dateFrom   预约日期起(可选)
      * @param dateTo     预约日期止(可选)
      * @param orderStatus 订单状态(可选,0:待支付 1:待使用 2:已完成 3:已过期 4:已取消 5:已关闭 6:退款中 7:已退款 8:商家预订)

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

@@ -81,6 +81,10 @@ public class StoreReservationServiceImpl extends ServiceImpl<StoreReservationMap
 
     /** 预约状态:已取消 */
     private static final int STATUS_CANCELLED = 3;
+    /** 预约状态:已到店 */
+    private static final int STATUS_ARRIVED = 2;
+    /** 预约状态:用餐结束 */
+    private static final int STATUS_FINISHED_DINING = 5;
     /** Redis key前缀:预订核销 */
     private static final String RESERVATION_VERIFY_PREFIX = "reservation:verify:";
 
@@ -125,6 +129,9 @@ public class StoreReservationServiceImpl extends ServiceImpl<StoreReservationMap
         if (reservation.getStatus() != null && reservation.getStatus() == STATUS_CANCELLED) {
             throw new RuntimeException("预约已取消,不能重复取消");
         }
+        if (reservation.getStatus() != null && reservation.getStatus() == STATUS_FINISHED_DINING) {
+            throw new RuntimeException("用餐结束的预约不能取消");
+        }
 
         // 校验取消原因(如果提供,限30字)
         if (cancelReason != null && cancelReason.length() > 30) {
@@ -672,6 +679,9 @@ public class StoreReservationServiceImpl extends ServiceImpl<StoreReservationMap
         if (reservation == null) {
             throw new RuntimeException("预约不存在");
         }
+        if (reservation.getStatus() != null && reservation.getStatus() == STATUS_FINISHED_DINING) {
+            throw new RuntimeException("用餐结束的预约不能加时");
+        }
 
         // 保存原结束时间用于日志
         String oldEndTime = reservation.getEndTime();
@@ -805,6 +815,34 @@ public class StoreReservationServiceImpl extends ServiceImpl<StoreReservationMap
 
     @Override
     @Transactional(rollbackFor = Exception.class)
+    public boolean finishDiningByStore(Integer reservationId) {
+        log.info("StoreReservationServiceImpl.finishDiningByStore?reservationId={}", reservationId);
+        if (reservationId == null) {
+            throw new RuntimeException("预约ID不能为空");
+        }
+        UserReservation reservation = this.getById(reservationId);
+        if (reservation == null) {
+            throw new RuntimeException("预约不存在");
+        }
+        Integer currentStoreId = getCurrentStoreId();
+        if (currentStoreId != null) {
+            if (reservation.getStoreId() == null || !currentStoreId.equals(reservation.getStoreId())) {
+                throw new RuntimeException("无权限操作其他门店的预约");
+            }
+        }
+        if (reservation.getStatus() == null || reservation.getStatus() != STATUS_ARRIVED) {
+            throw new RuntimeException("仅「已到店」状态可标记为用餐结束");
+        }
+        reservation.setStatus(STATUS_FINISHED_DINING);
+        if (!this.updateById(reservation)) {
+            throw new RuntimeException("更新预约状态失败");
+        }
+        log.info("标记用餐结束成功,reservationId={}", reservationId);
+        return true;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
     public boolean refundReservation(Integer reservationId) {
         log.info("StoreReservationServiceImpl.refundReservation?reservationId={}", reservationId);
 

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

@@ -89,6 +89,8 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
     private static final int STATUS_CANCELLED = 3;
     /** 预约状态:未到店超时 */
     private static final int STATUS_NO_SHOW_TIMEOUT = 4;
+    /** 预约状态:用餐结束(不参与约满占用统计) */
+    private static final int STATUS_FINISHED_DINING = 5;
     /** 订单状态:待使用 */
     private static final int ORDER_STATUS_TO_USE = 1;
     /** 订单状态:已过期 */
@@ -370,6 +372,10 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
                 continue;
             }
 
+            if (reservation.getStatus() != null && reservation.getStatus() == STATUS_FINISHED_DINING) {
+                continue;
+            }
+
             if (calReservation != null && reservation.getReservationDate() != null) {
                 Calendar cal = calendarOf(reservation.getReservationDate());
                 if (cal.get(Calendar.YEAR) != calReservation.get(Calendar.YEAR)
@@ -744,6 +750,7 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
         LambdaQueryWrapper<UserReservation> dayResWrapper = new LambdaQueryWrapper<>();
         dayResWrapper.eq(UserReservation::getStoreId, storeId)
                 .ne(UserReservation::getStatus, STATUS_CANCELLED)
+                .ne(UserReservation::getStatus, STATUS_FINISHED_DINING)
                 .ge(UserReservation::getReservationDate, dayStart)
                 .lt(UserReservation::getReservationDate, dayEnd);
         List<UserReservation> dayReservations = this.list(dayResWrapper);
@@ -810,6 +817,7 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
             LambdaQueryWrapper<UserReservation> dayResWrapper = new LambdaQueryWrapper<>();
             dayResWrapper.eq(UserReservation::getStoreId, storeId)
                     .ne(UserReservation::getStatus, STATUS_CANCELLED)
+                    .ne(UserReservation::getStatus, STATUS_FINISHED_DINING)
                     .ge(UserReservation::getReservationDate, dayStart)
                     .lt(UserReservation::getReservationDate, dayEnd);
             List<UserReservation> dayReservations = this.list(dayResWrapper);
@@ -955,7 +963,10 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
         if (reservation.getStatus() != null && reservation.getStatus() == STATUS_CANCELLED) {
             throw new RuntimeException("预约已取消,不能重复取消");
         }
-        
+        if (reservation.getStatus() != null && reservation.getStatus() == STATUS_FINISHED_DINING) {
+            throw new RuntimeException("用餐结束的预约不能取消");
+        }
+
         // 查询关联的订单信息
         LambdaQueryWrapper<UserReservationOrder> orderWrapper = new LambdaQueryWrapper<>();
         orderWrapper.eq(UserReservationOrder::getReservationId, reservationId);