From c49ce3f9985f7049fe67ce630e07d34d0cfb414f Mon Sep 17 00:00:00 2001
From: huangqizhen <15552608129@163.com>
Date: Tue, 24 Mar 2026 17:56:52 +0800
Subject: [PATCH] =?UTF-8?q?3.24=20=E8=AE=A2=E5=BA=A7=E7=B3=BB=E7=BB=9F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.idea/ApifoxUploaderProjectSetting.xml | 12 +
.idea/compiler.xml | 1 +
.idea/jarRepositories.xml | 5 +
.idea/misc.xml | 2 +-
.../src/main/java/com/lottery/dto/ApiResponse.java | 45 +++
.../java/com/lottery/dto/SeatSelectionRequest.java | 20 ++
.../java/com/lottery/dto/SeatSelectionResult.java | 21 ++
.../src/main/java/com/lottery/entity/Seat.java | 48 +++
.../java/com/lottery/entity/SeatCacheInfo.java | 19 +
.../main/java/com/lottery/entity/SeatStatus.java | 16 +
lottery-system/lottery-service/pom.xml | 15 +
.../main/java/com/lottery/LotteryApplication.java | 2 +
.../com/lottery/api/controller/SeatController.java | 110 ++++++
.../lottery/api/service/SeatAsyncSaveService.java | 33 ++
.../lottery/api/service/SeatSelectionService.java | 386 +++++++++++++++++++++
.../main/java/com/lottery/config/AsyncConfig.java | 34 ++
.../main/java/com/lottery/config/CorsConfig.java | 25 ++
.../java/com/lottery/config/WebSocketConfig.java | 25 ++
.../com/lottery/interceptor/AuthInterceptor.java | 15 +
.../com/lottery/interceptor/SeatRepository.java | 39 +++
.../com/lottery/websocket/SeatUpdateEvent.java | 23 ++
21 files changed, 895 insertions(+), 1 deletion(-)
create mode 100644 .idea/ApifoxUploaderProjectSetting.xml
create mode 100644 lottery-system/lottery-pojo/src/main/java/com/lottery/dto/ApiResponse.java
create mode 100644 lottery-system/lottery-pojo/src/main/java/com/lottery/dto/SeatSelectionRequest.java
create mode 100644 lottery-system/lottery-pojo/src/main/java/com/lottery/dto/SeatSelectionResult.java
create mode 100644 lottery-system/lottery-pojo/src/main/java/com/lottery/entity/Seat.java
create mode 100644 lottery-system/lottery-pojo/src/main/java/com/lottery/entity/SeatCacheInfo.java
create mode 100644 lottery-system/lottery-pojo/src/main/java/com/lottery/entity/SeatStatus.java
create mode 100644 lottery-system/lottery-service/src/main/java/com/lottery/api/controller/SeatController.java
create mode 100644 lottery-system/lottery-service/src/main/java/com/lottery/api/service/SeatAsyncSaveService.java
create mode 100644 lottery-system/lottery-service/src/main/java/com/lottery/api/service/SeatSelectionService.java
create mode 100644 lottery-system/lottery-service/src/main/java/com/lottery/config/AsyncConfig.java
create mode 100644 lottery-system/lottery-service/src/main/java/com/lottery/config/CorsConfig.java
create mode 100644 lottery-system/lottery-service/src/main/java/com/lottery/config/WebSocketConfig.java
create mode 100644 lottery-system/lottery-service/src/main/java/com/lottery/interceptor/SeatRepository.java
create mode 100644 lottery-system/lottery-service/src/main/java/com/lottery/websocket/SeatUpdateEvent.java
diff --git a/.idea/ApifoxUploaderProjectSetting.xml b/.idea/ApifoxUploaderProjectSetting.xml
new file mode 100644
index 0000000..a0ec383
--- /dev/null
+++ b/.idea/ApifoxUploaderProjectSetting.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index b89bc14..3a88ea7 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -2,6 +2,7 @@
+
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
index abb532a..c7ea920 100644
--- a/.idea/jarRepositories.xml
+++ b/.idea/jarRepositories.xml
@@ -3,6 +3,11 @@
+
+
+
+
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 8544af1..41cb02c 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -8,7 +8,7 @@
-
+
\ No newline at end of file
diff --git a/lottery-system/lottery-pojo/src/main/java/com/lottery/dto/ApiResponse.java b/lottery-system/lottery-pojo/src/main/java/com/lottery/dto/ApiResponse.java
new file mode 100644
index 0000000..536af91
--- /dev/null
+++ b/lottery-system/lottery-pojo/src/main/java/com/lottery/dto/ApiResponse.java
@@ -0,0 +1,45 @@
+package com.lottery.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ApiResponse {
+
+ private Integer code;
+ private String message;
+ private T data;
+ private Long timestamp;
+
+ public static ApiResponse success(T data) {
+ return ApiResponse.builder()
+ .code(200)
+ .message("success")
+ .data(data)
+ .timestamp(System.currentTimeMillis())
+ .build();
+ }
+
+ public static ApiResponse error(Integer code, String message) {
+ return ApiResponse.builder()
+ .code(code)
+ .message(message)
+ .data(null)
+ .timestamp(System.currentTimeMillis())
+ .build();
+ }
+
+ public static ApiResponse error(Integer code, String message, T data) {
+ return ApiResponse.builder()
+ .code(code)
+ .message(message)
+ .data(data)
+ .timestamp(System.currentTimeMillis())
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/lottery-system/lottery-pojo/src/main/java/com/lottery/dto/SeatSelectionRequest.java b/lottery-system/lottery-pojo/src/main/java/com/lottery/dto/SeatSelectionRequest.java
new file mode 100644
index 0000000..e6e810c
--- /dev/null
+++ b/lottery-system/lottery-pojo/src/main/java/com/lottery/dto/SeatSelectionRequest.java
@@ -0,0 +1,20 @@
+package com.lottery.dto;
+
+import lombok.Data;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+
+@Data
+public class SeatSelectionRequest {
+
+ @NotBlank(message = "用户 ID 不能为空")
+ private String homilyId;
+
+ @NotNull(message = "桌号不能为空")
+ private Integer tableNo;
+
+ @NotNull(message = "座号不能为空")
+ private Integer seatNo;
+
+ private String clientTraceId;
+}
\ No newline at end of file
diff --git a/lottery-system/lottery-pojo/src/main/java/com/lottery/dto/SeatSelectionResult.java b/lottery-system/lottery-pojo/src/main/java/com/lottery/dto/SeatSelectionResult.java
new file mode 100644
index 0000000..4b3f932
--- /dev/null
+++ b/lottery-system/lottery-pojo/src/main/java/com/lottery/dto/SeatSelectionResult.java
@@ -0,0 +1,21 @@
+package com.lottery.dto;
+
+
+import com.lottery.entity.SeatStatus;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class SeatSelectionResult {
+
+ private boolean success;
+ private String message;
+ private String seatId;
+ private SeatStatus finalStatus;
+ private Long timestamp;
+}
\ No newline at end of file
diff --git a/lottery-system/lottery-pojo/src/main/java/com/lottery/entity/Seat.java b/lottery-system/lottery-pojo/src/main/java/com/lottery/entity/Seat.java
new file mode 100644
index 0000000..02a6c48
--- /dev/null
+++ b/lottery-system/lottery-pojo/src/main/java/com/lottery/entity/Seat.java
@@ -0,0 +1,48 @@
+package com.lottery.entity;
+
+import com.lottery.entity.SeatStatus;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import javax.persistence.*;
+import java.io.Serializable;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Entity
+@Table(name = "t_seat")
+public class Seat implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "table_no", nullable = false)
+ private Integer tableNo;
+
+ @Column(name = "seat_no", nullable = false)
+ private Integer seatNo;
+
+ @Column(name = "unique_id", unique = true, nullable = false, length = 20)
+ private String uniqueId;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false, length = 20)
+ private SeatStatus status;
+
+ @Column(name = "locked_by", length = 50)
+ private String lockedBy;
+
+ @Column(name = "locked_at")
+ private Long lockedAt;
+
+ @Version
+ @Column(name = "version")
+ private Long version;
+}
\ No newline at end of file
diff --git a/lottery-system/lottery-pojo/src/main/java/com/lottery/entity/SeatCacheInfo.java b/lottery-system/lottery-pojo/src/main/java/com/lottery/entity/SeatCacheInfo.java
new file mode 100644
index 0000000..18daebb
--- /dev/null
+++ b/lottery-system/lottery-pojo/src/main/java/com/lottery/entity/SeatCacheInfo.java
@@ -0,0 +1,19 @@
+package com.lottery.entity;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class SeatCacheInfo implements Serializable {
+ private SeatStatus status;
+ private String lockedBy; // 选座用户
+ private Long lockedAt; // 锁定时间
+ private String extra; // 扩展字段
+}
\ No newline at end of file
diff --git a/lottery-system/lottery-pojo/src/main/java/com/lottery/entity/SeatStatus.java b/lottery-system/lottery-pojo/src/main/java/com/lottery/entity/SeatStatus.java
new file mode 100644
index 0000000..1a533c7
--- /dev/null
+++ b/lottery-system/lottery-pojo/src/main/java/com/lottery/entity/SeatStatus.java
@@ -0,0 +1,16 @@
+package com.lottery.entity;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum SeatStatus {
+ AVAILABLE("AVAILABLE", "可选", "#28a745"),
+ LOCKED("LOCKED", "已锁定", "#dc3545"),
+ TEMP_SELECTED("TEMP_SELECTED", "临时选中", "#007bff");
+
+ private final String code;
+ private final String desc;
+ private final String color;
+}
\ No newline at end of file
diff --git a/lottery-system/lottery-service/pom.xml b/lottery-system/lottery-service/pom.xml
index 86a2699..332d71f 100644
--- a/lottery-system/lottery-service/pom.xml
+++ b/lottery-system/lottery-service/pom.xml
@@ -26,6 +26,21 @@
+ org.springframework.boot
+ spring-boot-starter-websocket
+
+
+
+ org.apache.commons
+ commons-pool2
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
+
com.lottery
lottery-common
1.0-SNAPSHOT
diff --git a/lottery-system/lottery-service/src/main/java/com/lottery/LotteryApplication.java b/lottery-system/lottery-service/src/main/java/com/lottery/LotteryApplication.java
index e3896a0..b705fb0 100644
--- a/lottery-system/lottery-service/src/main/java/com/lottery/LotteryApplication.java
+++ b/lottery-system/lottery-service/src/main/java/com/lottery/LotteryApplication.java
@@ -10,6 +10,7 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
+import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import javax.swing.*;
@@ -17,6 +18,7 @@ import javax.swing.*;
@SpringBootApplication
@MapperScan("com.lottery.*.mapper")
@EnableTransactionManagement
+@EnableAsync
public class LotteryApplication {
public static void main(String[] args) {
SpringApplication.run(LotteryApplication.class, args);
diff --git a/lottery-system/lottery-service/src/main/java/com/lottery/api/controller/SeatController.java b/lottery-system/lottery-service/src/main/java/com/lottery/api/controller/SeatController.java
new file mode 100644
index 0000000..8f722e0
--- /dev/null
+++ b/lottery-system/lottery-service/src/main/java/com/lottery/api/controller/SeatController.java
@@ -0,0 +1,110 @@
+package com.lottery.api.controller;
+
+
+import com.lottery.api.service.SeatSelectionService;
+import com.lottery.dto.ApiResponse;
+import com.lottery.dto.SeatSelectionRequest;
+import com.lottery.dto.SeatSelectionResult;
+import com.lottery.entity.Seat;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.List;
+
+@RestController
+@RequestMapping("/api")
+@Slf4j
+public class SeatController {
+
+ @Autowired
+ private SeatSelectionService seatSelectionService;
+
+ /**
+ * 获取所有座位状态
+ */
+ @GetMapping("/seats")
+ public ApiResponse> getAllSeats() {
+ log.debug("获取所有座位状态");
+ List seats = seatSelectionService.getAllSeatsStatus();
+ return ApiResponse.success(seats);
+ }
+
+ /**
+ * 选座
+ */
+ @PostMapping("/seat/select")
+ public ApiResponse selectSeat(
+ @Validated @RequestBody SeatSelectionRequest request,
+ HttpServletRequest httpServletRequest) {
+
+ // 从请求头获取用户 ID(备用)
+ String headerHomilyId = httpServletRequest.getHeader("X-User-HomilyId");
+ if (headerHomilyId != null && !headerHomilyId.isEmpty()) {
+ request.setHomilyId(headerHomilyId);
+ }
+
+ log.info("收到选座请求:user={}, table={}, seat={}",
+ request.getHomilyId(), request.getTableNo(), request.getSeatNo());
+
+ SeatSelectionResult result = seatSelectionService.selectSeat(request);
+
+ if (result.isSuccess()) {
+ return ApiResponse.success(result);
+ } else {
+ return ApiResponse.error(409, result.getMessage(), result);
+ }
+ }
+
+ /**
+ * 查询用户已选座位
+ */
+ @GetMapping("/user/seats")
+ public ApiResponse> getUserSeats(
+ @RequestParam String homilyId) {
+
+ log.debug("查询用户座位:user={}", homilyId);
+ List seats = seatSelectionService.getUserSeats(homilyId);
+ return ApiResponse.success(seats);
+ }
+
+ /**
+ * 健康检查
+ */
+ @GetMapping("/health")
+ public ApiResponse health() {
+ return ApiResponse.success("OK");
+ }
+ /**
+ * 取消选座(释放座位)
+ */
+ @PostMapping("/seat/cancel")
+ public ApiResponse cancelSeat(
+ @RequestParam String homilyId,
+ HttpServletRequest httpServletRequest) {
+
+ // 兼容:如果请求头有用户ID,优先使用(防止参数篡改)
+ String headerHomilyId = httpServletRequest.getHeader("X-User-HomilyId");
+ if (headerHomilyId != null && !headerHomilyId.isEmpty()) {
+ homilyId = headerHomilyId;
+ }
+
+ log.info("收到取消选座请求:user={}", homilyId);
+
+ try {
+ SeatSelectionResult result = seatSelectionService.cancelSeat(homilyId);
+
+ if (result.isSuccess()) {
+ return ApiResponse.success(result);
+ } else {
+ // 业务逻辑失败(如用户没选座),返回 400 而非 500
+ return ApiResponse.error(400, result.getMessage(), result);
+ }
+ } catch (Exception e) {
+ log.error("取消选座异常: homilyId={}", homilyId, e);
+ return ApiResponse.error(500, "系统异常,请稍后重试");
+ }
+ }
+}
\ No newline at end of file
diff --git a/lottery-system/lottery-service/src/main/java/com/lottery/api/service/SeatAsyncSaveService.java b/lottery-system/lottery-service/src/main/java/com/lottery/api/service/SeatAsyncSaveService.java
new file mode 100644
index 0000000..20631ae
--- /dev/null
+++ b/lottery-system/lottery-service/src/main/java/com/lottery/api/service/SeatAsyncSaveService.java
@@ -0,0 +1,33 @@
+package com.lottery.api.service;
+
+import com.lottery.entity.Seat;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.persistence.EntityManager;
+import javax.persistence.PersistenceContext;
+
+@Service
+@Slf4j
+public class SeatAsyncSaveService {
+
+ @PersistenceContext
+ private EntityManager entityManager;
+
+ @Async("taskExecutor") // 指定线程池
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void save(Seat seat) {
+ try {
+ log.info("异步保存座位: {} @ thread={}",
+ seat.getUniqueId(), Thread.currentThread().getName());
+ entityManager.persist(seat);
+ entityManager.flush();
+ } catch (Exception e) {
+ log.error("异步保存失败: {}", seat.getUniqueId(), e);
+ throw e;
+ }
+ }
+}
\ No newline at end of file
diff --git a/lottery-system/lottery-service/src/main/java/com/lottery/api/service/SeatSelectionService.java b/lottery-system/lottery-service/src/main/java/com/lottery/api/service/SeatSelectionService.java
new file mode 100644
index 0000000..189956a
--- /dev/null
+++ b/lottery-system/lottery-service/src/main/java/com/lottery/api/service/SeatSelectionService.java
@@ -0,0 +1,386 @@
+package com.lottery.api.service;
+
+
+import com.lottery.dto.SeatSelectionRequest;
+import com.lottery.dto.SeatSelectionResult;
+import com.lottery.entity.Seat;
+import com.lottery.entity.SeatCacheInfo;
+import com.lottery.entity.SeatStatus;
+import com.lottery.interceptor.SeatRepository;
+import com.lottery.websocket.SeatUpdateEvent;
+import lombok.extern.slf4j.Slf4j;
+import org.hibernate.validator.internal.util.stereotypes.Lazy;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.messaging.simp.SimpMessagingTemplate;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.PostConstruct;
+import javax.persistence.EntityManager;
+import javax.persistence.PersistenceContext;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+@Service
+@Slf4j
+public class SeatSelectionService {
+
+ @Autowired
+ private RedisTemplate redisTemplate;
+
+ @Autowired
+ private SimpMessagingTemplate messagingTemplate;
+
+ @PersistenceContext
+ private EntityManager entityManager;
+ @Autowired
+ private SeatAsyncSaveService seatAsyncSaveService;
+
+ @Autowired
+ private SeatRepository seatRepository;
+
+ private static final String SEAT_KEY_PREFIX = "seat:status:";
+ private static final String LOCK_KEY_PREFIX = "seat:lock:";
+ private static final long LOCK_EXPIRE_SECONDS = 30;
+
+ // 🔧 新增:用户选座映射 + 全局限制标记
+ // 用户已选座位的 Key 前缀:user:seat:{userId}
+ private static final String USER_SEAT_PREFIX = "user:seat:";
+
+ // 用户选座过期时间(与座位锁一致)
+ private static final long USER_SEAT_EXPIRE_SECONDS = 60;
+ private static final long USER_SEAT_EXPIRE_DAYS = 7; // 选座记录保留7天
+
+ /**
+ * 初始化座位数据
+ */
+ @PostConstruct
+ public void initSeats() {
+ log.info("开始初始化座位数据...");
+
+ // 1. 先收集所有需要初始化的 key-value
+ Map initMap = new HashMap<>();
+
+ for (int table = 1; table <= 10; table++) {
+ for (int seat = 1; seat <= 10; seat++) {
+ String seatId = buildSeatId(table, seat);
+ String key = SEAT_KEY_PREFIX + seatId;
+
+ // 只初始化不存在的 key
+ if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) {
+ initMap.put(key, SeatCacheInfo.builder()
+ .status(SeatStatus.AVAILABLE)
+ .build());
+ }
+ }
+ }
+
+ // 2. 批量写入(减少网络请求)
+ if (!initMap.isEmpty()) {
+ redisTemplate.opsForValue().multiSet(initMap);
+ log.info("批量初始化 {} 个座位", initMap.size());
+ } else {
+ log.info("所有座位已存在,跳过初始化");
+ }
+ }
+
+ /**
+ * 选座核心方法
+ */
+ public SeatSelectionResult selectSeat(SeatSelectionRequest request) {
+ String userId = request.getHomilyId();
+ String seatId = buildSeatId(request.getTableNo(), request.getSeatNo());
+ String lockKey = LOCK_KEY_PREFIX + seatId;
+ String statusKey = SEAT_KEY_PREFIX + seatId;
+ String userSeatKey = USER_SEAT_PREFIX + userId;
+
+ log.info("用户 {} 尝试选座:{}", userId, seatId);
+
+ // ========= 🔴 第一层:Redis 快速检查 =========
+ String existingSeatId = (String) redisTemplate.opsForValue().get(userSeatKey);
+ if (existingSeatId != null) {
+ log.warn("选座失败 (Redis),用户已选过座位:userId={}, existingSeatId={}", userId, existingSeatId);
+ return SeatSelectionResult.builder()
+ .success(false)
+ .message("每人只能选一个座位,您已选择座位:" + existingSeatId)
+ .seatId(existingSeatId)
+ .finalStatus(SeatStatus.LOCKED)
+ .timestamp(System.currentTimeMillis())
+ .build();
+ }
+
+ // ========= 🔴 第二层:数据库兜底检查(修复 Optional 问题) =========
+ Seat dbExistingSeat = seatRepository.findByLockedByAndStatus(userId, SeatStatus.LOCKED).orElse(null);
+ if (dbExistingSeat != null) {
+ // 同步修复 Redis(可能过期了)
+ redisTemplate.opsForValue().set(userSeatKey, dbExistingSeat.getUniqueId());
+
+ log.warn("选座失败 (DB),用户已选过座位:userId={}, existingSeatId={}", userId, dbExistingSeat.getUniqueId());
+ return SeatSelectionResult.builder()
+ .success(false)
+ .message("每人只能选一个座位,您已选择座位:" + dbExistingSeat.getUniqueId())
+ .seatId(dbExistingSeat.getUniqueId())
+ .finalStatus(SeatStatus.LOCKED)
+ .timestamp(System.currentTimeMillis())
+ .build();
+ }
+
+ // ========= 1. 尝试获取分布式锁 =========
+ Boolean locked = redisTemplate.opsForValue()
+ .setIfAbsent(lockKey, userId, LOCK_EXPIRE_SECONDS, TimeUnit.SECONDS);
+
+ if (Boolean.FALSE.equals(locked)) {
+ log.warn("选座失败,座位已被锁定:{}", seatId);
+ return SeatSelectionResult.builder()
+ .success(false)
+ .message("座位已被他人选中,请重试")
+ .seatId(seatId)
+ .finalStatus(SeatStatus.LOCKED)
+ .timestamp(System.currentTimeMillis())
+ .build();
+ }
+
+ try {
+ // ========= 2. 检查座位状态 =========
+ SeatCacheInfo seatInfo = (SeatCacheInfo) redisTemplate.opsForValue().get(statusKey);
+ SeatStatus currentStatus = seatInfo != null ? seatInfo.getStatus() : SeatStatus.AVAILABLE;
+
+ if (currentStatus == SeatStatus.LOCKED) {
+ log.warn("选座失败,座位已被占用:{}", seatId);
+ return SeatSelectionResult.builder()
+ .success(false)
+ .message("座位已被锁定")
+ .seatId(seatId)
+ .finalStatus(SeatStatus.LOCKED)
+ .timestamp(System.currentTimeMillis())
+ .build();
+ }
+
+ // ========= 3. 更新 Redis 状态 =========
+ SeatCacheInfo newInfo = SeatCacheInfo.builder()
+ .status(SeatStatus.LOCKED)
+ .lockedBy(userId)
+ .lockedAt(System.currentTimeMillis())
+ .build();
+ redisTemplate.opsForValue().set(statusKey, newInfo);
+
+ // ========= 4. 记录用户 - 座位映射(不设过期) =========
+ redisTemplate.opsForValue().set(userSeatKey, seatId); // 👈 无过期时间
+
+ // ========= 5. 异步保存数据库 =========
+ Seat lockedSeat = Seat.builder()
+ .tableNo(request.getTableNo())
+ .seatNo(request.getSeatNo())
+ .uniqueId(seatId)
+ .status(SeatStatus.LOCKED)
+ .lockedBy(userId)
+ .lockedAt(System.currentTimeMillis())
+ .version(1L)
+ .build();
+ seatAsyncSaveService.save(lockedSeat);
+
+ // ========= 6. 广播 WebSocket 消息 =========
+ SeatUpdateEvent event = SeatUpdateEvent.builder()
+ .seatId(seatId)
+ .tableNo(request.getTableNo())
+ .seatNo(request.getSeatNo())
+ .newStatus(SeatStatus.LOCKED)
+ .lockedBy(userId)
+ .previousStatus(currentStatus)
+ .timestamp(System.currentTimeMillis())
+ .build();
+ messagingTemplate.convertAndSend("/topic/seat/update", event);
+
+ log.info("选座成功:用户={}, 座位={}", userId, seatId);
+
+ return SeatSelectionResult.builder()
+ .success(true)
+ .message("选座成功")
+ .seatId(seatId)
+ .finalStatus(SeatStatus.LOCKED)
+ .timestamp(System.currentTimeMillis())
+ .build();
+
+ } catch (Exception e) {
+ // 异常时清理用户映射
+ redisTemplate.delete(userSeatKey);
+
+ // 尝试恢复座位状态
+ SeatCacheInfo current = (SeatCacheInfo) redisTemplate.opsForValue().get(statusKey);
+ if (current != null && current.getStatus() == SeatStatus.LOCKED
+ && userId.equals(current.getLockedBy())) {
+ redisTemplate.opsForValue().set(statusKey, SeatCacheInfo.builder().status(SeatStatus.AVAILABLE).build());
+ }
+
+ log.error("选座过程中发生异常", e);
+ throw e;
+ } finally {
+ // 释放座位锁
+ redisTemplate.delete(lockKey);
+ }
+ }
+
+ /**
+ * 获取所有座位状态
+ */
+ public List getAllSeatsStatus() {
+ List seats = new ArrayList<>();
+ for (int table = 1; table <= 10; table++) {
+ for (int seat = 1; seat <= 10; seat++) {
+ String seatId = buildSeatId(table, seat);
+ String key = SEAT_KEY_PREFIX + seatId;
+ SeatCacheInfo info = (SeatCacheInfo) redisTemplate.opsForValue().get(key);
+
+ seats.add(Seat.builder()
+ .tableNo(table)
+ .seatNo(seat)
+ .uniqueId(seatId)
+ .status(info != null ? info.getStatus() : SeatStatus.AVAILABLE)
+ .lockedBy(info != null ? info.getLockedBy() : null) // 👈 取出来
+ .lockedAt(info != null ? info.getLockedAt() : null)
+ .build());
+ }
+ }
+ return seats;
+ }
+
+ /**
+ * 查询用户已选座位(优化版)
+ */
+ public List getUserSeats(String userId) {
+ List userSeats = new ArrayList<>();
+
+ // 1. 直接查用户 - 座位映射(1 次 Redis 查询)
+ String userSeatKey = USER_SEAT_PREFIX + userId;
+ String seatId = (String) redisTemplate.opsForValue().get(userSeatKey);
+
+ if (seatId == null) {
+ return userSeats; // 用户没选座
+ }
+
+ // 2. 解析 seatId 获取桌号座位号(假设格式 "T1-S8")
+ try {
+ String[] parts = seatId.replace("T", "").replace("S", "").split("-");
+ Integer tableNo = Integer.parseInt(parts[0]);
+ Integer seatNo = Integer.parseInt(parts[1]);
+
+ // 3. 查该座位的详细信息(1 次 Redis 查询)
+ String statusKey = SEAT_KEY_PREFIX + seatId;
+ SeatCacheInfo info = (SeatCacheInfo) redisTemplate.opsForValue().get(statusKey);
+
+ userSeats.add(Seat.builder()
+ .tableNo(tableNo)
+ .seatNo(seatNo)
+ .uniqueId(seatId)
+ .status(info != null ? info.getStatus() : SeatStatus.AVAILABLE)
+ .lockedBy(info != null ? info.getLockedBy() : null)
+ .lockedAt(info != null ? info.getLockedAt() : null)
+ .build());
+
+ } catch (Exception e) {
+ log.warn("解析用户选座信息异常: userId={}, seatId={}", userId, seatId, e);
+ }
+
+ return userSeats;
+ }
+
+ /**
+ * 异步保存数据库
+ */
+ @Async
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void asyncSaveToDatabase(Seat seat) {
+ try {
+ log.info("异步保存座位开始: seatId={}, thread={}",
+ seat.getUniqueId(), Thread.currentThread().getName()); // ← 🔧 调试日志
+ entityManager.persist(seat);
+ entityManager.flush(); // ← 🔧 强制刷新,确保立即执行
+ log.info("异步保存座位成功: seatId={}", seat.getUniqueId());
+ } catch (Exception e) {
+ log.error("异步保存座位失败! seatId={}, userId={}",
+ seat.getUniqueId(), seat.getLockedBy(), e); // ← 🔧 打印完整堆栈
+ throw e; // ← 🔧 重新抛出,触发全局异常处理器
+ }
+ }
+
+ private String buildSeatId(Integer table, Integer seat) {
+ return String.format("T%d-S%d", table, seat);
+ }
+
+ /**
+ * 取消选座(释放座位 + 清理映射)
+ */
+ public SeatSelectionResult cancelSeat(String userId) {
+ String userSeatKey = USER_SEAT_PREFIX + userId;
+
+ // 1. 查用户已选的座位
+ String seatId = (String) redisTemplate.opsForValue().get(userSeatKey);
+ if (seatId == null) {
+ return SeatSelectionResult.builder()
+ .success(false)
+ .message("您没有选中的座位")
+ .build();
+ }
+
+ try {
+ // 2. 解析座位信息
+ String[] parts = seatId.replace("T", "").replace("S", "").split("-");
+ Integer tableNo = Integer.parseInt(parts[0]);
+ Integer seatNo = Integer.parseInt(parts[1]);
+
+ String statusKey = SEAT_KEY_PREFIX + seatId;
+
+ // 3. 恢复座位状态
+ SeatCacheInfo availableInfo = SeatCacheInfo.builder()
+ .status(SeatStatus.AVAILABLE)
+ .build();
+ redisTemplate.opsForValue().set(statusKey, availableInfo);
+
+ // 4. 删除用户 - 座位映射
+ redisTemplate.delete(userSeatKey);
+
+ // 5. 异步更新数据库
+ Seat releasedSeat = Seat.builder()
+ .tableNo(tableNo)
+ .seatNo(seatNo)
+ .uniqueId(seatId)
+ .status(SeatStatus.AVAILABLE)
+ .build();
+ seatAsyncSaveService.save(releasedSeat); // 确保你的 asyncSaveService 支持更新
+
+ // 6. 广播状态变更
+ SeatUpdateEvent event = SeatUpdateEvent.builder()
+ .seatId(seatId)
+ .tableNo(tableNo)
+ .seatNo(seatNo)
+ .newStatus(SeatStatus.AVAILABLE)
+ .previousStatus(SeatStatus.LOCKED)
+ .timestamp(System.currentTimeMillis())
+ .build();
+ messagingTemplate.convertAndSend("/topic/seat/update", event);
+
+ log.info("取消选座成功:用户={}, 座位={}", userId, seatId);
+
+ return SeatSelectionResult.builder()
+ .success(true)
+ .message("取消选座成功")
+ .seatId(seatId)
+ .finalStatus(SeatStatus.AVAILABLE)
+ .timestamp(System.currentTimeMillis())
+ .build();
+
+ } catch (Exception e) {
+ log.error("取消选座异常: userId={}, seatId={}", userId, seatId, e);
+ return SeatSelectionResult.builder()
+ .success(false)
+ .message("系统异常,请稍后重试")
+ .build();
+ }
+ }
+}
\ No newline at end of file
diff --git a/lottery-system/lottery-service/src/main/java/com/lottery/config/AsyncConfig.java b/lottery-system/lottery-service/src/main/java/com/lottery/config/AsyncConfig.java
new file mode 100644
index 0000000..740a550
--- /dev/null
+++ b/lottery-system/lottery-service/src/main/java/com/lottery/config/AsyncConfig.java
@@ -0,0 +1,34 @@
+package com.lottery.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.EnableAspectJAutoProxy;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.ThreadPoolExecutor;
+
+@Configuration
+@EnableAsync
+@EnableAspectJAutoProxy(exposeProxy = true) // 如果用了AopContext方案,保留这行
+public class AsyncConfig {
+
+ /**
+ * ⚠️ Bean名称必须是 "taskExecutor"(Spring @Async 的默认查找名称)
+ * 或者去掉 @Async("taskExecutor") 中的名称,使用此默认Bean
+ */
+ @Bean("taskExecutor") // ← 关键:名称必须匹配 @Async 注解
+ public Executor taskExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(5);
+ executor.setMaxPoolSize(20);
+ executor.setQueueCapacity(100);
+ executor.setThreadNamePrefix("seat-save-");
+ executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+ executor.setWaitForTasksToCompleteOnShutdown(true);
+ executor.setAwaitTerminationSeconds(60);
+ executor.initialize();
+ return executor;
+ }
+}
\ No newline at end of file
diff --git a/lottery-system/lottery-service/src/main/java/com/lottery/config/CorsConfig.java b/lottery-system/lottery-service/src/main/java/com/lottery/config/CorsConfig.java
new file mode 100644
index 0000000..ca12b00
--- /dev/null
+++ b/lottery-system/lottery-service/src/main/java/com/lottery/config/CorsConfig.java
@@ -0,0 +1,25 @@
+package com.lottery.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+import org.springframework.web.filter.CorsFilter;
+
+@Configuration
+public class CorsConfig {
+
+ @Bean
+ public CorsFilter corsFilter() {
+ CorsConfiguration config = new CorsConfiguration();
+ config.addAllowedOriginPattern("*");
+ config.addAllowedHeader("*");
+ config.addAllowedMethod("*");
+ config.setAllowCredentials(true);
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", config);
+
+ return new CorsFilter(source);
+ }
+}
\ No newline at end of file
diff --git a/lottery-system/lottery-service/src/main/java/com/lottery/config/WebSocketConfig.java b/lottery-system/lottery-service/src/main/java/com/lottery/config/WebSocketConfig.java
new file mode 100644
index 0000000..3ca83c1
--- /dev/null
+++ b/lottery-system/lottery-service/src/main/java/com/lottery/config/WebSocketConfig.java
@@ -0,0 +1,25 @@
+package com.lottery.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.messaging.simp.config.MessageBrokerRegistry;
+import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
+import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
+import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
+
+@Configuration
+@EnableWebSocketMessageBroker
+public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
+
+ @Override
+ public void registerStompEndpoints(StompEndpointRegistry registry) {
+ registry.addEndpoint("/ws/seat")
+ .setAllowedOriginPatterns("*")
+ .withSockJS();
+ }
+
+ @Override
+ public void configureMessageBroker(MessageBrokerRegistry registry) {
+ registry.enableSimpleBroker("/topic");
+ registry.setApplicationDestinationPrefixes("/app");
+ }
+}
\ No newline at end of file
diff --git a/lottery-system/lottery-service/src/main/java/com/lottery/interceptor/AuthInterceptor.java b/lottery-system/lottery-service/src/main/java/com/lottery/interceptor/AuthInterceptor.java
index 6a72a23..5bbe353 100644
--- a/lottery-system/lottery-service/src/main/java/com/lottery/interceptor/AuthInterceptor.java
+++ b/lottery-system/lottery-service/src/main/java/com/lottery/interceptor/AuthInterceptor.java
@@ -58,6 +58,21 @@ public class AuthInterceptor implements HandlerInterceptor {
if("/api/funding/getActivity".equals(request.getRequestURI())) {
return true;
}
+ if ("/api/seats".equals(request.getRequestURI())) {
+ return true;
+ }
+ if ("/api/seat/select".equals(request.getRequestURI())) {
+ return true;
+ }
+ if ("/api/user/seats".equals(request.getRequestURI())) {
+ return true;
+ }
+ if ("/api/health".equals(request.getRequestURI())) {
+ return true;
+ }
+ if ("/ws/seat".equals(request.getRequestURI())){
+ return true;
+ }
// 2. 检查其他接口是否携带 Token
diff --git a/lottery-system/lottery-service/src/main/java/com/lottery/interceptor/SeatRepository.java b/lottery-system/lottery-service/src/main/java/com/lottery/interceptor/SeatRepository.java
new file mode 100644
index 0000000..b269f44
--- /dev/null
+++ b/lottery-system/lottery-service/src/main/java/com/lottery/interceptor/SeatRepository.java
@@ -0,0 +1,39 @@
+package com.lottery.interceptor; // 👈 确保包名正确
+
+import com.lottery.entity.Seat;
+import com.lottery.entity.SeatStatus;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface SeatRepository extends JpaRepository {
+
+ /**
+ * 根据用户 ID 和状态查询座位(一人一座校验用)
+ */
+ Optional findByLockedByAndStatus(String lockedBy, SeatStatus status);
+
+ /**
+ * 根据唯一 ID 查询座位
+ */
+ Optional findByUniqueId(String uniqueId);
+
+ /**
+ * 更新座位状态 👇 必须加 @Modifying + @Query
+ */
+ @Modifying
+ @Query("UPDATE Seat s SET s.status = :status WHERE s.uniqueId = :uniqueId")
+ int updateStatusByUniqueId(@Param("uniqueId") String uniqueId, @Param("status") SeatStatus status);
+
+ /**
+ * 释放用户的所有锁定座位
+ */
+ @Modifying
+ @Query("UPDATE Seat s SET s.status = 'AVAILABLE', s.lockedBy = NULL WHERE s.lockedBy = :lockedBy")
+ int releaseSeatsByUser(@Param("lockedBy") String lockedBy);
+}
\ No newline at end of file
diff --git a/lottery-system/lottery-service/src/main/java/com/lottery/websocket/SeatUpdateEvent.java b/lottery-system/lottery-service/src/main/java/com/lottery/websocket/SeatUpdateEvent.java
new file mode 100644
index 0000000..4f047b8
--- /dev/null
+++ b/lottery-system/lottery-service/src/main/java/com/lottery/websocket/SeatUpdateEvent.java
@@ -0,0 +1,23 @@
+package com.lottery.websocket;
+
+
+import com.lottery.entity.SeatStatus;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class SeatUpdateEvent {
+
+ private String seatId;
+ private Integer tableNo;
+ private Integer seatNo;
+ private SeatStatus newStatus;
+ private String lockedBy;
+ private Long timestamp;
+ private SeatStatus previousStatus;
+}
\ No newline at end of file