From a7e567e9b413c3c3c4cbc9c491087504d8602c56 Mon Sep 17 00:00:00 2001 From: irving Date: Wed, 24 Dec 2025 10:55:26 -0500 Subject: [PATCH] working but not tested --- play-admin/pom.xml | 4 + .../admin/common/config/WebSocketConfig.java | 32 ++++ .../controller/PlayClerkPkController.java | 51 +++++- .../module/entity/PlayClerkPkEntity.java | 30 ++++ .../clerk/service/IPlayClerkPkService.java | 11 ++ .../service/impl/PlayClerkPkServiceImpl.java | 22 +++ .../impl/OrderLifecycleServiceImpl.java | 12 ++ .../admin/modules/pk/dto/PkEventMessage.java | 53 ++++++ .../admin/modules/pk/dto/PkEventType.java | 9 ++ .../admin/modules/pk/dto/PkScoreBoardDto.java | 97 +++++++++++ .../modules/pk/event/PkContributionEvent.java | 74 +++++++++ .../pk/event/PkContributionSource.java | 10 ++ .../modules/pk/event/PkScoreChangedEvent.java | 22 +++ .../pk/listener/PkContributionListener.java | 71 ++++++++ .../pk/listener/PkScoreChangedListener.java | 37 +++++ .../modules/pk/redis/PkRedisKeyConstants.java | 32 ++++ .../pk/service/ClerkPkLifecycleService.java | 26 +++ .../pk/service/IPkScoreboardService.java | 17 ++ .../impl/ClerkPkLifecycleServiceImpl.java | 142 ++++++++++++++++ .../service/impl/PkScoreboardServiceImpl.java | 87 ++++++++++ .../V18__add_pk_scores_and_winner.sql | 8 + .../admin/modules/pk/PkIntegrationTest.java | 151 ++++++++++++++++++ 22 files changed, 997 insertions(+), 1 deletion(-) create mode 100644 play-admin/src/main/java/com/starry/admin/common/config/WebSocketConfig.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/dto/PkEventMessage.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/dto/PkEventType.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/dto/PkScoreBoardDto.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/event/PkContributionEvent.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/event/PkContributionSource.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/event/PkScoreChangedEvent.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/listener/PkContributionListener.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/listener/PkScoreChangedListener.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/redis/PkRedisKeyConstants.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/service/ClerkPkLifecycleService.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/service/IPkScoreboardService.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/service/impl/ClerkPkLifecycleServiceImpl.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/service/impl/PkScoreboardServiceImpl.java create mode 100644 play-admin/src/main/resources/db/migration/V18__add_pk_scores_and_winner.sql create mode 100644 play-admin/src/test/java/com/starry/admin/modules/pk/PkIntegrationTest.java 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 entries = stringRedisTemplate.opsForHash().entries(scoreKey); + + BigDecimal clerkAScore = parseDecimal(entries.get(PkRedisKeyConstants.FIELD_CLERK_A_SCORE)); + BigDecimal clerkBScore = parseDecimal(entries.get(PkRedisKeyConstants.FIELD_CLERK_B_SCORE)); + int clerkAOrderCount = parseInt(entries.get(PkRedisKeyConstants.FIELD_CLERK_A_ORDER_COUNT)); + int clerkBOrderCount = parseInt(entries.get(PkRedisKeyConstants.FIELD_CLERK_B_ORDER_COUNT)); + + pk.setClerkAScore(clerkAScore); + pk.setClerkBScore(clerkBScore); + pk.setClerkAOrderCount(clerkAOrderCount); + pk.setClerkBOrderCount(clerkBOrderCount); + pk.setStatus(ClerkPkEnum.FINISHED.name()); + pk.setSettled(1); + if (clerkAScore.compareTo(clerkBScore) > 0) { + pk.setWinnerClerkId(pk.getClerkA()); + } else if (clerkBScore.compareTo(clerkAScore) > 0) { + pk.setWinnerClerkId(pk.getClerkB()); + } else { + pk.setWinnerClerkId(null); + } + clerkPkService.updateById(pk); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void scanAndUpdate() { + LocalDateTime now = LocalDateTime.now(); + Date nowDate = Date.from(now.atZone(ZoneId.systemDefault()).toInstant()); + + clerkPkService.list(Wrappers.lambdaQuery() + .eq(PlayClerkPkEntity::getStatus, ClerkPkEnum.TO_BE_STARTED.name()) + .le(PlayClerkPkEntity::getPkBeginTime, nowDate)) + .forEach(pk -> { + try { + startPk(pk.getId()); + } catch (Exception e) { + log.warn("自动开始PK失败, pkId={}", pk.getId(), e); + } + }); + + clerkPkService.list(Wrappers.lambdaQuery() + .in(PlayClerkPkEntity::getStatus, + ClerkPkEnum.IN_PROGRESS.name(), ClerkPkEnum.TO_BE_STARTED.name()) + .le(PlayClerkPkEntity::getPkEndTime, nowDate) + .eq(PlayClerkPkEntity::getSettled, 0)) + .forEach(pk -> { + try { + finishPk(pk.getId()); + } catch (Exception e) { + log.warn("自动结束PK失败, pkId={}", pk.getId(), e); + } + }); + } + + @Scheduled(fixedDelay = 30000) + public void scheduledScan() { + scanAndUpdate(); + } + + private static BigDecimal parseDecimal(Object value) { + if (value == null) { + return BigDecimal.ZERO; + } + try { + return new BigDecimal(value.toString()); + } catch (NumberFormatException ex) { + return BigDecimal.ZERO; + } + } + + private static int parseInt(Object value) { + if (value == null) { + return 0; + } + try { + return Integer.parseInt(value.toString()); + } catch (NumberFormatException ex) { + return 0; + } + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/service/impl/PkScoreboardServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/pk/service/impl/PkScoreboardServiceImpl.java new file mode 100644 index 0000000..f12a456 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/service/impl/PkScoreboardServiceImpl.java @@ -0,0 +1,87 @@ +package com.starry.admin.modules.pk.service.impl; + +import com.starry.admin.common.exception.CustomException; +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.redis.PkRedisKeyConstants; +import com.starry.admin.modules.pk.service.IPkScoreboardService; +import java.math.BigDecimal; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Map; +import javax.annotation.Resource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +@Service +public class PkScoreboardServiceImpl implements IPkScoreboardService { + + @Resource + private IPlayClerkPkService clerkPkService; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + @Override + public PkScoreBoardDto getScoreboard(String pkId) { + PlayClerkPkEntity pk = clerkPkService.selectPlayClerkPkById(pkId); + if (pk == null) { + throw new CustomException("PK不存在"); + } + PkScoreBoardDto dto = new PkScoreBoardDto(); + dto.setPkId(pk.getId()); + dto.setClerkAId(pk.getClerkA()); + dto.setClerkBId(pk.getClerkB()); + + String scoreKey = PkRedisKeyConstants.scoreKey(pkId); + Map entries = stringRedisTemplate.opsForHash().entries(scoreKey); + + BigDecimal clerkAScore = parseDecimal(entries.get(PkRedisKeyConstants.FIELD_CLERK_A_SCORE)); + BigDecimal clerkBScore = parseDecimal(entries.get(PkRedisKeyConstants.FIELD_CLERK_B_SCORE)); + int clerkAOrderCount = parseInt(entries.get(PkRedisKeyConstants.FIELD_CLERK_A_ORDER_COUNT)); + int clerkBOrderCount = parseInt(entries.get(PkRedisKeyConstants.FIELD_CLERK_B_ORDER_COUNT)); + + dto.setClerkAScore(clerkAScore); + dto.setClerkBScore(clerkBScore); + dto.setClerkAOrderCount(clerkAOrderCount); + dto.setClerkBOrderCount(clerkBOrderCount); + dto.setRemainingSeconds(calculateRemainingSeconds(pk)); + return dto; + } + + private static BigDecimal parseDecimal(Object value) { + if (value == null) { + return BigDecimal.ZERO; + } + try { + return new BigDecimal(value.toString()); + } catch (NumberFormatException ex) { + return BigDecimal.ZERO; + } + } + + private static int parseInt(Object value) { + if (value == null) { + return 0; + } + try { + return Integer.parseInt(value.toString()); + } catch (NumberFormatException ex) { + return 0; + } + } + + private static long calculateRemainingSeconds(PlayClerkPkEntity pk) { + if (pk.getPkEndTime() == null) { + return 0L; + } + LocalDateTime now = LocalDateTime.now(); + LocalDateTime endTime = LocalDateTime.ofInstant(pk.getPkEndTime().toInstant(), ZoneId.systemDefault()); + if (endTime.isBefore(now)) { + return 0L; + } + return Duration.between(now, endTime).getSeconds(); + } +} diff --git a/play-admin/src/main/resources/db/migration/V18__add_pk_scores_and_winner.sql b/play-admin/src/main/resources/db/migration/V18__add_pk_scores_and_winner.sql new file mode 100644 index 0000000..8ad2cf0 --- /dev/null +++ b/play-admin/src/main/resources/db/migration/V18__add_pk_scores_and_winner.sql @@ -0,0 +1,8 @@ +ALTER TABLE `play_clerk_pk` + ADD COLUMN `clerk_a_score` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '店员A得分' AFTER `status`, + ADD COLUMN `clerk_b_score` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '店员B得分' AFTER `clerk_a_score`, + ADD COLUMN `clerk_a_order_count` int NOT NULL DEFAULT 0 COMMENT '店员A订单数' AFTER `clerk_b_score`, + ADD COLUMN `clerk_b_order_count` int NOT NULL DEFAULT 0 COMMENT '店员B订单数' AFTER `clerk_a_order_count`, + ADD COLUMN `winner_clerk_id` varchar(32) DEFAULT NULL COMMENT '获胜店员ID' AFTER `clerk_b_order_count`, + ADD COLUMN `settled` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否已结算(1:是;0:否)' AFTER `winner_clerk_id`; + diff --git a/play-admin/src/test/java/com/starry/admin/modules/pk/PkIntegrationTest.java b/play-admin/src/test/java/com/starry/admin/modules/pk/PkIntegrationTest.java new file mode 100644 index 0000000..824fe5d --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/modules/pk/PkIntegrationTest.java @@ -0,0 +1,151 @@ +package com.starry.admin.modules.pk; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.starry.admin.api.AbstractApiTest; +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.dto.PkScoreBoardDto; +import com.starry.admin.modules.pk.event.PkContributionEvent; +import com.starry.admin.modules.pk.redis.PkRedisKeyConstants; +import com.starry.admin.modules.pk.service.ClerkPkLifecycleService; +import com.starry.admin.modules.pk.service.IPkScoreboardService; +import com.starry.admin.utils.SecurityUtils; +import com.starry.common.utils.IdUtils; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import javax.annotation.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.redis.core.StringRedisTemplate; + +/** + * 集成测试:验证 PK 贡献事件、Redis 计分、结算生命周期的整体行为。 + */ +class PkIntegrationTest extends AbstractApiTest { + + @Resource + private IPlayClerkPkService clerkPkService; + + @Resource + private IPkScoreboardService pkScoreboardService; + + @Resource + private ClerkPkLifecycleService clerkPkLifecycleService; + + @Resource + private com.starry.admin.modules.pk.listener.PkContributionListener pkContributionListener; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + @BeforeEach + void clearPkKeys() { + if (stringRedisTemplate.getConnectionFactory() == null) { + return; + } + stringRedisTemplate.getConnectionFactory().getConnection().flushDb(); + } + + @Test + @DisplayName("PK 贡献事件应累加到 Redis 中的比分和订单数") + void contributionEventsShouldAccumulateScoresInRedis() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + String clerkAId = "pk-clerk-a"; + String clerkBId = "pk-clerk-b"; + + SecurityUtils.setTenantId(DEFAULT_TENANT); + PlayClerkPkEntity pk = buildActivePk(pkId, clerkAId, clerkBId, now); + clerkPkService.save(pk); + + String scoreKey = PkRedisKeyConstants.scoreKey(pkId); + stringRedisTemplate.delete(scoreKey); + + assertThat(clerkPkService.findActivePkForClerk(clerkAId, now)).isPresent(); + assertThat(clerkPkService.findActivePkForClerk(clerkBId, now)).isPresent(); + + pkContributionListener.onContribution( + PkContributionEvent.orderContribution("order-1", clerkAId, new BigDecimal("100.50"), now)); + // duplicate event for same order should be ignored + pkContributionListener.onContribution( + PkContributionEvent.orderContribution("order-1", clerkAId, new BigDecimal("100.50"), now)); + pkContributionListener.onContribution( + PkContributionEvent.orderContribution("order-2", clerkBId, new BigDecimal("50.25"), now)); + pkContributionListener.onContribution( + PkContributionEvent.orderContribution("order-3", clerkAId, new BigDecimal("20.00"), now)); + + PkScoreBoardDto scoreboard = pkScoreboardService.getScoreboard(pkId); + + assertThat(scoreboard.getPkId()).isEqualTo(pkId); + assertThat(scoreboard.getClerkAId()).isEqualTo(clerkAId); + assertThat(scoreboard.getClerkBId()).isEqualTo(clerkBId); + assertThat(scoreboard.getClerkAScore()).isEqualByComparingTo(new BigDecimal("120.50")); + assertThat(scoreboard.getClerkBScore()).isEqualByComparingTo(new BigDecimal("50.25")); + assertThat(scoreboard.getClerkAOrderCount()).isEqualTo(2); + assertThat(scoreboard.getClerkBOrderCount()).isEqualTo(1); + } + + @Test + @DisplayName("finishPk 应从 Redis 读取比分并写回数据库,同时标记赢家") + void finishPkShouldPersistScoresAndWinner() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + String clerkAId = "pk-clerk-a2"; + String clerkBId = "pk-clerk-b2"; + + SecurityUtils.setTenantId(DEFAULT_TENANT); + PlayClerkPkEntity pk = buildActivePk(pkId, clerkAId, clerkBId, now); + clerkPkService.save(pk); + + String scoreKey = PkRedisKeyConstants.scoreKey(pkId); + stringRedisTemplate.delete(scoreKey); + + pkContributionListener.onContribution( + PkContributionEvent.orderContribution("order-a1", clerkAId, new BigDecimal("80.00"), now)); + pkContributionListener.onContribution( + PkContributionEvent.orderContribution("order-b1", clerkBId, new BigDecimal("30.00"), now)); + pkContributionListener.onContribution( + PkContributionEvent.orderContribution("order-a2", clerkAId, new BigDecimal("40.00"), now)); + + PkScoreBoardDto beforeFinish = pkScoreboardService.getScoreboard(pkId); + + clerkPkLifecycleService.finishPk(pkId); + PlayClerkPkEntity persisted = clerkPkService.selectPlayClerkPkById(pkId); + + assertThat(persisted.getStatus()).isEqualTo(ClerkPkEnum.FINISHED.name()); + assertThat(persisted.getSettled()).isEqualTo(1); + assertThat(persisted.getClerkAScore()).isEqualByComparingTo(beforeFinish.getClerkAScore()); + assertThat(persisted.getClerkBScore()).isEqualByComparingTo(beforeFinish.getClerkBScore()); + assertThat(persisted.getClerkAOrderCount()).isEqualTo(beforeFinish.getClerkAOrderCount()); + assertThat(persisted.getClerkBOrderCount()).isEqualTo(beforeFinish.getClerkBOrderCount()); + String expectedWinner; + if (beforeFinish.getClerkAScore().compareTo(beforeFinish.getClerkBScore()) > 0) { + expectedWinner = clerkAId; + } else if (beforeFinish.getClerkBScore().compareTo(beforeFinish.getClerkAScore()) > 0) { + expectedWinner = clerkBId; + } else { + expectedWinner = null; + } + assertThat(persisted.getWinnerClerkId()).isEqualTo(expectedWinner); + } + + private static PlayClerkPkEntity buildActivePk(String pkId, String clerkAId, String clerkBId, + LocalDateTime now) { + PlayClerkPkEntity pk = new PlayClerkPkEntity(); + pk.setId(pkId); + pk.setTenantId("tenant-apitest"); + pk.setClerkA(clerkAId); + pk.setClerkB(clerkBId); + pk.setPkBeginTime(Date.from(now.minusMinutes(5).atZone(ZoneId.systemDefault()).toInstant())); + pk.setPkEndTime(Date.from(now.plusMinutes(30).atZone(ZoneId.systemDefault()).toInstant())); + pk.setStatus(ClerkPkEnum.IN_PROGRESS.name()); + pk.setSettled(0); + return pk; + } +}