diff --git a/play-admin/pom.xml b/play-admin/pom.xml
index c846fcc..60180cd 100644
--- a/play-admin/pom.xml
+++ b/play-admin/pom.xml
@@ -25,6 +25,10 @@
org.springframework.boot
spring-boot-starter-web
+
+ org.springframework.boot
+ spring-boot-starter-websocket
+
org.flywaydb
diff --git a/play-admin/src/main/java/com/starry/admin/common/config/WebSocketConfig.java b/play-admin/src/main/java/com/starry/admin/common/config/WebSocketConfig.java
new file mode 100644
index 0000000..36b79e1
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/common/config/WebSocketConfig.java
@@ -0,0 +1,32 @@
+package com.starry.admin.common.config;
+
+import com.starry.admin.modules.weichat.constant.WebSocketConstant;
+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;
+
+/**
+ * WebSocket 配置,基于 STOMP 的简单消息代理。
+ */
+@Configuration
+@EnableWebSocketMessageBroker
+public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
+
+ private static final String APPLICATION_DESTINATION_PREFIX = "/app";
+ private static final String TOPIC_DESTINATION_PREFIX = "/topic";
+ private static final String PK_ENDPOINT = "/ws/pk";
+
+ @Override
+ public void configureMessageBroker(MessageBrokerRegistry registry) {
+ registry.enableSimpleBroker(TOPIC_DESTINATION_PREFIX);
+ registry.setApplicationDestinationPrefixes(APPLICATION_DESTINATION_PREFIX);
+ registry.setUserDestinationPrefix(WebSocketConstant.USER_DESTINATION_PREFIX);
+ }
+
+ @Override
+ public void registerStompEndpoints(StompEndpointRegistry registry) {
+ registry.addEndpoint(PK_ENDPOINT).setAllowedOriginPatterns("*");
+ }
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/clerk/controller/PlayClerkPkController.java b/play-admin/src/main/java/com/starry/admin/modules/clerk/controller/PlayClerkPkController.java
index aa063b8..8cdbacc 100644
--- a/play-admin/src/main/java/com/starry/admin/modules/clerk/controller/PlayClerkPkController.java
+++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/controller/PlayClerkPkController.java
@@ -3,6 +3,9 @@ package com.starry.admin.modules.clerk.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
+import com.starry.admin.modules.pk.dto.PkScoreBoardDto;
+import com.starry.admin.modules.pk.service.ClerkPkLifecycleService;
+import com.starry.admin.modules.pk.service.IPkScoreboardService;
import com.starry.common.annotation.Log;
import com.starry.common.enums.BusinessType;
import com.starry.common.result.R;
@@ -13,7 +16,13 @@ import io.swagger.annotations.ApiParam;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
import javax.annotation.Resource;
-import org.springframework.web.bind.annotation.*;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
/**
* 店员pkController
@@ -28,6 +37,12 @@ public class PlayClerkPkController {
@Resource
private IPlayClerkPkService playClerkPkService;
+ @Resource
+ private IPkScoreboardService pkScoreboardService;
+
+ @Resource
+ private ClerkPkLifecycleService clerkPkLifecycleService;
+
/**
* 查询店员pk列表
*/
@@ -51,6 +66,40 @@ public class PlayClerkPkController {
return R.ok(playClerkPkService.selectPlayClerkPkById(id));
}
+ /**
+ * 获取店员PK实时比分
+ */
+ @ApiOperation(value = "获取PK实时比分", notes = "根据ID获取店员PK当前比分")
+ @ApiImplicitParam(name = "id", value = "PK记录ID", required = true, paramType = "path", dataType = "String", example = "1")
+ @ApiResponses({@ApiResponse(code = 200, message = "操作成功", response = PkScoreBoardDto.class)})
+ @GetMapping(value = "/{id}/scoreboard")
+ public R getScoreboard(@PathVariable("id") String id) {
+ PkScoreBoardDto scoreboard = pkScoreboardService.getScoreboard(id);
+ return R.ok(scoreboard);
+ }
+
+ /**
+ * 手动开始PK
+ */
+ @ApiOperation(value = "开始PK", notes = "将指定PK从待开始状态切换为进行中")
+ @ApiImplicitParam(name = "id", value = "PK记录ID", required = true, paramType = "path", dataType = "String", example = "1")
+ @PostMapping(value = "/{id}/start")
+ public R startPk(@PathVariable("id") String id) {
+ clerkPkLifecycleService.startPk(id);
+ return R.ok();
+ }
+
+ /**
+ * 手动结束并结算PK
+ */
+ @ApiOperation(value = "结束PK并结算", notes = "将指定PK标记为已完成,并写入最终比分和胜者信息")
+ @ApiImplicitParam(name = "id", value = "PK记录ID", required = true, paramType = "path", dataType = "String", example = "1")
+ @PostMapping(value = "/{id}/finish")
+ public R finishPk(@PathVariable("id") String id) {
+ clerkPkLifecycleService.finishPk(id);
+ return R.ok();
+ }
+
/**
* 新增店员pk
*/
diff --git a/play-admin/src/main/java/com/starry/admin/modules/clerk/module/entity/PlayClerkPkEntity.java b/play-admin/src/main/java/com/starry/admin/modules/clerk/module/entity/PlayClerkPkEntity.java
index 353faa0..e21f1a0 100644
--- a/play-admin/src/main/java/com/starry/admin/modules/clerk/module/entity/PlayClerkPkEntity.java
+++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/module/entity/PlayClerkPkEntity.java
@@ -87,4 +87,34 @@ public class PlayClerkPkEntity extends BaseEntity {
*/
private String status;
+ /**
+ * 店员A得分
+ */
+ private java.math.BigDecimal clerkAScore;
+
+ /**
+ * 店员B得分
+ */
+ private java.math.BigDecimal clerkBScore;
+
+ /**
+ * 店员A订单数
+ */
+ private Integer clerkAOrderCount;
+
+ /**
+ * 店员B订单数
+ */
+ private Integer clerkBOrderCount;
+
+ /**
+ * 获胜店员ID
+ */
+ private String winnerClerkId;
+
+ /**
+ * 是否已结算(1:是;0:否)
+ */
+ private Integer settled;
+
}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/clerk/service/IPlayClerkPkService.java b/play-admin/src/main/java/com/starry/admin/modules/clerk/service/IPlayClerkPkService.java
index 8a676f1..a84901c 100644
--- a/play-admin/src/main/java/com/starry/admin/modules/clerk/service/IPlayClerkPkService.java
+++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/service/IPlayClerkPkService.java
@@ -3,6 +3,8 @@ package com.starry.admin.modules.clerk.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
+import java.time.LocalDateTime;
+import java.util.Optional;
/**
* 店员pkService接口
@@ -64,4 +66,13 @@ public interface IPlayClerkPkService extends IService {
* @return 结果
*/
int deletePlayClerkPkById(String id);
+
+ /**
+ * 查询某个店员在指定时间是否存在进行中的 PK。
+ *
+ * @param clerkId 店员ID
+ * @param occurredAt 发生时间
+ * @return 存在则返回 PK 记录,否则返回空
+ */
+ Optional findActivePkForClerk(String clerkId, LocalDateTime occurredAt);
}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/clerk/service/impl/PlayClerkPkServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/clerk/service/impl/PlayClerkPkServiceImpl.java
index e3fa550..59e17ac 100644
--- a/play-admin/src/main/java/com/starry/admin/modules/clerk/service/impl/PlayClerkPkServiceImpl.java
+++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/service/impl/PlayClerkPkServiceImpl.java
@@ -14,8 +14,11 @@ import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity;
import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService;
import com.starry.common.utils.IdUtils;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
import java.util.Arrays;
import java.util.Date;
+import java.util.Optional;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -136,4 +139,23 @@ public class PlayClerkPkServiceImpl extends ServiceImpl findActivePkForClerk(String clerkId, LocalDateTime occurredAt) {
+ if (StrUtil.isBlank(clerkId) || occurredAt == null) {
+ return Optional.empty();
+ }
+ Date eventTime =
+ Date.from(occurredAt.atZone(ZoneId.systemDefault()).toInstant());
+ LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(PlayClerkPkEntity.class)
+ .in(PlayClerkPkEntity::getStatus,
+ Arrays.asList(ClerkPkEnum.TO_BE_STARTED.name(), ClerkPkEnum.IN_PROGRESS.name()))
+ .and(w -> w.eq(PlayClerkPkEntity::getClerkA, clerkId)
+ .or()
+ .eq(PlayClerkPkEntity::getClerkB, clerkId))
+ .le(PlayClerkPkEntity::getPkBeginTime, eventTime)
+ .ge(PlayClerkPkEntity::getPkEndTime, eventTime);
+ PlayClerkPkEntity entity = this.getOne(wrapper, false);
+ return Optional.ofNullable(entity);
+ }
}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java
index 1344c88..2d6ca22 100644
--- a/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java
+++ b/play-admin/src/main/java/com/starry/admin/modules/order/service/impl/OrderLifecycleServiceImpl.java
@@ -47,6 +47,7 @@ import com.starry.admin.modules.order.service.IOrderLifecycleService;
import com.starry.admin.modules.order.service.IPlayOrderRefundInfoService;
import com.starry.admin.modules.order.service.support.ClerkRevenueCalculator;
import com.starry.admin.modules.personnel.service.IPlayBalanceDetailsInfoService;
+import com.starry.admin.modules.pk.event.PkContributionEvent;
import com.starry.admin.modules.shop.module.constant.CouponUseState;
import com.starry.admin.modules.shop.module.entity.PlayCouponDetailsEntity;
import com.starry.admin.modules.shop.module.entity.PlayCouponInfoEntity;
@@ -504,6 +505,17 @@ public class OrderLifecycleServiceImpl implements IOrderLifecycleService {
if (shouldNotify) {
notificationSender.sendOrderFinishMessageAsync(latest);
}
+
+ if (OrderStatus.COMPLETED.getCode().equals(latest.getOrderStatus())
+ && latest.getFinalAmount() != null
+ && latest.getFinalAmount().compareTo(BigDecimal.ZERO) > 0
+ && StrUtil.isNotBlank(latest.getAcceptBy())) {
+ LocalDateTime contributionTime =
+ latest.getOrderEndTime() != null ? latest.getOrderEndTime() : endTime;
+ applicationEventPublisher.publishEvent(
+ PkContributionEvent.orderContribution(latest.getId(), latest.getAcceptBy(),
+ latest.getFinalAmount(), contributionTime));
+ }
}
@Override
diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/dto/PkEventMessage.java b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/PkEventMessage.java
new file mode 100644
index 0000000..01e2b4a
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/PkEventMessage.java
@@ -0,0 +1,53 @@
+package com.starry.admin.modules.pk.dto;
+
+/**
+ * 通过 WebSocket 推送的 PK 事件消息。
+ */
+public class PkEventMessage {
+
+ private PkEventType type;
+ private String pkId;
+ private long timestamp;
+ private T payload;
+
+ public static PkEventMessage of(PkEventType type, String pkId, T payload, long timestamp) {
+ PkEventMessage message = new PkEventMessage<>();
+ message.setType(type);
+ message.setPkId(pkId);
+ message.setPayload(payload);
+ message.setTimestamp(timestamp);
+ return message;
+ }
+
+ public PkEventType getType() {
+ return type;
+ }
+
+ public void setType(PkEventType type) {
+ this.type = type;
+ }
+
+ public String getPkId() {
+ return pkId;
+ }
+
+ public void setPkId(String pkId) {
+ this.pkId = pkId;
+ }
+
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ public void setTimestamp(long timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ public T getPayload() {
+ return payload;
+ }
+
+ public void setPayload(T payload) {
+ this.payload = payload;
+ }
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/dto/PkEventType.java b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/PkEventType.java
new file mode 100644
index 0000000..a19a233
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/PkEventType.java
@@ -0,0 +1,9 @@
+package com.starry.admin.modules.pk.dto;
+
+/**
+ * PK WebSocket 事件类型。
+ */
+public enum PkEventType {
+ SCORE_UPDATE,
+ STATE_CHANGE
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/dto/PkScoreBoardDto.java b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/PkScoreBoardDto.java
new file mode 100644
index 0000000..74fd870
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/PkScoreBoardDto.java
@@ -0,0 +1,97 @@
+package com.starry.admin.modules.pk.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import java.math.BigDecimal;
+
+@ApiModel(value = "PkScoreBoardDto", description = "店员 PK 实时比分")
+public class PkScoreBoardDto {
+
+ @ApiModelProperty(value = "PK ID", required = true)
+ private String pkId;
+
+ @ApiModelProperty(value = "店员A ID", required = true)
+ private String clerkAId;
+
+ @ApiModelProperty(value = "店员B ID", required = true)
+ private String clerkBId;
+
+ @ApiModelProperty(value = "店员A得分", required = true)
+ private BigDecimal clerkAScore;
+
+ @ApiModelProperty(value = "店员B得分", required = true)
+ private BigDecimal clerkBScore;
+
+ @ApiModelProperty(value = "店员A订单数", required = true)
+ private int clerkAOrderCount;
+
+ @ApiModelProperty(value = "店员B订单数", required = true)
+ private int clerkBOrderCount;
+
+ @ApiModelProperty(value = "PK 剩余秒数", required = true)
+ private long remainingSeconds;
+
+ public String getPkId() {
+ return pkId;
+ }
+
+ public void setPkId(String pkId) {
+ this.pkId = pkId;
+ }
+
+ public String getClerkAId() {
+ return clerkAId;
+ }
+
+ public void setClerkAId(String clerkAId) {
+ this.clerkAId = clerkAId;
+ }
+
+ public String getClerkBId() {
+ return clerkBId;
+ }
+
+ public void setClerkBId(String clerkBId) {
+ this.clerkBId = clerkBId;
+ }
+
+ public BigDecimal getClerkAScore() {
+ return clerkAScore;
+ }
+
+ public void setClerkAScore(BigDecimal clerkAScore) {
+ this.clerkAScore = clerkAScore;
+ }
+
+ public BigDecimal getClerkBScore() {
+ return clerkBScore;
+ }
+
+ public void setClerkBScore(BigDecimal clerkBScore) {
+ this.clerkBScore = clerkBScore;
+ }
+
+ public int getClerkAOrderCount() {
+ return clerkAOrderCount;
+ }
+
+ public void setClerkAOrderCount(int clerkAOrderCount) {
+ this.clerkAOrderCount = clerkAOrderCount;
+ }
+
+ public int getClerkBOrderCount() {
+ return clerkBOrderCount;
+ }
+
+ public void setClerkBOrderCount(int clerkBOrderCount) {
+ this.clerkBOrderCount = clerkBOrderCount;
+ }
+
+ public long getRemainingSeconds() {
+ return remainingSeconds;
+ }
+
+ public void setRemainingSeconds(long remainingSeconds) {
+ this.remainingSeconds = remainingSeconds;
+ }
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/event/PkContributionEvent.java b/play-admin/src/main/java/com/starry/admin/modules/pk/event/PkContributionEvent.java
new file mode 100644
index 0000000..7114ff4
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/pk/event/PkContributionEvent.java
@@ -0,0 +1,74 @@
+package com.starry.admin.modules.pk.event;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.Objects;
+
+/**
+ * 店员在 PK 期间产生的一次贡献事件。
+ */
+public final class PkContributionEvent {
+
+ /**
+ * 贡献来源的业务唯一ID,例如订单ID、礼物记录ID等。
+ */
+ private final String referenceId;
+
+ private final String clerkId;
+ private final BigDecimal amount;
+ private final PkContributionSource source;
+ private final LocalDateTime occurredAt;
+
+ private PkContributionEvent(String referenceId, String clerkId, BigDecimal amount, PkContributionSource source,
+ LocalDateTime occurredAt) {
+ this.referenceId = Objects.requireNonNull(referenceId, "referenceId cannot be null");
+ this.clerkId = Objects.requireNonNull(clerkId, "clerkId cannot be null");
+ this.amount = Objects.requireNonNull(amount, "amount cannot be null");
+ this.source = Objects.requireNonNull(source, "source cannot be null");
+ this.occurredAt = Objects.requireNonNull(occurredAt, "occurredAt cannot be null");
+ if (this.referenceId.isEmpty()) {
+ throw new IllegalArgumentException("referenceId cannot be empty");
+ }
+ if (this.clerkId.isEmpty()) {
+ throw new IllegalArgumentException("clerkId cannot be empty");
+ }
+ if (this.amount.compareTo(BigDecimal.ZERO) < 0) {
+ throw new IllegalArgumentException("amount cannot be negative");
+ }
+ }
+
+ public static PkContributionEvent orderContribution(String orderId, String clerkId, BigDecimal amount,
+ LocalDateTime occurredAt) {
+ return new PkContributionEvent(orderId, clerkId, amount, PkContributionSource.ORDER, occurredAt);
+ }
+
+ public static PkContributionEvent giftContribution(String giftRecordId, String clerkId, BigDecimal amount,
+ LocalDateTime occurredAt) {
+ return new PkContributionEvent(giftRecordId, clerkId, amount, PkContributionSource.GIFT, occurredAt);
+ }
+
+ public static PkContributionEvent rechargeContribution(String rechargeRecordId, String clerkId, BigDecimal amount,
+ LocalDateTime occurredAt) {
+ return new PkContributionEvent(rechargeRecordId, clerkId, amount, PkContributionSource.RECHARGE, occurredAt);
+ }
+
+ public String getReferenceId() {
+ return referenceId;
+ }
+
+ public String getClerkId() {
+ return clerkId;
+ }
+
+ public BigDecimal getAmount() {
+ return amount;
+ }
+
+ public PkContributionSource getSource() {
+ return source;
+ }
+
+ public LocalDateTime getOccurredAt() {
+ return occurredAt;
+ }
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/event/PkContributionSource.java b/play-admin/src/main/java/com/starry/admin/modules/pk/event/PkContributionSource.java
new file mode 100644
index 0000000..a83fc7c
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/pk/event/PkContributionSource.java
@@ -0,0 +1,10 @@
+package com.starry.admin.modules.pk.event;
+
+/**
+ * PK 贡献来源类型。
+ */
+public enum PkContributionSource {
+ ORDER,
+ GIFT,
+ RECHARGE
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/event/PkScoreChangedEvent.java b/play-admin/src/main/java/com/starry/admin/modules/pk/event/PkScoreChangedEvent.java
new file mode 100644
index 0000000..39fc338
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/pk/event/PkScoreChangedEvent.java
@@ -0,0 +1,22 @@
+package com.starry.admin.modules.pk.event;
+
+import java.util.Objects;
+
+/**
+ * 某一场 PK 的比分发生变化。
+ */
+public final class PkScoreChangedEvent {
+
+ private final String pkId;
+
+ public PkScoreChangedEvent(String pkId) {
+ this.pkId = Objects.requireNonNull(pkId, "pkId cannot be null");
+ if (this.pkId.isEmpty()) {
+ throw new IllegalArgumentException("pkId cannot be empty");
+ }
+ }
+
+ public String getPkId() {
+ return pkId;
+ }
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/listener/PkContributionListener.java b/play-admin/src/main/java/com/starry/admin/modules/pk/listener/PkContributionListener.java
new file mode 100644
index 0000000..6835242
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/pk/listener/PkContributionListener.java
@@ -0,0 +1,71 @@
+package com.starry.admin.modules.pk.listener;
+
+import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
+import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
+import com.starry.admin.modules.pk.event.PkContributionEvent;
+import com.starry.admin.modules.pk.event.PkScoreChangedEvent;
+import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
+import java.time.LocalDateTime;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import javax.annotation.Resource;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.context.event.EventListener;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Component;
+
+/**
+ * 监听订单/礼物/充值产生的 PK 贡献事件,更新 Redis 中的比分。
+ */
+@Component
+public class PkContributionListener {
+
+ private static final long SINGLE_CONTRIBUTION_COUNT = 1L;
+
+ @Resource
+ private IPlayClerkPkService clerkPkService;
+
+ @Resource
+ private StringRedisTemplate stringRedisTemplate;
+
+ @Resource
+ private ApplicationEventPublisher applicationEventPublisher;
+
+ @EventListener
+ public void onContribution(PkContributionEvent event) {
+ if (!acquireDedup(event)) {
+ return;
+ }
+ Optional pkOptional =
+ clerkPkService.findActivePkForClerk(event.getClerkId(), event.getOccurredAt());
+ if (!pkOptional.isPresent()) {
+ return;
+ }
+ PlayClerkPkEntity pk = pkOptional.get();
+ updateRedisScore(pk, event);
+ applicationEventPublisher.publishEvent(new PkScoreChangedEvent(pk.getId()));
+ }
+
+ private boolean acquireDedup(PkContributionEvent event) {
+ String dedupKey =
+ PkRedisKeyConstants.contributionDedupKey(event.getSource().name(), event.getReferenceId());
+ Boolean firstSeen = stringRedisTemplate.opsForValue()
+ .setIfAbsent(dedupKey, "1", PkRedisKeyConstants.CONTRIBUTION_DEDUP_TTL_SECONDS, TimeUnit.SECONDS);
+ return firstSeen == null || firstSeen;
+ }
+
+ private void updateRedisScore(PlayClerkPkEntity pk, PkContributionEvent event) {
+ String pkId = pk.getId();
+ if (pkId == null || pkId.isEmpty()) {
+ return;
+ }
+ boolean isClerkA = event.getClerkId().equals(pk.getClerkA());
+ String scoreField = isClerkA ? PkRedisKeyConstants.FIELD_CLERK_A_SCORE : PkRedisKeyConstants.FIELD_CLERK_B_SCORE;
+ String countField =
+ isClerkA ? PkRedisKeyConstants.FIELD_CLERK_A_ORDER_COUNT : PkRedisKeyConstants.FIELD_CLERK_B_ORDER_COUNT;
+
+ String scoreKey = PkRedisKeyConstants.scoreKey(pkId);
+ stringRedisTemplate.opsForHash().increment(scoreKey, scoreField, event.getAmount().doubleValue());
+ stringRedisTemplate.opsForHash().increment(scoreKey, countField, SINGLE_CONTRIBUTION_COUNT);
+ }
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/listener/PkScoreChangedListener.java b/play-admin/src/main/java/com/starry/admin/modules/pk/listener/PkScoreChangedListener.java
new file mode 100644
index 0000000..78d87d4
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/pk/listener/PkScoreChangedListener.java
@@ -0,0 +1,37 @@
+package com.starry.admin.modules.pk.listener;
+
+import com.starry.admin.modules.pk.dto.PkEventMessage;
+import com.starry.admin.modules.pk.dto.PkEventType;
+import com.starry.admin.modules.pk.dto.PkScoreBoardDto;
+import com.starry.admin.modules.pk.event.PkScoreChangedEvent;
+import com.starry.admin.modules.pk.service.IPkScoreboardService;
+import javax.annotation.Resource;
+import org.springframework.context.event.EventListener;
+import org.springframework.messaging.simp.SimpMessagingTemplate;
+import org.springframework.stereotype.Component;
+
+/**
+ * 监听比分变化事件,通过 WebSocket 将最新比分推送给订阅方。
+ */
+@Component
+public class PkScoreChangedListener {
+
+ private static final String PK_TOPIC_PREFIX = "/topic/pk/";
+
+ @Resource
+ private IPkScoreboardService pkScoreboardService;
+
+ @Resource
+ private SimpMessagingTemplate messagingTemplate;
+
+ @EventListener
+ public void onScoreChanged(PkScoreChangedEvent event) {
+ PkScoreBoardDto scoreboard = pkScoreboardService.getScoreboard(event.getPkId());
+ PkEventMessage message = PkEventMessage.of(
+ PkEventType.SCORE_UPDATE,
+ event.getPkId(),
+ scoreboard,
+ System.currentTimeMillis());
+ messagingTemplate.convertAndSend(PK_TOPIC_PREFIX + event.getPkId(), message);
+ }
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/redis/PkRedisKeyConstants.java b/play-admin/src/main/java/com/starry/admin/modules/pk/redis/PkRedisKeyConstants.java
new file mode 100644
index 0000000..dec6c14
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/pk/redis/PkRedisKeyConstants.java
@@ -0,0 +1,32 @@
+package com.starry.admin.modules.pk.redis;
+
+/**
+ * PK 相关 Redis key 常量。
+ */
+public final class PkRedisKeyConstants {
+
+ private static final String SCORE_HASH_PREFIX = "pk:";
+ private static final String SCORE_HASH_SUFFIX = ":score";
+ private static final String DEDUP_KEY_PREFIX = "pk:dedup:";
+
+ /**
+ * 贡献幂等记录的存活时间(秒)。
+ */
+ public static final long CONTRIBUTION_DEDUP_TTL_SECONDS = 3600L;
+
+ public static final String FIELD_CLERK_A_SCORE = "clerk_a_score";
+ public static final String FIELD_CLERK_B_SCORE = "clerk_b_score";
+ public static final String FIELD_CLERK_A_ORDER_COUNT = "clerk_a_order_count";
+ public static final String FIELD_CLERK_B_ORDER_COUNT = "clerk_b_order_count";
+
+ private PkRedisKeyConstants() {
+ }
+
+ public static String scoreKey(String pkId) {
+ return SCORE_HASH_PREFIX + pkId + SCORE_HASH_SUFFIX;
+ }
+
+ public static String contributionDedupKey(String sourceCode, String referenceId) {
+ return DEDUP_KEY_PREFIX + sourceCode + ":" + referenceId;
+ }
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/service/ClerkPkLifecycleService.java b/play-admin/src/main/java/com/starry/admin/modules/pk/service/ClerkPkLifecycleService.java
new file mode 100644
index 0000000..2904f59
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/pk/service/ClerkPkLifecycleService.java
@@ -0,0 +1,26 @@
+package com.starry.admin.modules.pk.service;
+
+/**
+ * 店员 PK 生命周期管理服务。
+ */
+public interface ClerkPkLifecycleService {
+
+ /**
+ * 启动指定 PK(从待开始进入进行中)。
+ *
+ * @param pkId PK ID
+ */
+ void startPk(String pkId);
+
+ /**
+ * 完成并结算指定 PK。
+ *
+ * @param pkId PK ID
+ */
+ void finishPk(String pkId);
+
+ /**
+ * 扫描当前需要状态流转的 PK。
+ */
+ void scanAndUpdate();
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/service/IPkScoreboardService.java b/play-admin/src/main/java/com/starry/admin/modules/pk/service/IPkScoreboardService.java
new file mode 100644
index 0000000..4506959
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/pk/service/IPkScoreboardService.java
@@ -0,0 +1,17 @@
+package com.starry.admin.modules.pk.service;
+
+import com.starry.admin.modules.pk.dto.PkScoreBoardDto;
+
+/**
+ * PK 实时比分查询服务。
+ */
+public interface IPkScoreboardService {
+
+ /**
+ * 查询指定 PK 的实时比分。
+ *
+ * @param pkId PK ID
+ * @return 比分信息(不会为 null)
+ */
+ PkScoreBoardDto getScoreboard(String pkId);
+}
diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/service/impl/ClerkPkLifecycleServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/pk/service/impl/ClerkPkLifecycleServiceImpl.java
new file mode 100644
index 0000000..d717a17
--- /dev/null
+++ b/play-admin/src/main/java/com/starry/admin/modules/pk/service/impl/ClerkPkLifecycleServiceImpl.java
@@ -0,0 +1,142 @@
+package com.starry.admin.modules.pk.service.impl;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.starry.admin.common.exception.CustomException;
+import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum;
+import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity;
+import com.starry.admin.modules.clerk.service.IPlayClerkPkService;
+import com.starry.admin.modules.pk.redis.PkRedisKeyConstants;
+import com.starry.admin.modules.pk.service.ClerkPkLifecycleService;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Date;
+import java.util.Map;
+import javax.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@Service
+public class ClerkPkLifecycleServiceImpl implements ClerkPkLifecycleService {
+
+ @Resource
+ private IPlayClerkPkService clerkPkService;
+
+ @Resource
+ private StringRedisTemplate stringRedisTemplate;
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void startPk(String pkId) {
+ PlayClerkPkEntity pk = clerkPkService.selectPlayClerkPkById(pkId);
+ if (pk == null) {
+ throw new CustomException("PK不存在");
+ }
+ if (!ClerkPkEnum.TO_BE_STARTED.name().equals(pk.getStatus())) {
+ return;
+ }
+ LocalDateTime now = LocalDateTime.now();
+ if (pk.getPkBeginTime() != null
+ && LocalDateTime.ofInstant(pk.getPkBeginTime().toInstant(), ZoneId.systemDefault()).isAfter(now)) {
+ throw new CustomException("PK开始时间尚未到达");
+ }
+ pk.setStatus(ClerkPkEnum.IN_PROGRESS.name());
+ clerkPkService.updateById(pk);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void finishPk(String pkId) {
+ PlayClerkPkEntity pk = clerkPkService.selectPlayClerkPkById(pkId);
+ if (pk == null) {
+ throw new CustomException("PK不存在");
+ }
+ if (ClerkPkEnum.FINISHED.name().equals(pk.getStatus())) {
+ return;
+ }
+ String scoreKey = PkRedisKeyConstants.scoreKey(pkId);
+ Map