Compare commits

...

2 Commits

Author SHA1 Message Date
huangqizhen 7baeab95ee 3.25 订座系统 修改bug 2 weeks ago
huangqizhen c49ce3f998 3.24 订座系统 2 weeks ago
  1. 12
      .idea/ApifoxUploaderProjectSetting.xml
  2. 1
      .idea/compiler.xml
  3. 5
      .idea/jarRepositories.xml
  4. 2
      .idea/misc.xml
  5. 45
      lottery-system/lottery-pojo/src/main/java/com/lottery/dto/ApiResponse.java
  6. 20
      lottery-system/lottery-pojo/src/main/java/com/lottery/dto/SeatSelectionRequest.java
  7. 21
      lottery-system/lottery-pojo/src/main/java/com/lottery/dto/SeatSelectionResult.java
  8. 48
      lottery-system/lottery-pojo/src/main/java/com/lottery/entity/Seat.java
  9. 19
      lottery-system/lottery-pojo/src/main/java/com/lottery/entity/SeatCacheInfo.java
  10. 16
      lottery-system/lottery-pojo/src/main/java/com/lottery/entity/SeatStatus.java
  11. 15
      lottery-system/lottery-service/pom.xml
  12. 2
      lottery-system/lottery-service/src/main/java/com/lottery/LotteryApplication.java
  13. 110
      lottery-system/lottery-service/src/main/java/com/lottery/api/controller/SeatController.java
  14. 53
      lottery-system/lottery-service/src/main/java/com/lottery/api/service/SeatAsyncSaveService.java
  15. 380
      lottery-system/lottery-service/src/main/java/com/lottery/api/service/SeatSelectionService.java
  16. 52
      lottery-system/lottery-service/src/main/java/com/lottery/config/AsyncConfig.java
  17. 25
      lottery-system/lottery-service/src/main/java/com/lottery/config/CorsConfig.java
  18. 25
      lottery-system/lottery-service/src/main/java/com/lottery/config/WebSocketConfig.java
  19. 18
      lottery-system/lottery-service/src/main/java/com/lottery/interceptor/AuthInterceptor.java
  20. 42
      lottery-system/lottery-service/src/main/java/com/lottery/interceptor/SeatRepository.java
  21. 23
      lottery-system/lottery-service/src/main/java/com/lottery/websocket/SeatUpdateEvent.java

12
.idea/ApifoxUploaderProjectSetting.xml

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ApifoxUploaderProjectSetting">
<option name="apiAccessToken" value="APS-WStluzIggFrk00TdHcl7us4Z2nvmNEbm" />
<option name="apiProjectIds">
<array>
<option value="&lt;byte-array&gt;rO0ABXNyADZjb20uaXRhbmdjZW50LmlkZWEucGx1Z2luLmFwaS5hY2NvdW50LlByb2plY3RBbmRNb2R1bGUAAAAAAAAAAQIAFVoABmVuYWJsZUwACG1vZHVsZUlkdAASTGphdmEvbGFuZy9TdHJpbmc7TAAGb3RoZXIxcQB+AAFMAAdvdGhlcjEwcQB+AAFMAAdvdGhlcjExcQB+AAFMAAdvdGhlcjEycQB+AAFMAAZvdGhlcjJxAH4AAUwABm90aGVyM3EAfgABTAAGb3RoZXI0cQB+AAFMAAZvdGhlcjVxAH4AAUwABm90aGVyNnEAfgABTAAGb3RoZXI3cQB+AAFMAAZvdGhlcjhxAH4AAUwABm90aGVyOXEAfgABTAAKcGF0aEJlZm9yZXEAfgABTAANcHJvamVjdEZvbGRlcnEAfgABTAAPcHJvamVjdEZvbGRlcklkcQB+AAFMAAlwcm9qZWN0SWRxAH4AAUwAC3Byb2plY3ROYW1lcQB+AAFMAAxzY2hlbWFGb2xkZXJxAH4AAUwACHNjaGVtYUlkcQB+AAF4cAF0AA9sb3R0ZXJ5LXNlcnZpY2V0AAc2NDk3ODczcHBwdAAHNTgyODUwNnQAC2JyYW5jaC1tYWludAAM6buY6K6k5qih5Z2XcHBwcHB0AAB0AAnmoLnnm67lvZV0AAEwdAAHNjc4NTQ1NHQADOS4quS6uumhueebrnEAfgAJcQB+AAo=&lt;/byte-array&gt;" />
</array>
</option>
<option name="treeNodesJTree" value="&lt;byte-array&gt;rO0ABXNyACFqYXZheC5zd2luZy50cmVlLkRlZmF1bHRUcmVlTW9kZWynvpEmGsXl2QMAA1oAEmFza3NBbGxvd3NDaGlsZHJlbkwADGxpc3RlbmVyTGlzdHQAJUxqYXZheC9zd2luZy9ldmVudC9FdmVudExpc3RlbmVyTGlzdDtMAARyb290dAAbTGphdmF4L3N3aW5nL3RyZWUvVHJlZU5vZGU7eHAAc3IAI2phdmF4LnN3aW5nLmV2ZW50LkV2ZW50TGlzdGVuZXJMaXN0kUjMLXPfDt4DAAB4cHB4c3IAJ2phdmF4LnN3aW5nLnRyZWUuRGVmYXVsdE11dGFibGVUcmVlTm9kZcRYv/zyqHHgAwADWgAOYWxsb3dzQ2hpbGRyZW5MAAhjaGlsZHJlbnQAEkxqYXZhL3V0aWwvVmVjdG9yO0wABnBhcmVudHQAIkxqYXZheC9zd2luZy90cmVlL011dGFibGVUcmVlTm9kZTt4cAFzcgAQamF2YS51dGlsLlZlY3RvctmXfVuAO68BAwADSQARY2FwYWNpdHlJbmNyZW1lbnRJAAxlbGVtZW50Q291bnRbAAtlbGVtZW50RGF0YXQAE1tMamF2YS9sYW5nL09iamVjdDt4cAAAAAAAAAACdXIAE1tMamF2YS5sYW5nLk9iamVjdDuQzlifEHMpbAIAAHhwAAAACnNxAH4ABgFzcQB+AAoAAAAAAAAABHVxAH4ADQAAAApzcQB+AAYBcHEAfgAPdXEAfgANAAAAAnQACnVzZXJPYmplY3RzcgAuY29tLml0YW5nY2VudC5pZGVhLnBsdWdpbi5hcGkuYWNjb3VudC5UcmVlTm9kZQAAAAAAAAABAgAQTAAHYWxsUGF0aHQAEkxqYXZhL2xhbmcvU3RyaW5nO1sAFGJyYW5jaEFuZFZlcnNpb25JdGVtdABLW0xjb20vaXRhbmdjZW50L2lkZWEvcGx1Z2luL2RpYWxvZy9jb21wb25lbnQvYWNjb3VudC9BY2NvdW50UmlnaHRQYW5lbEl0ZW07TAAUYnJhbmNoSWRBbmRWZXJzaW9uSWRxAH4AFkwACGNoaWxkcmVudAAPTGphdmEvdXRpbC9NYXA7TAAKZm9sZGVyVHlwZXEAfgAWTAAIZnVsbFBhdGhxAH4AFkwAA2tleXEAfgAWWwAJbW9kZWxJdGVtcQB+ABdMAAhtb2R1bGVJZHEAfgAWTAAEbmFtZXEAfgAWTAAIcGFyZW50SWRxAH4AFkwACXByb2plY3RJZHEAfgAWTAALcHJvamVjdE5hbWVxAH4AFkwABnRlYW1JZHEAfgAWTAAIdGVhbU5hbWVxAH4AFkwABHR5cGV0ADBMY29tL2l0YW5nY2VudC9pZGVhL3BsdWdpbi9hcGkvYWNjb3VudC9Ob2RlVHlwZTt4cHQAGeS4quS6uuWboumYny/kuKrkurrpobnnm651cgBLW0xjb20uaXRhbmdjZW50LmlkZWEucGx1Z2luLmRpYWxvZy5jb21wb25lbnQuYWNjb3VudC5BY2NvdW50UmlnaHRQYW5lbEl0ZW07KbxSniq4DKkCAAB4cAAAAAFzcgBIY29tLml0YW5nY2VudC5pZGVhLnBsdWdpbi5kaWFsb2cuY29tcG9uZW50LmFjY291bnQuQWNjb3VudFJpZ2h0UGFuZWxJdGVtAAAAAAAAAAECAARaAA9pc01haW5PckRlZmF1bHRMAAhpY29uVHlwZXEAfgAWTAACaWRxAH4AFkwABG5hbWVxAH4AFnhwAXQABmJyYW5jaHQABzY0OTc4NzN0AARtYWlucHNyABdqYXZhLnV0aWwuTGlua2VkSGFzaE1hcDTATlwQbMD7AgABWgALYWNjZXNzT3JkZXJ4cgARamF2YS51dGlsLkhhc2hNYXAFB9rBwxZg0QMAAkYACmxvYWRGYWN0b3JJAAl0aHJlc2hvbGR4cD9AAAAAAAAAdwgAAAAQAAAAAHgAcHB0AAc2Nzg1NDU0dXEAfgAcAAAAAXNxAH4AHgF0AAVtb2RlbHQABzU4Mjg1MDZ0AAzpu5jorqTmqKHlnZdwdAAW5Liq5Lq66aG555uuICg2Nzg1NDU0KXQABzM2NzY2MTd0AAc2Nzg1NDU0dAAM5Liq5Lq66aG555uudAAHMzY3NjYxN3B+cgAuY29tLml0YW5nY2VudC5pZGVhLnBsdWdpbi5hcGkuYWNjb3VudC5Ob2RlVHlwZQAAAAAAAAAAEgAAeHIADmphdmEubGFuZy5FbnVtAAAAAAAAAAASAAB4cHQAB1BST0pFQ1R4c3EAfgAGAXBxAH4AD3VxAH4ADQAAAAJxAH4AFHNxAH4AFXQAE+S4quS6uuWboumYny/ph5HluIFwcHNxAH4AIz9AAAAAAAAAdwgAAAAQAAAAAHgAcHB0AAc2Nzg1NTM0cHB0ABDph5HluIEgKDY3ODU1MzQpdAAHMzY3NjYxN3QABzY3ODU1MzR0AAbph5HluIF0AAczNjc2NjE3cHEAfgAzeHNxAH4ABgFwcQB+AA91cQB+AA0AAAACcQB+ABRzcQB+ABV0ABnkuKrkurrlm6LpmJ8v5a2m5Lmg6K6h5YiSdXEAfgAcAAAAAXNxAH4AHgFxAH4AIHQABzY2MjQzNTN0AARtYWlucHNxAH4AIz9AAAAAAAAAdwgAAAAQAAAAAHgAcHB0AAc2OTA4NDY0dXEAfgAcAAAAAXNxAH4AHgFxAH4AKXQABzU5NTY0ODZ0AAzpu5jorqTmqKHlnZdwdAAW5a2m5Lmg6K6h5YiSICg2OTA4NDY0KXQABzM2NzY2MTd0AAc2OTA4NDY0dAAM5a2m5Lmg6K6h5YiSdAAHMzY3NjYxN3BxAH4AM3hzcQB+AAYBcHEAfgAPdXEAfgANAAAAAnEAfgAUc3EAfgAVdAAT5Liq5Lq65Zui6ZifL+e7g+S5oHBwc3EAfgAjP0AAAAAAAAB3CAAAABAAAAAAeABwcHQABzcxMTg5MzhwcHQAEOe7g+S5oCAoNzExODkzOCl0AAczNjc2NjE3dAAHNzExODkzOHQABue7g+S5oHQABzM2NzY2MTdwcQB+ADN4cHBwcHBweHEAfgAJdXEAfgANAAAAAnEAfgAUc3EAfgAVdAAM5Liq5Lq65Zui6ZifcHBzcQB+ACM/QAAAAAAAAHcIAAAAEAAAAAB4AHBwdAAHMzY3NjYxN3BwdAAM5Liq5Lq65Zui6ZifcHBwdAAHMzY3NjYxN3QADOS4quS6uuWboumYn35xAH4AMXQABFRFQU14c3EAfgAGAXNxAH4ACgAAAAAAAAABdXEAfgANAAAACnNxAH4ABgFwcQB+AGh1cQB+AA0AAAACcQB+ABRzcQB+ABV0ABxEZWVwQ2hhcnTpobnnm67nu4QvRGVlcENoYXJ0cHBzcQB+ACM/QAAAAAAAAHcIAAAAEAAAAAB4AHBwdAAHNzMwMjYxNXBwdAATRGVlcENoYXJ0ICg3MzAyNjE1KXQABzM5MTg0NDh0AAc3MzAyNjE1dAAJRGVlcENoYXJ0dAAHMzkxODQ0OHBxAH4AM3hwcHBwcHBwcHB4cQB+AAl1cQB+AA0AAAACcQB+ABRzcQB+ABV0ABJEZWVwQ2hhcnTpobnnm67nu4RwcHNxAH4AIz9AAAAAAAAAdwgAAAAQAAAAAHgAcHB0AAczOTE4NDQ4cHB0ABJEZWVwQ2hhcnTpobnnm67nu4RwcHB0AAczOTE4NDQ4dAASRGVlcENoYXJ06aG555uu57uEcQB+AGZ4cHBwcHBwcHB4cHVxAH4ADQAAAAJxAH4AFHNxAH4AFXQABFJvb3RwcHBwcHQAATBwcHEAfgCAcHBwcHBxAH4AZnhzcQB+AAoAAAAAAAAAAnVxAH4ADQAAAAp0AARyb290cQB+AAlwcHBwcHBwcHh4&lt;/byte-array&gt;" />
</component>
</project>

1
.idea/compiler.xml

@ -2,6 +2,7 @@
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile default="true" name="Default" enabled="true" />
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />

5
.idea/jarRepositories.xml

@ -3,6 +3,11 @@
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://repo.maven.apache.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>

2
.idea/misc.xml

@ -8,7 +8,7 @@
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="11" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="11" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

45
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<T> {
private Integer code;
private String message;
private T data;
private Long timestamp;
public static <T> ApiResponse<T> success(T data) {
return ApiResponse.<T>builder()
.code(200)
.message("success")
.data(data)
.timestamp(System.currentTimeMillis())
.build();
}
public static <T> ApiResponse<T> error(Integer code, String message) {
return ApiResponse.<T>builder()
.code(code)
.message(message)
.data(null)
.timestamp(System.currentTimeMillis())
.build();
}
public static <T> ApiResponse<T> error(Integer code, String message, T data) {
return ApiResponse.<T>builder()
.code(code)
.message(message)
.data(data)
.timestamp(System.currentTimeMillis())
.build();
}
}

20
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;
}

21
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;
}

48
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;
}

19
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; // 扩展字段
}

16
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;
}

15
lottery-system/lottery-service/pom.xml

@ -26,6 +26,21 @@
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.lottery</groupId>
<artifactId>lottery-common</artifactId>
<version>1.0-SNAPSHOT</version>

2
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);

110
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<List<Seat>> getAllSeats() {
log.debug("获取所有座位状态");
List<Seat> seats = seatSelectionService.getAllSeatsStatus();
return ApiResponse.success(seats);
}
/**
* 选座
*/
@PostMapping("/seat/select")
public ApiResponse<SeatSelectionResult> 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<List<Seat>> getUserSeats(
@RequestParam String homilyId) {
log.debug("查询用户座位:user={}", homilyId);
List<Seat> seats = seatSelectionService.getUserSeats(homilyId);
return ApiResponse.success(seats);
}
/**
* 健康检查
*/
@GetMapping("/health")
public ApiResponse<String> health() {
return ApiResponse.success("OK");
}
/**
* 取消选座释放座位
*/
@PostMapping("/seat/cancel")
public ApiResponse<SeatSelectionResult> 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, "系统异常,请稍后重试");
}
}
}

53
lottery-system/lottery-service/src/main/java/com/lottery/api/service/SeatAsyncSaveService.java

@ -0,0 +1,53 @@
package com.lottery.api.service;
import com.lottery.entity.Seat;
import com.lottery.entity.SeatStatus;
import com.lottery.interceptor.SeatRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
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;
@Autowired
private SeatRepository seatRepository;
@Async("seatExecutor") // 复用你现有的线程池配置
@Transactional(propagation = Propagation.REQUIRES_NEW) // 确保独立事务提交
public void updateStatusByUniqueId(String lockedBy, SeatStatus status) {
int affected = seatRepository.updateStatusByUniqueId(lockedBy, status);
// 🔥 关键记录更新结果便于排查
if (affected == 0) {
log.warn("座位更新失败: uniqueId={}, 可能不存在或已被释放", lockedBy);
// 可选集成告警/监控
} else {
log.debug("座位释放成功: uniqueId={}, affected={}", lockedBy, affected);
}
}
@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;
}
}
}

380
lottery-system/lottery-service/src/main/java/com/lottery/api/service/SeatSelectionService.java

@ -0,0 +1,380 @@
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<String, Object> 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<String, SeatCacheInfo> 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<Seat> getAllSeatsStatus() {
List<Seat> 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<Seat> getUserSeats(String userId) {
List<Seat> 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. 异步更新数据库 🔥 直接调用 repository UPDATE 方法
seatAsyncSaveService.updateStatusByUniqueId(userId, SeatStatus.AVAILABLE);
// 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();
}
}
}

52
lottery-system/lottery-service/src/main/java/com/lottery/config/AsyncConfig.java

@ -0,0 +1,52 @@
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;
}
@Bean("seatExecutor") // 关键Bean 名称必须匹配
public Executor seatExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心参数根据业务 QPS 调整
executor.setCorePoolSize(4); // 核心线程数
executor.setMaxPoolSize(10); // 最大线程数
executor.setQueueCapacity(100); // 队列容量
executor.setKeepAliveSeconds(60); // 空闲线程存活时间
executor.setThreadNamePrefix("seat-async-"); // 线程名前缀便于日志追踪
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 拒绝策略
executor.setWaitForTasksToCompleteOnShutdown(true); // 优雅关闭
executor.setAwaitTerminationSeconds(30);
executor.initialize(); // 🔥 必须调用否则配置不生效
return executor;
}
}

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

25
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");
}
}

18
lottery-system/lottery-service/src/main/java/com/lottery/interceptor/AuthInterceptor.java

@ -58,6 +58,24 @@ 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;
}
if ("/api/seat/cancel".equals(request.getRequestURI())){
return true;
}
// 2. 检查其他接口是否携带 Token

42
lottery-system/lottery-service/src/main/java/com/lottery/interceptor/SeatRepository.java

@ -0,0 +1,42 @@
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<Seat, Long> {
/**
* 根据用户 ID 和状态查询座位一人一座校验用
*/
Optional<Seat> findByLockedByAndStatus(String lockedBy, SeatStatus status);
/**
* 根据唯一 ID 查询座位
*/
Optional<Seat> findByUniqueId(String uniqueId);
/**
* 释放用户的所有锁定座位
*/
@Modifying
@Query("UPDATE Seat s SET s.status = 'AVAILABLE', s.lockedBy = NULL WHERE s.lockedBy = :lockedBy")
int releaseSeatsByUser(@Param("lockedBy") String lockedBy);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE Seat s SET s.status = :status, " +
"s.lockedBy = null, " +
"s.lockedAt = null " +
"WHERE s.lockedBy = :lockedBy")
int updateStatusByUniqueId(@Param("lockedBy") String lockedBy,
@Param("status") SeatStatus status);
}

23
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;
}
Loading…
Cancel
Save