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