Compare commits
merge into: qimaohong:master
qimaohong:dev
qimaohong:feature/0725lihuilin
qimaohong:jihaipeng/feature-20250714184358-抽奖众筹
qimaohong:master
qimaohong:milestone-20250723-wwl
qimaohong:milestone-20250731-积分频道身份标识
qimaohong:milestone-20260324-订座
qimaohong:wuweili/feature-20250715095139-抽奖众筹
pull from: qimaohong:milestone-20260324-订座
qimaohong:dev
qimaohong:feature/0725lihuilin
qimaohong:jihaipeng/feature-20250714184358-抽奖众筹
qimaohong:master
qimaohong:milestone-20250723-wwl
qimaohong:milestone-20250731-积分频道身份标识
qimaohong:milestone-20260324-订座
qimaohong:wuweili/feature-20250715095139-抽奖众筹
2 Commits
master
...
milestone-
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
7baeab95ee |
3.25 订座系统 修改bug
|
2 weeks ago |
|
|
c49ce3f998 |
3.24 订座系统
|
3 weeks ago |
21 changed files with 933 additions and 1 deletions
-
12.idea/ApifoxUploaderProjectSetting.xml
-
1.idea/compiler.xml
-
5.idea/jarRepositories.xml
-
2.idea/misc.xml
-
45lottery-system/lottery-pojo/src/main/java/com/lottery/dto/ApiResponse.java
-
20lottery-system/lottery-pojo/src/main/java/com/lottery/dto/SeatSelectionRequest.java
-
21lottery-system/lottery-pojo/src/main/java/com/lottery/dto/SeatSelectionResult.java
-
48lottery-system/lottery-pojo/src/main/java/com/lottery/entity/Seat.java
-
19lottery-system/lottery-pojo/src/main/java/com/lottery/entity/SeatCacheInfo.java
-
16lottery-system/lottery-pojo/src/main/java/com/lottery/entity/SeatStatus.java
-
15lottery-system/lottery-service/pom.xml
-
2lottery-system/lottery-service/src/main/java/com/lottery/LotteryApplication.java
-
110lottery-system/lottery-service/src/main/java/com/lottery/api/controller/SeatController.java
-
53lottery-system/lottery-service/src/main/java/com/lottery/api/service/SeatAsyncSaveService.java
-
380lottery-system/lottery-service/src/main/java/com/lottery/api/service/SeatSelectionService.java
-
52lottery-system/lottery-service/src/main/java/com/lottery/config/AsyncConfig.java
-
25lottery-system/lottery-service/src/main/java/com/lottery/config/CorsConfig.java
-
25lottery-system/lottery-service/src/main/java/com/lottery/config/WebSocketConfig.java
-
18lottery-system/lottery-service/src/main/java/com/lottery/interceptor/AuthInterceptor.java
-
42lottery-system/lottery-service/src/main/java/com/lottery/interceptor/SeatRepository.java
-
23lottery-system/lottery-service/src/main/java/com/lottery/websocket/SeatUpdateEvent.java
@ -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="<byte-array>rO0ABXNyADZjb20uaXRhbmdjZW50LmlkZWEucGx1Z2luLmFwaS5hY2NvdW50LlByb2plY3RBbmRNb2R1bGUAAAAAAAAAAQIAFVoABmVuYWJsZUwACG1vZHVsZUlkdAASTGphdmEvbGFuZy9TdHJpbmc7TAAGb3RoZXIxcQB+AAFMAAdvdGhlcjEwcQB+AAFMAAdvdGhlcjExcQB+AAFMAAdvdGhlcjEycQB+AAFMAAZvdGhlcjJxAH4AAUwABm90aGVyM3EAfgABTAAGb3RoZXI0cQB+AAFMAAZvdGhlcjVxAH4AAUwABm90aGVyNnEAfgABTAAGb3RoZXI3cQB+AAFMAAZvdGhlcjhxAH4AAUwABm90aGVyOXEAfgABTAAKcGF0aEJlZm9yZXEAfgABTAANcHJvamVjdEZvbGRlcnEAfgABTAAPcHJvamVjdEZvbGRlcklkcQB+AAFMAAlwcm9qZWN0SWRxAH4AAUwAC3Byb2plY3ROYW1lcQB+AAFMAAxzY2hlbWFGb2xkZXJxAH4AAUwACHNjaGVtYUlkcQB+AAF4cAF0AA9sb3R0ZXJ5LXNlcnZpY2V0AAc2NDk3ODczcHBwdAAHNTgyODUwNnQAC2JyYW5jaC1tYWludAAM6buY6K6k5qih5Z2XcHBwcHB0AAB0AAnmoLnnm67lvZV0AAEwdAAHNjc4NTQ1NHQADOS4quS6uumhueebrnEAfgAJcQB+AAo=</byte-array>" /> |
||||
|
</array> |
||||
|
</option> |
||||
|
<option name="treeNodesJTree" value="<byte-array>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</byte-array>" /> |
||||
|
</component> |
||||
|
</project> |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; // 扩展字段 |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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, "系统异常,请稍后重试"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue