21 changed files with 895 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
-
33lottery-system/lottery-service/src/main/java/com/lottery/api/service/SeatAsyncSaveService.java
-
386lottery-system/lottery-service/src/main/java/com/lottery/api/service/SeatSelectionService.java
-
34lottery-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
-
15lottery-system/lottery-service/src/main/java/com/lottery/interceptor/AuthInterceptor.java
-
39lottery-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,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; |
|||
} |
|||
} |
|||
} |
|||
@ -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<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. 异步更新数据库 |
|||
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(); |
|||
} |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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,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<Seat, Long> { |
|||
|
|||
/** |
|||
* 根据用户 ID 和状态查询座位(一人一座校验用) |
|||
*/ |
|||
Optional<Seat> findByLockedByAndStatus(String lockedBy, SeatStatus status); |
|||
|
|||
/** |
|||
* 根据唯一 ID 查询座位 |
|||
*/ |
|||
Optional<Seat> 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); |
|||
} |
|||
@ -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