From 17a8c358a85ad447e276c17aa9d8975a7e740301 Mon Sep 17 00:00:00 2001 From: irving Date: Fri, 2 Jan 2026 01:34:03 -0500 Subject: [PATCH] feat(pk): implement PK (Player-Killer) system with lifecycle management - Add PK entity fields: winner, scores, and setting_id - Implement force start/end API endpoints for clerk PK - Add PK lifecycle service with auto-start/end scheduling - Add Redis-based PK state management - Implement PK detail service with live/history/upcoming queries - Add WeChat PK controller with history and live PK endpoints - Add comprehensive PK integration tests - Create PK setting management with tenant-specific configs - Add database migrations for PK scores, winner, settings, and menu - Add PK-related DTOs and enums (status, menu paths) - Add TenantScope utility for tenant context management --- justfile | 2 + .../handler/GlobalExceptionHandler.java | 13 +- .../controller/PlayClerkPkController.java | 13 + .../clerk/mapper/PlayClerkPkMapper.java | 48 + .../module/entity/PlayClerkPkEntity.java | 5 + .../clerk/service/IPlayClerkPkService.java | 4 + .../service/impl/PlayClerkPkServiceImpl.java | 51 +- .../order/mapper/PlayOrderInfoMapper.java | 33 + .../pk/constants/PkWxQueryConstants.java | 24 + .../pk/dto/PlayClerkPkForceStartRequest.java | 19 + .../pk/dto/WxPkClerkHistoryPageDto.java | 14 + .../pk/dto/WxPkClerkHistorySummaryDto.java | 12 + .../modules/pk/dto/WxPkContributorDto.java | 13 + .../admin/modules/pk/dto/WxPkDetailDto.java | 40 + .../admin/modules/pk/dto/WxPkHistoryDto.java | 24 + .../admin/modules/pk/dto/WxPkLiveDto.java | 33 + .../admin/modules/pk/dto/WxPkUpcomingDto.java | 28 + .../pk/enums/PkLifecycleErrorCode.java | 17 + .../admin/modules/pk/enums/PkWxState.java | 16 + .../modules/pk/redis/PkRedisKeyConstants.java | 36 + .../constants/PkReminderConstants.java | 10 + .../task/ClerkPkUpcomingReminderJob.java | 64 ++ .../constants/PkSchedulerConstants.java | 19 + .../scheduler/task/PkFinishSchedulerJob.java | 144 +++ .../scheduler/task/PkStartSchedulerJob.java | 133 +++ .../pk/service/ClerkPkLifecycleService.java | 10 + .../modules/pk/service/IPkDetailService.java | 16 + .../impl/ClerkPkLifecycleServiceImpl.java | 135 ++- .../pk/service/impl/PkDetailServiceImpl.java | 265 +++++ .../constants/PkSettingApiConstants.java | 16 + .../PkSettingValidationConstants.java | 16 + .../PlayClerkPkSettingController.java | 118 +++ .../PlayClerkPkSettingBulkCreateRequest.java | 14 + .../dto/PlayClerkPkSettingUpsertRequest.java | 54 + .../entity/PlayClerkPkSettingEntity.java | 62 ++ .../pk/setting/enums/PkRecurrenceType.java | 18 + .../pk/setting/enums/PkScheduleDayOfWeek.java | 20 + .../pk/setting/enums/PkSettingErrorCode.java | 20 + .../pk/setting/enums/PkSettingStatus.java | 15 + .../mapper/PlayClerkPkSettingMapper.java | 7 + .../service/IPlayClerkPkSettingService.java | 27 + .../impl/PlayClerkPkSettingServiceImpl.java | 419 ++++++++ .../WxClerkCommodityController.java | 56 +- .../weichat/controller/WxPkController.java | 279 ++++++ .../com/starry/admin/utils/TenantScope.java | 19 + ....sql => V19__add_pk_scores_and_winner.sql} | 0 .../V20__create_clerk_pk_settings.sql | 26 + .../db/migration/V21__add_pk_setting_id.sql | 3 + .../db/migration/V22__add_pk_menu.sql | 288 ++++++ .../db/migration/V23__fix_pk_menu_path.sql | 10 + .../admin/modules/pk/PkIntegrationTest.java | 946 +++++++++++++++++- .../admin/modules/pk/PlayClerkPkApiTest.java | 529 ++++++++++ .../modules/pk/PlayClerkPkSettingApiTest.java | 583 +++++++++++ .../starry/admin/modules/pk/WxPkApiTest.java | 642 ++++++++++++ 54 files changed, 5391 insertions(+), 37 deletions(-) create mode 100644 justfile create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/constants/PkWxQueryConstants.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/dto/PlayClerkPkForceStartRequest.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkClerkHistoryPageDto.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkClerkHistorySummaryDto.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkContributorDto.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkDetailDto.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkHistoryDto.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkLiveDto.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkUpcomingDto.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/enums/PkLifecycleErrorCode.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/enums/PkWxState.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/reminder/constants/PkReminderConstants.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/reminder/task/ClerkPkUpcomingReminderJob.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/scheduler/constants/PkSchedulerConstants.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/scheduler/task/PkFinishSchedulerJob.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/scheduler/task/PkStartSchedulerJob.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/service/IPkDetailService.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/service/impl/PkDetailServiceImpl.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/setting/constants/PkSettingApiConstants.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/setting/constants/PkSettingValidationConstants.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/setting/controller/PlayClerkPkSettingController.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/setting/dto/PlayClerkPkSettingBulkCreateRequest.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/setting/dto/PlayClerkPkSettingUpsertRequest.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/setting/entity/PlayClerkPkSettingEntity.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/setting/enums/PkRecurrenceType.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/setting/enums/PkScheduleDayOfWeek.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/setting/enums/PkSettingErrorCode.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/setting/enums/PkSettingStatus.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/setting/mapper/PlayClerkPkSettingMapper.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/setting/service/IPlayClerkPkSettingService.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/pk/setting/service/impl/PlayClerkPkSettingServiceImpl.java create mode 100644 play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxPkController.java create mode 100644 play-admin/src/main/java/com/starry/admin/utils/TenantScope.java rename play-admin/src/main/resources/db/migration/{V18__add_pk_scores_and_winner.sql => V19__add_pk_scores_and_winner.sql} (100%) create mode 100644 play-admin/src/main/resources/db/migration/V20__create_clerk_pk_settings.sql create mode 100644 play-admin/src/main/resources/db/migration/V21__add_pk_setting_id.sql create mode 100644 play-admin/src/main/resources/db/migration/V22__add_pk_menu.sql create mode 100644 play-admin/src/main/resources/db/migration/V23__fix_pk_menu_path.sql create mode 100644 play-admin/src/test/java/com/starry/admin/modules/pk/PlayClerkPkApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/modules/pk/PlayClerkPkSettingApiTest.java create mode 100644 play-admin/src/test/java/com/starry/admin/modules/pk/WxPkApiTest.java diff --git a/justfile b/justfile new file mode 100644 index 0000000..efadabd --- /dev/null +++ b/justfile @@ -0,0 +1,2 @@ +iperf: + iperf3 -c 101.43.124.74 diff --git a/play-admin/src/main/java/com/starry/admin/common/exception/handler/GlobalExceptionHandler.java b/play-admin/src/main/java/com/starry/admin/common/exception/handler/GlobalExceptionHandler.java index 8d16045..c66512c 100644 --- a/play-admin/src/main/java/com/starry/admin/common/exception/handler/GlobalExceptionHandler.java +++ b/play-admin/src/main/java/com/starry/admin/common/exception/handler/GlobalExceptionHandler.java @@ -24,6 +24,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class GlobalExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + public static final String PARAMETER_FORMAT_ERROR = "请求参数格式异常"; /** * 业务异常 @@ -87,20 +88,20 @@ public class GlobalExceptionHandler { @ExceptionHandler(MismatchedInputException.class) public R mismatchedInputException(MismatchedInputException e) { - log.error("请求参数格式异常", e); - return R.error("请求参数格式异常"); + log.error(PARAMETER_FORMAT_ERROR, e); + return R.error(PARAMETER_FORMAT_ERROR); } @ExceptionHandler(HttpMessageNotReadableException.class) public R httpMessageNotReadableException(HttpMessageNotReadableException e) { - log.error("请求参数格式异常", e); - return R.error("请求参数格式异常"); + log.error(PARAMETER_FORMAT_ERROR, e); + return R.error(PARAMETER_FORMAT_ERROR); } @ExceptionHandler(MissingServletRequestParameterException.class) public R missingServletRequestParameterException(MissingServletRequestParameterException e) { - log.error("请求参数格式异常", e); - return R.error("请求参数格式异常"); + log.error(PARAMETER_FORMAT_ERROR, e); + return R.error(PARAMETER_FORMAT_ERROR); } /** 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 8cdbacc..355b6b7 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 @@ -4,6 +4,7 @@ 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.dto.PlayClerkPkForceStartRequest; import com.starry.admin.modules.pk.service.ClerkPkLifecycleService; import com.starry.admin.modules.pk.service.IPkScoreboardService; import com.starry.common.annotation.Log; @@ -100,6 +101,18 @@ public class PlayClerkPkController { return R.ok(); } + /** + * 强制开始PK(无需排期,便于人工触发) + */ + @ApiOperation(value = "强制开始PK", notes = "人工触发PK开始并直接进入进行中状态") + @ApiResponses({@ApiResponse(code = 200, message = "操作成功")}) + @Log(title = "店员pk", businessType = BusinessType.INSERT) + @PostMapping(value = "/force-start") + public R forceStart(@ApiParam(value = "强制开始请求", required = true) + @RequestBody PlayClerkPkForceStartRequest request) { + return R.ok(clerkPkLifecycleService.forceStart(request)); + } + /** * 新增店员pk */ diff --git a/play-admin/src/main/java/com/starry/admin/modules/clerk/mapper/PlayClerkPkMapper.java b/play-admin/src/main/java/com/starry/admin/modules/clerk/mapper/PlayClerkPkMapper.java index f612c7f..ee60019 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/clerk/mapper/PlayClerkPkMapper.java +++ b/play-admin/src/main/java/com/starry/admin/modules/clerk/mapper/PlayClerkPkMapper.java @@ -1,7 +1,12 @@ package com.starry.admin.modules.clerk.mapper; +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity; +import java.util.Date; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; /** * 店员pkMapper接口 @@ -11,4 +16,47 @@ import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity; */ public interface PlayClerkPkMapper extends BaseMapper { + @InterceptorIgnore(tenantLine = "1") + @Select("SELECT * FROM play_clerk_pk " + + "WHERE status = #{status} " + + "AND pk_begin_time >= #{beginTime} " + + "AND pk_begin_time <= #{endTime}") + List selectUpcomingByStatus( + @Param("status") String status, + @Param("beginTime") Date beginTime, + @Param("endTime") Date endTime); + + @Select("") + List selectRecentFinishedBetweenClerks( + @Param("tenantId") String tenantId, + @Param("clerkAId") String clerkAId, + @Param("clerkBId") String clerkBId, + @Param("status") String status, + @Param("limit") int limit); + + @Select("") + List selectUpcomingForClerk( + @Param("tenantId") String tenantId, + @Param("clerkId") String clerkId, + @Param("status") String status, + @Param("beginTime") Date beginTime, + @Param("limit") int limit); } 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 e21f1a0..4a6148e 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,6 +87,11 @@ public class PlayClerkPkEntity extends BaseEntity { */ private String status; + /** + * 排期设置ID + */ + private String settingId; + /** * 店员A得分 */ 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 a84901c..6fdcada 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 @@ -4,6 +4,8 @@ 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.Date; +import java.util.List; import java.util.Optional; /** @@ -75,4 +77,6 @@ public interface IPlayClerkPkService extends IService { * @return 存在则返回 PK 记录,否则返回空 */ Optional findActivePkForClerk(String clerkId, LocalDateTime occurredAt); + + List selectUpcomingForClerk(String tenantId, String clerkId, Date beginTime, int limit); } 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 59e17ac..c80fa63 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 @@ -7,19 +7,25 @@ import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.starry.admin.common.PageBuilder; +import com.starry.admin.common.exception.CustomException; import com.starry.admin.modules.clerk.mapper.PlayClerkPkMapper; import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum; import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity; 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.admin.modules.pk.enums.PkLifecycleErrorCode; +import com.starry.admin.modules.pk.redis.PkRedisKeyConstants; 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.List; import java.util.Optional; import javax.annotation.Resource; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; /** @@ -36,6 +42,8 @@ public class PlayClerkPkServiceImpl extends ServiceImpl selectPlayClerkPkByPage(PlayClerkPkEntity playClerkPk) { - Page page = new Page<>(1, 10); - return this.baseMapper.selectPage(page, new LambdaQueryWrapper<>()); + Page page = PageBuilder.build(); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(StrUtil.isNotBlank(playClerkPk.getStatus()), PlayClerkPkEntity::getStatus, playClerkPk.getStatus()); + wrapper.eq(StrUtil.isNotBlank(playClerkPk.getClerkA()), PlayClerkPkEntity::getClerkA, playClerkPk.getClerkA()); + wrapper.eq(StrUtil.isNotBlank(playClerkPk.getClerkB()), PlayClerkPkEntity::getClerkB, playClerkPk.getClerkB()); + wrapper.eq(StrUtil.isNotBlank(playClerkPk.getSettingId()), PlayClerkPkEntity::getSettingId, + playClerkPk.getSettingId()); + wrapper.orderByDesc(PlayClerkPkEntity::getPkBeginTime); + return this.baseMapper.selectPage(page, wrapper); } /** @@ -101,7 +116,11 @@ public class PlayClerkPkServiceImpl extends ServiceImpl findActivePkForClerk(String clerkId, LocalDateTime occurredAt) { if (StrUtil.isBlank(clerkId) || occurredAt == null) { @@ -158,4 +189,18 @@ public class PlayClerkPkServiceImpl extends ServiceImpl selectUpcomingForClerk(String tenantId, String clerkId, Date beginTime, + int limit) { + if (StrUtil.isBlank(tenantId) || StrUtil.isBlank(clerkId) || beginTime == null || limit <= 0) { + throw new CustomException(PkLifecycleErrorCode.REQUEST_INVALID.getMessage()); + } + return playClerkPkMapper.selectUpcomingForClerk( + tenantId, + clerkId, + ClerkPkEnum.TO_BE_STARTED.name(), + beginTime, + limit); + } } diff --git a/play-admin/src/main/java/com/starry/admin/modules/order/mapper/PlayOrderInfoMapper.java b/play-admin/src/main/java/com/starry/admin/modules/order/mapper/PlayOrderInfoMapper.java index 0638f5b..2c4c467 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/order/mapper/PlayOrderInfoMapper.java +++ b/play-admin/src/main/java/com/starry/admin/modules/order/mapper/PlayOrderInfoMapper.java @@ -2,6 +2,12 @@ package com.starry.admin.modules.order.mapper; import com.github.yulichang.base.MPJBaseMapper; import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.pk.dto.WxPkContributorDto; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; /** * 订单Mapper接口 @@ -11,4 +17,31 @@ import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; */ public interface PlayOrderInfoMapper extends MPJBaseMapper { + @Select("") + List selectPkContributors( + @Param("tenantId") String tenantId, + @Param("clerkAId") String clerkAId, + @Param("clerkBId") String clerkBId, + @Param("startTime") LocalDateTime startTime, + @Param("endTime") LocalDateTime endTime, + @Param("orderStatus") String orderStatus, + @Param("minAmount") BigDecimal minAmount, + @Param("limit") int limit); } diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/constants/PkWxQueryConstants.java b/play-admin/src/main/java/com/starry/admin/modules/pk/constants/PkWxQueryConstants.java new file mode 100644 index 0000000..122b1c0 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/constants/PkWxQueryConstants.java @@ -0,0 +1,24 @@ +package com.starry.admin.modules.pk.constants; + +import java.math.BigDecimal; + +public final class PkWxQueryConstants { + + public static final int CONTRIBUTOR_LIMIT = 10; + public static final int HISTORY_LIMIT = 10; + public static final int CLERK_HISTORY_PAGE_NUM = 1; + public static final int CLERK_HISTORY_PAGE_SIZE = 10; + public static final int CLERK_HISTORY_MIN_PAGE = 1; + public static final int CLERK_SCHEDULE_DEFAULT_LIMIT = 3; + public static final int CLERK_SCHEDULE_MIN_LIMIT = 1; + public static final int CLERK_SCHEDULE_MAX_LIMIT = 20; + public static final int TOP_CONTRIBUTOR_LIMIT = 1; + public static final int WIN_RATE_SCALE = 2; + public static final String PERCENT_SUFFIX = "%"; + public static final BigDecimal MIN_CONTRIBUTION_AMOUNT = BigDecimal.ZERO; + public static final BigDecimal WIN_RATE_MULTIPLIER = new BigDecimal("100"); + + private PkWxQueryConstants() { + throw new IllegalStateException("Utility class"); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/dto/PlayClerkPkForceStartRequest.java b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/PlayClerkPkForceStartRequest.java new file mode 100644 index 0000000..06ebed6 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/PlayClerkPkForceStartRequest.java @@ -0,0 +1,19 @@ +package com.starry.admin.modules.pk.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +@ApiModel(value = "PlayClerkPkForceStartRequest", description = "强制开始PK请求") +@Data +public class PlayClerkPkForceStartRequest { + + @ApiModelProperty(value = "店员A ID", required = true) + private String clerkAId; + + @ApiModelProperty(value = "店员B ID", required = true) + private String clerkBId; + + @ApiModelProperty(value = "持续分钟数", required = true) + private Integer durationMinutes; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkClerkHistoryPageDto.java b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkClerkHistoryPageDto.java new file mode 100644 index 0000000..c259f75 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkClerkHistoryPageDto.java @@ -0,0 +1,14 @@ +package com.starry.admin.modules.pk.dto; + +import java.util.ArrayList; +import java.util.List; +import lombok.Data; + +@Data +public class WxPkClerkHistoryPageDto { + private List items = new ArrayList<>(); + private WxPkClerkHistorySummaryDto summary = new WxPkClerkHistorySummaryDto(); + private long totalCount; + private int pageNum; + private int pageSize; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkClerkHistorySummaryDto.java b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkClerkHistorySummaryDto.java new file mode 100644 index 0000000..cd2fc5f --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkClerkHistorySummaryDto.java @@ -0,0 +1,12 @@ +package com.starry.admin.modules.pk.dto; + +import lombok.Data; + +@Data +public class WxPkClerkHistorySummaryDto { + private static final String ZERO_PERCENT = "0%"; + + private long winCount; + private long totalCount; + private String winRate = ZERO_PERCENT; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkContributorDto.java b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkContributorDto.java new file mode 100644 index 0000000..c7a5829 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkContributorDto.java @@ -0,0 +1,13 @@ +package com.starry.admin.modules.pk.dto; + +import java.math.BigDecimal; +import lombok.Data; + +@Data +public class WxPkContributorDto { + private static final String EMPTY_TEXT = ""; + + private String userId = EMPTY_TEXT; + private String nickname = EMPTY_TEXT; + private BigDecimal amount = BigDecimal.ZERO; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkDetailDto.java b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkDetailDto.java new file mode 100644 index 0000000..be36f87 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkDetailDto.java @@ -0,0 +1,40 @@ +package com.starry.admin.modules.pk.dto; + +import com.starry.admin.modules.pk.enums.PkWxState; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import lombok.Data; + +@Data +public class WxPkDetailDto { + private static final String EMPTY_TEXT = ""; + private static final long ZERO_SECONDS = 0L; + private static final Date EPOCH_DATE = Date.from(Instant.EPOCH); + + private String id = EMPTY_TEXT; + private String state = EMPTY_TEXT; + private String clerkAId = EMPTY_TEXT; + private String clerkBId = EMPTY_TEXT; + private String clerkAName = EMPTY_TEXT; + private String clerkBName = EMPTY_TEXT; + private String clerkAAvatar = EMPTY_TEXT; + private String clerkBAvatar = EMPTY_TEXT; + private BigDecimal clerkAScore = BigDecimal.ZERO; + private BigDecimal clerkBScore = BigDecimal.ZERO; + private int clerkAOrderCount = 0; + private int clerkBOrderCount = 0; + private long remainingSeconds = ZERO_SECONDS; + private Date pkBeginTime = EPOCH_DATE; + private Date pkEndTime = EPOCH_DATE; + private List contributors = new ArrayList<>(); + private List history = new ArrayList<>(); + + public static WxPkDetailDto inactive() { + WxPkDetailDto dto = new WxPkDetailDto(); + dto.setState(PkWxState.INACTIVE.getValue()); + return dto; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkHistoryDto.java b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkHistoryDto.java new file mode 100644 index 0000000..66a3e7b --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkHistoryDto.java @@ -0,0 +1,24 @@ +package com.starry.admin.modules.pk.dto; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Date; +import lombok.Data; + +@Data +public class WxPkHistoryDto { + private static final String EMPTY_TEXT = ""; + private static final Date EPOCH_DATE = Date.from(Instant.EPOCH); + + private String id = EMPTY_TEXT; + private String clerkAId = EMPTY_TEXT; + private String clerkBId = EMPTY_TEXT; + private String winnerClerkId = EMPTY_TEXT; + private String clerkAName = EMPTY_TEXT; + private String clerkBName = EMPTY_TEXT; + private BigDecimal clerkAScore = BigDecimal.ZERO; + private BigDecimal clerkBScore = BigDecimal.ZERO; + private Date pkBeginTime = EPOCH_DATE; + private String topContributorName = EMPTY_TEXT; + private BigDecimal topContributorAmount = BigDecimal.ZERO; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkLiveDto.java b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkLiveDto.java new file mode 100644 index 0000000..696aba0 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkLiveDto.java @@ -0,0 +1,33 @@ +package com.starry.admin.modules.pk.dto; + +import com.starry.admin.modules.pk.enums.PkWxState; +import java.math.BigDecimal; +import lombok.Data; + +@Data +public class WxPkLiveDto { + private static final String EMPTY_TEXT = ""; + private static final long ZERO_SECONDS = 0L; + + private String id = EMPTY_TEXT; + private String state = EMPTY_TEXT; + private String clerkAId = EMPTY_TEXT; + private String clerkBId = EMPTY_TEXT; + private String clerkAName = EMPTY_TEXT; + private String clerkBName = EMPTY_TEXT; + private String clerkAAvatar = EMPTY_TEXT; + private String clerkBAvatar = EMPTY_TEXT; + private BigDecimal clerkAScore = BigDecimal.ZERO; + private BigDecimal clerkBScore = BigDecimal.ZERO; + private int clerkAOrderCount = 0; + private int clerkBOrderCount = 0; + private long remainingSeconds = ZERO_SECONDS; + private long pkEndEpochSeconds = ZERO_SECONDS; + private long serverEpochSeconds = ZERO_SECONDS; + + public static WxPkLiveDto inactive() { + WxPkLiveDto dto = new WxPkLiveDto(); + dto.setState(PkWxState.INACTIVE.getValue()); + return dto; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkUpcomingDto.java b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkUpcomingDto.java new file mode 100644 index 0000000..9790f73 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/dto/WxPkUpcomingDto.java @@ -0,0 +1,28 @@ +package com.starry.admin.modules.pk.dto; + +import com.starry.admin.modules.pk.enums.PkWxState; +import java.time.Instant; +import java.util.Date; +import lombok.Data; + +@Data +public class WxPkUpcomingDto { + private static final String EMPTY_TEXT = ""; + private static final Date EPOCH_DATE = Date.from(Instant.EPOCH); + + private String id = EMPTY_TEXT; + private String state = EMPTY_TEXT; + private String clerkAId = EMPTY_TEXT; + private String clerkBId = EMPTY_TEXT; + private String clerkAName = EMPTY_TEXT; + private String clerkBName = EMPTY_TEXT; + private String clerkAAvatar = EMPTY_TEXT; + private String clerkBAvatar = EMPTY_TEXT; + private Date pkBeginTime = EPOCH_DATE; + + public static WxPkUpcomingDto inactive() { + WxPkUpcomingDto dto = new WxPkUpcomingDto(); + dto.setState(PkWxState.INACTIVE.getValue()); + return dto; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/enums/PkLifecycleErrorCode.java b/play-admin/src/main/java/com/starry/admin/modules/pk/enums/PkLifecycleErrorCode.java new file mode 100644 index 0000000..ee88e4c --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/enums/PkLifecycleErrorCode.java @@ -0,0 +1,17 @@ +package com.starry.admin.modules.pk.enums; + +public enum PkLifecycleErrorCode { + REQUEST_INVALID("PK手动开始参数非法"), + CLERK_CONFLICT("店员排期冲突"), + TENANT_MISSING("租户ID缺失"); + + private final String message; + + PkLifecycleErrorCode(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/enums/PkWxState.java b/play-admin/src/main/java/com/starry/admin/modules/pk/enums/PkWxState.java new file mode 100644 index 0000000..e657217 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/enums/PkWxState.java @@ -0,0 +1,16 @@ +package com.starry.admin.modules.pk.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +public enum PkWxState { + ACTIVE("ACTIVE", "进行中"), + UPCOMING("UPCOMING", "即将开始"), + INACTIVE("INACTIVE", "无进行中PK"); + + @Getter + private final String value; + @Getter + private final String desc; +} 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 index dec6c14..471066a 100644 --- 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 @@ -8,11 +8,19 @@ 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:"; + private static final String UPCOMING_PREFIX = "pk:upcoming:"; + private static final String START_SCHEDULE_PREFIX = "pk:scheduler:start:"; + private static final String START_LOCK_PREFIX = "pk:scheduler:start:lock:"; + private static final String FINISH_SCHEDULE_PREFIX = "pk:scheduler:finish:"; + private static final String FINISH_RETRY_PREFIX = "pk:scheduler:finish:retry:"; + private static final String FINISH_FAILED_PREFIX = "pk:scheduler:finish:failed:"; + private static final String FINISH_LOCK_PREFIX = "pk:scheduler:finish:lock:"; /** * 贡献幂等记录的存活时间(秒)。 */ public static final long CONTRIBUTION_DEDUP_TTL_SECONDS = 3600L; + public static final long UPCOMING_REMINDER_TTL_SECONDS = 7200L; public static final String FIELD_CLERK_A_SCORE = "clerk_a_score"; public static final String FIELD_CLERK_B_SCORE = "clerk_b_score"; @@ -29,4 +37,32 @@ public final class PkRedisKeyConstants { public static String contributionDedupKey(String sourceCode, String referenceId) { return DEDUP_KEY_PREFIX + sourceCode + ":" + referenceId; } + + public static String upcomingKey(String tenantId) { + return UPCOMING_PREFIX + tenantId; + } + + public static String startScheduleKey(String tenantId) { + return START_SCHEDULE_PREFIX + tenantId; + } + + public static String startLockKey(String tenantId) { + return START_LOCK_PREFIX + tenantId; + } + + public static String finishScheduleKey(String tenantId) { + return FINISH_SCHEDULE_PREFIX + tenantId; + } + + public static String finishRetryKey(String tenantId) { + return FINISH_RETRY_PREFIX + tenantId; + } + + public static String finishFailedKey(String tenantId) { + return FINISH_FAILED_PREFIX + tenantId; + } + + public static String finishLockKey(String tenantId) { + return FINISH_LOCK_PREFIX + tenantId; + } } diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/reminder/constants/PkReminderConstants.java b/play-admin/src/main/java/com/starry/admin/modules/pk/reminder/constants/PkReminderConstants.java new file mode 100644 index 0000000..8592f26 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/reminder/constants/PkReminderConstants.java @@ -0,0 +1,10 @@ +package com.starry.admin.modules.pk.reminder.constants; + +public final class PkReminderConstants { + + public static final long SCAN_INTERVAL_MILLIS = 300000L; + public static final long UPCOMING_WINDOW_MINUTES = 60L; + + private PkReminderConstants() { + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/reminder/task/ClerkPkUpcomingReminderJob.java b/play-admin/src/main/java/com/starry/admin/modules/pk/reminder/task/ClerkPkUpcomingReminderJob.java new file mode 100644 index 0000000..b302dd7 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/reminder/task/ClerkPkUpcomingReminderJob.java @@ -0,0 +1,64 @@ +package com.starry.admin.modules.pk.reminder.task; + +import cn.hutool.core.util.StrUtil; +import com.starry.admin.modules.clerk.mapper.PlayClerkPkMapper; +import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum; +import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity; +import com.starry.admin.modules.pk.redis.PkRedisKeyConstants; +import com.starry.admin.modules.pk.reminder.constants.PkReminderConstants; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class ClerkPkUpcomingReminderJob { + + private static final double SCORE_MIN = Double.NEGATIVE_INFINITY; + + private final PlayClerkPkMapper clerkPkMapper; + private final StringRedisTemplate stringRedisTemplate; + + public ClerkPkUpcomingReminderJob(PlayClerkPkMapper clerkPkMapper, StringRedisTemplate stringRedisTemplate) { + this.clerkPkMapper = clerkPkMapper; + this.stringRedisTemplate = stringRedisTemplate; + } + + @Scheduled(fixedDelay = PkReminderConstants.SCAN_INTERVAL_MILLIS) + public void refreshUpcomingPkReminders() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime windowEnd = now.plusMinutes(PkReminderConstants.UPCOMING_WINDOW_MINUTES); + Date begin = Date.from(now.atZone(ZoneId.systemDefault()).toInstant()); + Date end = Date.from(windowEnd.atZone(ZoneId.systemDefault()).toInstant()); + + List upcoming = clerkPkMapper.selectUpcomingByStatus( + ClerkPkEnum.TO_BE_STARTED.name(), + begin, + end); + if (upcoming.isEmpty()) { + return; + } + Map> byTenant = upcoming.stream() + .filter(pk -> StrUtil.isNotBlank(pk.getTenantId())) + .collect(Collectors.groupingBy(PlayClerkPkEntity::getTenantId)); + long nowEpochSeconds = now.atZone(ZoneId.systemDefault()).toEpochSecond(); + for (Map.Entry> entry : byTenant.entrySet()) { + String key = PkRedisKeyConstants.upcomingKey(entry.getKey()); + stringRedisTemplate.opsForZSet().removeRangeByScore(key, SCORE_MIN, nowEpochSeconds - 1); + for (PlayClerkPkEntity pk : entry.getValue()) { + if (pk.getPkBeginTime() == null || pk.getId() == null) { + continue; + } + long score = pk.getPkBeginTime().toInstant().getEpochSecond(); + stringRedisTemplate.opsForZSet().add(key, pk.getId(), score); + } + stringRedisTemplate.expire(key, PkRedisKeyConstants.UPCOMING_REMINDER_TTL_SECONDS, TimeUnit.SECONDS); + } + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/scheduler/constants/PkSchedulerConstants.java b/play-admin/src/main/java/com/starry/admin/modules/pk/scheduler/constants/PkSchedulerConstants.java new file mode 100644 index 0000000..554d954 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/scheduler/constants/PkSchedulerConstants.java @@ -0,0 +1,19 @@ +package com.starry.admin.modules.pk.scheduler.constants; + +public final class PkSchedulerConstants { + + public static final long START_SCAN_INTERVAL_MILLIS = 1000L; + public static final long FINISH_SCAN_INTERVAL_MILLIS = 1000L; + public static final long FALLBACK_SCAN_INTERVAL_MILLIS = 300000L; + + public static final long START_LOCK_TTL_MILLIS = 5000L; + public static final long FINISH_LOCK_TTL_MILLIS = 5000L; + + public static final int FINISH_RETRY_MAX_ATTEMPTS = 3; + public static final int[] FINISH_RETRY_BACKOFF_SECONDS = {5, 10, 20}; + + public static final int START_RETRY_DELAY_SECONDS = 5; + + private PkSchedulerConstants() { + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/scheduler/task/PkFinishSchedulerJob.java b/play-admin/src/main/java/com/starry/admin/modules/pk/scheduler/task/PkFinishSchedulerJob.java new file mode 100644 index 0000000..24b0cfe --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/scheduler/task/PkFinishSchedulerJob.java @@ -0,0 +1,144 @@ +package com.starry.admin.modules.pk.scheduler.task; + +import cn.hutool.core.util.StrUtil; +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.pk.enums.PkLifecycleErrorCode; +import com.starry.admin.modules.pk.redis.PkRedisKeyConstants; +import com.starry.admin.modules.pk.scheduler.constants.PkSchedulerConstants; +import com.starry.admin.modules.pk.service.ClerkPkLifecycleService; +import com.starry.admin.modules.system.module.entity.SysTenantEntity; +import com.starry.admin.modules.system.service.ISysTenantService; +import com.starry.admin.utils.TenantScope; +import com.starry.common.utils.IdUtils; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class PkFinishSchedulerJob { + + private static final double SCORE_MIN = Double.NEGATIVE_INFINITY; + + private final StringRedisTemplate stringRedisTemplate; + private final ClerkPkLifecycleService clerkPkLifecycleService; + private final ISysTenantService sysTenantService; + + public PkFinishSchedulerJob(StringRedisTemplate stringRedisTemplate, + ClerkPkLifecycleService clerkPkLifecycleService, + ISysTenantService sysTenantService) { + this.stringRedisTemplate = stringRedisTemplate; + this.clerkPkLifecycleService = clerkPkLifecycleService; + this.sysTenantService = sysTenantService; + } + + @Scheduled(fixedDelay = PkSchedulerConstants.FINISH_SCAN_INTERVAL_MILLIS) + public void scanFinishSchedule() { + List tenantEntities = sysTenantService.listAll(); + if (tenantEntities.isEmpty()) { + return; + } + long nowEpochSeconds = Instant.now().getEpochSecond(); + for (SysTenantEntity tenantEntity : tenantEntities) { + String tenantId = tenantEntity.getTenantId(); + if (StrUtil.isBlank(tenantId)) { + log.warn("PK结算调度跳过空租户, errorCode={}", PkLifecycleErrorCode.TENANT_MISSING.name()); + continue; + } + try { + handleTenant(tenantId, nowEpochSeconds); + } catch (Exception ex) { + log.error("PK结算调度失败, tenantId={}", tenantId, ex); + continue; + } + } + } + + private void handleTenant(String tenantId, long nowEpochSeconds) { + String lockKey = PkRedisKeyConstants.finishLockKey(tenantId); + String lockValue = IdUtils.getUuid(); + Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent( + lockKey, + lockValue, + PkSchedulerConstants.FINISH_LOCK_TTL_MILLIS, + TimeUnit.MILLISECONDS); + if (!Boolean.TRUE.equals(locked)) { + return; + } + try { + try (TenantScope scope = TenantScope.use(tenantId)) { + String scheduleKey = PkRedisKeyConstants.finishScheduleKey(tenantId); + Set duePkIds = stringRedisTemplate.opsForZSet() + .rangeByScore(scheduleKey, SCORE_MIN, nowEpochSeconds); + if (duePkIds == null || duePkIds.isEmpty()) { + return; + } + for (String pkId : duePkIds) { + processFinish(scheduleKey, tenantId, pkId, nowEpochSeconds); + } + } + } finally { + releaseLock(lockKey, lockValue); + } + } + + private void processFinish(String scheduleKey, String tenantId, String pkId, long nowEpochSeconds) { + if (StrUtil.isBlank(pkId)) { + return; + } + try { + clerkPkLifecycleService.finishPk(pkId); + clearRetry(tenantId, pkId); + removeFailed(tenantId, pkId); + stringRedisTemplate.opsForZSet().remove(scheduleKey, pkId); + } catch (Exception ex) { + handleRetry(scheduleKey, tenantId, pkId, nowEpochSeconds, ex); + } + } + + private void handleRetry(String scheduleKey, String tenantId, String pkId, long nowEpochSeconds, Exception ex) { + String retryKey = PkRedisKeyConstants.finishRetryKey(tenantId); + Long attempt = stringRedisTemplate.opsForHash().increment(retryKey, pkId, 1L); + int retryCount = attempt == null ? 1 : attempt.intValue(); + if (retryCount >= PkSchedulerConstants.FINISH_RETRY_MAX_ATTEMPTS) { + String failedKey = PkRedisKeyConstants.finishFailedKey(tenantId); + stringRedisTemplate.opsForZSet().add(failedKey, pkId, nowEpochSeconds); + stringRedisTemplate.opsForZSet().remove(scheduleKey, pkId); + clearRetry(tenantId, pkId); + log.error("PK 自动结算失败超过重试上限, pkId={}, retryCount={}", pkId, retryCount, ex); + return; + } + long backoffSeconds = resolveBackoffSeconds(retryCount); + long nextScore = nowEpochSeconds + backoffSeconds; + stringRedisTemplate.opsForZSet().add(scheduleKey, pkId, nextScore); + log.warn("PK 自动结算失败, pkId={}, retryCount={}, nextScore={}", pkId, retryCount, nextScore, ex); + } + + private long resolveBackoffSeconds(int retryCount) { + int[] backoffs = PkSchedulerConstants.FINISH_RETRY_BACKOFF_SECONDS; + int index = Math.min(retryCount, backoffs.length) - 1; + return backoffs[index]; + } + + private void clearRetry(String tenantId, String pkId) { + String retryKey = PkRedisKeyConstants.finishRetryKey(tenantId); + stringRedisTemplate.opsForHash().delete(retryKey, pkId); + } + + private void removeFailed(String tenantId, String pkId) { + String failedKey = PkRedisKeyConstants.finishFailedKey(tenantId); + stringRedisTemplate.opsForZSet().remove(failedKey, pkId); + } + + private void releaseLock(String lockKey, String lockValue) { + String currentValue = stringRedisTemplate.opsForValue().get(lockKey); + if (lockValue.equals(currentValue)) { + stringRedisTemplate.delete(lockKey); + } + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/scheduler/task/PkStartSchedulerJob.java b/play-admin/src/main/java/com/starry/admin/modules/pk/scheduler/task/PkStartSchedulerJob.java new file mode 100644 index 0000000..fc20bd9 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/scheduler/task/PkStartSchedulerJob.java @@ -0,0 +1,133 @@ +package com.starry.admin.modules.pk.scheduler.task; + +import cn.hutool.core.util.StrUtil; +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.enums.PkLifecycleErrorCode; +import com.starry.admin.modules.pk.redis.PkRedisKeyConstants; +import com.starry.admin.modules.pk.scheduler.constants.PkSchedulerConstants; +import com.starry.admin.modules.pk.service.ClerkPkLifecycleService; +import com.starry.admin.modules.system.module.entity.SysTenantEntity; +import com.starry.admin.modules.system.service.ISysTenantService; +import com.starry.admin.utils.TenantScope; +import com.starry.common.utils.IdUtils; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class PkStartSchedulerJob { + + private static final double SCORE_MIN = Double.NEGATIVE_INFINITY; + + private final StringRedisTemplate stringRedisTemplate; + private final ClerkPkLifecycleService clerkPkLifecycleService; + private final IPlayClerkPkService clerkPkService; + private final ISysTenantService sysTenantService; + + public PkStartSchedulerJob(StringRedisTemplate stringRedisTemplate, + ClerkPkLifecycleService clerkPkLifecycleService, + IPlayClerkPkService clerkPkService, + ISysTenantService sysTenantService) { + this.stringRedisTemplate = stringRedisTemplate; + this.clerkPkLifecycleService = clerkPkLifecycleService; + this.clerkPkService = clerkPkService; + this.sysTenantService = sysTenantService; + } + + @Scheduled(fixedDelay = PkSchedulerConstants.START_SCAN_INTERVAL_MILLIS) + public void scanStartSchedule() { + List tenantEntities = sysTenantService.listAll(); + if (tenantEntities.isEmpty()) { + return; + } + long nowEpochSeconds = Instant.now().getEpochSecond(); + for (SysTenantEntity tenantEntity : tenantEntities) { + String tenantId = tenantEntity.getTenantId(); + if (StrUtil.isBlank(tenantId)) { + log.warn("PK开始调度跳过空租户, errorCode={}", PkLifecycleErrorCode.TENANT_MISSING.name()); + continue; + } + try { + handleTenant(tenantId, nowEpochSeconds); + } catch (Exception ex) { + log.error("PK开始调度失败, tenantId={}", tenantId, ex); + continue; + } + } + } + + private void handleTenant(String tenantId, long nowEpochSeconds) { + String lockKey = PkRedisKeyConstants.startLockKey(tenantId); + String lockValue = IdUtils.getUuid(); + Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent( + lockKey, + lockValue, + PkSchedulerConstants.START_LOCK_TTL_MILLIS, + TimeUnit.MILLISECONDS); + if (!Boolean.TRUE.equals(locked)) { + return; + } + try { + try (TenantScope scope = TenantScope.use(tenantId)) { + String scheduleKey = PkRedisKeyConstants.startScheduleKey(tenantId); + Set duePkIds = stringRedisTemplate.opsForZSet() + .rangeByScore(scheduleKey, SCORE_MIN, nowEpochSeconds); + if (duePkIds == null || duePkIds.isEmpty()) { + return; + } + for (String pkId : duePkIds) { + processStart(scheduleKey, pkId, nowEpochSeconds); + } + } + } finally { + releaseLock(lockKey, lockValue); + } + } + + private void processStart(String scheduleKey, String pkId, long nowEpochSeconds) { + if (StrUtil.isBlank(pkId)) { + return; + } + PlayClerkPkEntity pk = clerkPkService.selectPlayClerkPkById(pkId); + if (pk == null) { + stringRedisTemplate.opsForZSet().remove(scheduleKey, pkId); + return; + } + if (!ClerkPkEnum.TO_BE_STARTED.name().equals(pk.getStatus())) { + stringRedisTemplate.opsForZSet().remove(scheduleKey, pkId); + return; + } + if (pk.getPkBeginTime() == null) { + stringRedisTemplate.opsForZSet().remove(scheduleKey, pkId); + return; + } + long beginEpochSeconds = pk.getPkBeginTime().toInstant().getEpochSecond(); + if (beginEpochSeconds > nowEpochSeconds) { + stringRedisTemplate.opsForZSet().add(scheduleKey, pkId, beginEpochSeconds); + return; + } + try { + clerkPkLifecycleService.startPk(pkId); + } catch (Exception ex) { + long retryAt = nowEpochSeconds + PkSchedulerConstants.START_RETRY_DELAY_SECONDS; + stringRedisTemplate.opsForZSet().add(scheduleKey, pkId, retryAt); + log.warn("PK 自动开始失败, pkId={}, retryAt={}", pkId, retryAt, ex); + } + } + + private void releaseLock(String lockKey, String lockValue) { + String currentValue = stringRedisTemplate.opsForValue().get(lockKey); + if (lockValue.equals(currentValue)) { + stringRedisTemplate.delete(lockKey); + } + } +} 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 index 2904f59..432d52b 100644 --- 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 @@ -1,5 +1,7 @@ package com.starry.admin.modules.pk.service; +import com.starry.admin.modules.pk.dto.PlayClerkPkForceStartRequest; + /** * 店员 PK 生命周期管理服务。 */ @@ -23,4 +25,12 @@ public interface ClerkPkLifecycleService { * 扫描当前需要状态流转的 PK。 */ void scanAndUpdate(); + + /** + * 强制开始PK(用于测试,无需排期)。 + * + * @param request 强制开始请求 + * @return PK ID + */ + String forceStart(PlayClerkPkForceStartRequest request); } diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/service/IPkDetailService.java b/play-admin/src/main/java/com/starry/admin/modules/pk/service/IPkDetailService.java new file mode 100644 index 0000000..045005d --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/service/IPkDetailService.java @@ -0,0 +1,16 @@ +package com.starry.admin.modules.pk.service; + +import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity; +import com.starry.admin.modules.pk.dto.WxPkClerkHistoryPageDto; +import com.starry.admin.modules.pk.dto.WxPkContributorDto; +import com.starry.admin.modules.pk.dto.WxPkHistoryDto; +import java.util.List; + +public interface IPkDetailService { + + List getContributors(PlayClerkPkEntity pk); + + List getHistory(PlayClerkPkEntity pk); + + WxPkClerkHistoryPageDto getClerkHistory(String clerkId, int pageNum, int pageSize); +} 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 index d717a17..0cad7eb 100644 --- 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 @@ -1,16 +1,27 @@ package com.starry.admin.modules.pk.service.impl; +import cn.hutool.core.util.StrUtil; 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.dto.PlayClerkPkForceStartRequest; +import com.starry.admin.modules.pk.enums.PkLifecycleErrorCode; import com.starry.admin.modules.pk.redis.PkRedisKeyConstants; +import com.starry.admin.modules.pk.scheduler.constants.PkSchedulerConstants; import com.starry.admin.modules.pk.service.ClerkPkLifecycleService; +import com.starry.admin.modules.pk.setting.constants.PkSettingValidationConstants; +import com.starry.admin.modules.system.module.entity.SysTenantEntity; +import com.starry.admin.modules.system.service.ISysTenantService; +import com.starry.admin.utils.SecurityUtils; +import com.starry.admin.utils.TenantScope; +import com.starry.common.utils.IdUtils; import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.Date; +import java.util.List; import java.util.Map; import javax.annotation.Resource; import lombok.extern.slf4j.Slf4j; @@ -29,6 +40,9 @@ public class ClerkPkLifecycleServiceImpl implements ClerkPkLifecycleService { @Resource private StringRedisTemplate stringRedisTemplate; + @Resource + private ISysTenantService sysTenantService; + @Override @Transactional(rollbackFor = Exception.class) public void startPk(String pkId) { @@ -37,6 +51,7 @@ public class ClerkPkLifecycleServiceImpl implements ClerkPkLifecycleService { throw new CustomException("PK不存在"); } if (!ClerkPkEnum.TO_BE_STARTED.name().equals(pk.getStatus())) { + removeStartSchedule(pk); return; } LocalDateTime now = LocalDateTime.now(); @@ -46,6 +61,8 @@ public class ClerkPkLifecycleServiceImpl implements ClerkPkLifecycleService { } pk.setStatus(ClerkPkEnum.IN_PROGRESS.name()); clerkPkService.updateById(pk); + removeStartSchedule(pk); + scheduleFinish(pk); } @Override @@ -56,6 +73,7 @@ public class ClerkPkLifecycleServiceImpl implements ClerkPkLifecycleService { throw new CustomException("PK不存在"); } if (ClerkPkEnum.FINISHED.name().equals(pk.getStatus())) { + removeFinishSchedule(pk); return; } String scoreKey = PkRedisKeyConstants.scoreKey(pkId); @@ -80,6 +98,7 @@ public class ClerkPkLifecycleServiceImpl implements ClerkPkLifecycleService { pk.setWinnerClerkId(null); } clerkPkService.updateById(pk); + removeFinishSchedule(pk); } @Override @@ -113,9 +132,90 @@ public class ClerkPkLifecycleServiceImpl implements ClerkPkLifecycleService { }); } - @Scheduled(fixedDelay = 30000) + @Override + @Transactional(rollbackFor = Exception.class) + public String forceStart(PlayClerkPkForceStartRequest request) { + validateForceStartRequest(request); + String tenantId = SecurityUtils.getTenantId(); + if (StrUtil.isBlank(tenantId)) { + throw new CustomException(PkLifecycleErrorCode.TENANT_MISSING.getMessage()); + } + LocalDateTime now = LocalDateTime.now(); + LocalDateTime endTime = now.plusMinutes(request.getDurationMinutes()); + Date beginDate = Date.from(now.atZone(ZoneId.systemDefault()).toInstant()); + Date endDate = Date.from(endTime.atZone(ZoneId.systemDefault()).toInstant()); + if (hasClerkConflict(request.getClerkAId(), request.getClerkBId(), beginDate, endDate)) { + throw new CustomException(PkLifecycleErrorCode.CLERK_CONFLICT.getMessage()); + } + PlayClerkPkEntity pk = new PlayClerkPkEntity(); + pk.setId(IdUtils.getUuid()); + pk.setTenantId(tenantId); + pk.setClerkA(request.getClerkAId()); + pk.setClerkB(request.getClerkBId()); + pk.setPkBeginTime(beginDate); + pk.setPkEndTime(endDate); + pk.setStatus(ClerkPkEnum.IN_PROGRESS.name()); + pk.setSettled(0); + pk.setCreatedBy(SecurityUtils.getUserId()); + clerkPkService.save(pk); + scheduleFinish(pk); + return pk.getId(); + } + + @Scheduled(fixedDelay = PkSchedulerConstants.FALLBACK_SCAN_INTERVAL_MILLIS) public void scheduledScan() { - scanAndUpdate(); + scanAndUpdateForAllTenants(); + } + + private void scanAndUpdateForAllTenants() { + List tenantEntities = sysTenantService.listAll(); + if (tenantEntities.isEmpty()) { + return; + } + for (SysTenantEntity tenantEntity : tenantEntities) { + String tenantId = tenantEntity.getTenantId(); + if (StrUtil.isBlank(tenantId)) { + log.warn("PK扫描跳过空租户, errorCode={}", PkLifecycleErrorCode.TENANT_MISSING.name()); + continue; + } + try (TenantScope scope = TenantScope.use(tenantId)) { + scanAndUpdate(); + } + } + } + + private void scheduleFinish(PlayClerkPkEntity pk) { + if (pk == null || pk.getPkEndTime() == null) { + throw new CustomException(PkLifecycleErrorCode.REQUEST_INVALID.getMessage()); + } + String tenantId = pk.getTenantId(); + if (StrUtil.isBlank(tenantId)) { + throw new CustomException(PkLifecycleErrorCode.TENANT_MISSING.getMessage()); + } + long endEpochSeconds = pk.getPkEndTime().toInstant().getEpochSecond(); + String scheduleKey = PkRedisKeyConstants.finishScheduleKey(tenantId); + stringRedisTemplate.opsForZSet().add(scheduleKey, pk.getId(), endEpochSeconds); + } + + private void removeStartSchedule(PlayClerkPkEntity pk) { + if (pk == null || StrUtil.isBlank(pk.getTenantId())) { + return; + } + String scheduleKey = PkRedisKeyConstants.startScheduleKey(pk.getTenantId()); + stringRedisTemplate.opsForZSet().remove(scheduleKey, pk.getId()); + } + + private void removeFinishSchedule(PlayClerkPkEntity pk) { + if (pk == null || StrUtil.isBlank(pk.getTenantId())) { + return; + } + String tenantId = pk.getTenantId(); + String scheduleKey = PkRedisKeyConstants.finishScheduleKey(tenantId); + stringRedisTemplate.opsForZSet().remove(scheduleKey, pk.getId()); + String retryKey = PkRedisKeyConstants.finishRetryKey(tenantId); + stringRedisTemplate.opsForHash().delete(retryKey, pk.getId()); + String failedKey = PkRedisKeyConstants.finishFailedKey(tenantId); + stringRedisTemplate.opsForZSet().remove(failedKey, pk.getId()); } private static BigDecimal parseDecimal(Object value) { @@ -139,4 +239,35 @@ public class ClerkPkLifecycleServiceImpl implements ClerkPkLifecycleService { return 0; } } + + private void validateForceStartRequest(PlayClerkPkForceStartRequest request) { + if (request == null + || StrUtil.isBlank(request.getClerkAId()) + || StrUtil.isBlank(request.getClerkBId()) + || request.getDurationMinutes() == null) { + throw new CustomException(PkLifecycleErrorCode.REQUEST_INVALID.getMessage()); + } + if (request.getClerkAId().equals(request.getClerkBId())) { + throw new CustomException(PkLifecycleErrorCode.REQUEST_INVALID.getMessage()); + } + if (request.getDurationMinutes() < PkSettingValidationConstants.MIN_DURATION_MINUTES) { + throw new CustomException(PkLifecycleErrorCode.REQUEST_INVALID.getMessage()); + } + } + + private boolean hasClerkConflict(String clerkAId, String clerkBId, Date beginTime, Date endTime) { + return clerkPkService.count(Wrappers.lambdaQuery() + .in(PlayClerkPkEntity::getStatus, + ClerkPkEnum.TO_BE_STARTED.name(), + ClerkPkEnum.IN_PROGRESS.name()) + .and(time -> time.le(PlayClerkPkEntity::getPkBeginTime, endTime) + .ge(PlayClerkPkEntity::getPkEndTime, beginTime)) + .and(clerk -> clerk.eq(PlayClerkPkEntity::getClerkA, clerkAId) + .or() + .eq(PlayClerkPkEntity::getClerkB, clerkAId) + .or() + .eq(PlayClerkPkEntity::getClerkA, clerkBId) + .or() + .eq(PlayClerkPkEntity::getClerkB, clerkBId))) > 0; + } } diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/service/impl/PkDetailServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/pk/service/impl/PkDetailServiceImpl.java new file mode 100644 index 0000000..05d4d60 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/service/impl/PkDetailServiceImpl.java @@ -0,0 +1,265 @@ +package com.starry.admin.modules.pk.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.clerk.mapper.PlayClerkPkMapper; +import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum; +import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity; +import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; +import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; +import com.starry.admin.modules.order.mapper.PlayOrderInfoMapper; +import com.starry.admin.modules.order.module.constant.OrderConstant; +import com.starry.admin.modules.pk.constants.PkWxQueryConstants; +import com.starry.admin.modules.pk.dto.WxPkClerkHistoryPageDto; +import com.starry.admin.modules.pk.dto.WxPkClerkHistorySummaryDto; +import com.starry.admin.modules.pk.dto.WxPkContributorDto; +import com.starry.admin.modules.pk.dto.WxPkHistoryDto; +import com.starry.admin.modules.pk.service.IPkDetailService; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Resource; +import org.springframework.stereotype.Service; + +@Service +public class PkDetailServiceImpl implements IPkDetailService { + + private static final ZoneId DEFAULT_ZONE = ZoneId.systemDefault(); + + @Resource + private PlayOrderInfoMapper orderInfoMapper; + + @Resource + private PlayClerkPkMapper clerkPkMapper; + + @Resource + private IPlayClerkUserInfoService clerkUserInfoService; + + @Override + public List getContributors(PlayClerkPkEntity pk) { + validatePk(pk); + LocalDateTime startTime = toLocalDateTime(pk.getPkBeginTime()); + LocalDateTime endTime = toLocalDateTime(pk.getPkEndTime()); + LocalDateTime now = LocalDateTime.now(DEFAULT_ZONE); + if (endTime.isAfter(now)) { + endTime = now; + } + if (endTime.isBefore(startTime)) { + return Collections.emptyList(); + } + List contributors = orderInfoMapper.selectPkContributors( + pk.getTenantId(), + pk.getClerkA(), + pk.getClerkB(), + startTime, + endTime, + OrderConstant.OrderStatus.COMPLETED.getCode(), + PkWxQueryConstants.MIN_CONTRIBUTION_AMOUNT, + PkWxQueryConstants.CONTRIBUTOR_LIMIT); + if (contributors == null || contributors.isEmpty()) { + return Collections.emptyList(); + } + List normalized = new ArrayList<>(); + for (WxPkContributorDto contributor : contributors) { + normalized.add(normalizeContributor(contributor)); + } + return normalized; + } + + @Override + public List getHistory(PlayClerkPkEntity pk) { + validatePk(pk); + List history = clerkPkMapper.selectRecentFinishedBetweenClerks( + pk.getTenantId(), + pk.getClerkA(), + pk.getClerkB(), + ClerkPkEnum.FINISHED.name(), + PkWxQueryConstants.HISTORY_LIMIT); + if (history == null || history.isEmpty()) { + return Collections.emptyList(); + } + Map clerkNames = loadClerkNames(pk.getClerkA(), pk.getClerkB()); + List items = new ArrayList<>(); + for (PlayClerkPkEntity item : history) { + items.add(toHistoryDto(item, clerkNames)); + } + return items; + } + + @Override + public WxPkClerkHistoryPageDto getClerkHistory(String clerkId, int pageNum, int pageSize) { + if (StrUtil.isBlank(clerkId)) { + throw new CustomException("店员ID不能为空"); + } + int safePageNum = normalizePageParam(pageNum, PkWxQueryConstants.CLERK_HISTORY_PAGE_NUM); + int safePageSize = normalizePageParam(pageSize, PkWxQueryConstants.CLERK_HISTORY_PAGE_SIZE); + + com.baomidou.mybatisplus.extension.plugins.pagination.Page page = + new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(safePageNum, safePageSize); + com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper wrapper = + com.baomidou.mybatisplus.core.toolkit.Wrappers.lambdaQuery(PlayClerkPkEntity.class) + .eq(PlayClerkPkEntity::getStatus, ClerkPkEnum.FINISHED.name()) + .and(w -> w.eq(PlayClerkPkEntity::getClerkA, clerkId) + .or() + .eq(PlayClerkPkEntity::getClerkB, clerkId)) + .orderByDesc(PlayClerkPkEntity::getPkBeginTime); + com.baomidou.mybatisplus.extension.plugins.pagination.Page result = + clerkPkMapper.selectPage(page, wrapper); + + List items = new ArrayList<>(); + if (result.getRecords() != null) { + for (PlayClerkPkEntity item : result.getRecords()) { + items.add(toClerkHistoryDto(item)); + } + } + + WxPkClerkHistoryPageDto pageDto = new WxPkClerkHistoryPageDto(); + pageDto.setItems(items); + pageDto.setTotalCount(result.getTotal()); + pageDto.setPageNum(safePageNum); + pageDto.setPageSize(safePageSize); + pageDto.setSummary(buildSummary(clerkId, result.getTotal())); + return pageDto; + } + + private void validatePk(PlayClerkPkEntity pk) { + if (pk == null) { + throw new CustomException("PK不存在"); + } + if (StrUtil.isBlank(pk.getTenantId())) { + throw new CustomException("租户信息缺失"); + } + if (StrUtil.isBlank(pk.getClerkA()) || StrUtil.isBlank(pk.getClerkB())) { + throw new CustomException("PK店员信息缺失"); + } + if (pk.getPkBeginTime() == null || pk.getPkEndTime() == null) { + throw new CustomException("PK时间信息缺失"); + } + } + + private LocalDateTime toLocalDateTime(java.util.Date date) { + if (date == null) { + throw new CustomException("时间信息缺失"); + } + return LocalDateTime.ofInstant(date.toInstant(), DEFAULT_ZONE); + } + + private WxPkContributorDto normalizeContributor(WxPkContributorDto source) { + WxPkContributorDto dto = new WxPkContributorDto(); + if (source == null) { + return dto; + } + if (StrUtil.isNotBlank(source.getUserId())) { + dto.setUserId(source.getUserId()); + } + if (StrUtil.isNotBlank(source.getNickname())) { + dto.setNickname(source.getNickname()); + } + if (source.getAmount() != null) { + dto.setAmount(source.getAmount()); + } + return dto; + } + + private Map loadClerkNames(String clerkAId, String clerkBId) { + Map names = new HashMap<>(); + PlayClerkUserInfoEntity clerkA = clerkUserInfoService.getById(clerkAId); + PlayClerkUserInfoEntity clerkB = clerkUserInfoService.getById(clerkBId); + names.put(clerkAId, clerkA == null ? "" : safeText(clerkA.getNickname())); + names.put(clerkBId, clerkB == null ? "" : safeText(clerkB.getNickname())); + return names; + } + + private WxPkHistoryDto toHistoryDto(PlayClerkPkEntity item, Map clerkNames) { + WxPkHistoryDto dto = new WxPkHistoryDto(); + if (item == null) { + return dto; + } + dto.setClerkAName(safeText(clerkNames.get(item.getClerkA()))); + dto.setClerkBName(safeText(clerkNames.get(item.getClerkB()))); + dto.setClerkAScore(safeAmount(item.getClerkAScore())); + dto.setClerkBScore(safeAmount(item.getClerkBScore())); + dto.setId(safeText(item.getId())); + dto.setClerkAId(safeText(item.getClerkA())); + dto.setClerkBId(safeText(item.getClerkB())); + dto.setWinnerClerkId(safeText(item.getWinnerClerkId())); + if (item.getPkBeginTime() != null) { + dto.setPkBeginTime(item.getPkBeginTime()); + } + return dto; + } + + private String safeText(String value) { + return value == null ? "" : value; + } + + private BigDecimal safeAmount(BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } + + private int normalizePageParam(int value, int defaultValue) { + if (value < PkWxQueryConstants.CLERK_HISTORY_MIN_PAGE) { + return defaultValue; + } + return value; + } + + private WxPkHistoryDto toClerkHistoryDto(PlayClerkPkEntity item) { + WxPkHistoryDto dto = toHistoryDto(item, loadClerkNames(item.getClerkA(), item.getClerkB())); + WxPkContributorDto topContributor = loadTopContributor(item); + if (topContributor != null) { + dto.setTopContributorName(safeText(topContributor.getNickname())); + dto.setTopContributorAmount(safeAmount(topContributor.getAmount())); + } + return dto; + } + + private WxPkContributorDto loadTopContributor(PlayClerkPkEntity pk) { + if (pk == null || pk.getPkBeginTime() == null || pk.getPkEndTime() == null) { + return null; + } + LocalDateTime startTime = toLocalDateTime(pk.getPkBeginTime()); + LocalDateTime endTime = toLocalDateTime(pk.getPkEndTime()); + List contributors = orderInfoMapper.selectPkContributors( + pk.getTenantId(), + pk.getClerkA(), + pk.getClerkB(), + startTime, + endTime, + OrderConstant.OrderStatus.COMPLETED.getCode(), + PkWxQueryConstants.MIN_CONTRIBUTION_AMOUNT, + PkWxQueryConstants.TOP_CONTRIBUTOR_LIMIT); + if (contributors == null || contributors.isEmpty()) { + return null; + } + return normalizeContributor(contributors.get(0)); + } + + private WxPkClerkHistorySummaryDto buildSummary(String clerkId, long totalCount) { + WxPkClerkHistorySummaryDto summary = new WxPkClerkHistorySummaryDto(); + summary.setTotalCount(totalCount); + if (totalCount <= 0) { + return summary; + } + com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper winWrapper = + com.baomidou.mybatisplus.core.toolkit.Wrappers.lambdaQuery(PlayClerkPkEntity.class) + .eq(PlayClerkPkEntity::getStatus, ClerkPkEnum.FINISHED.name()) + .and(w -> w.eq(PlayClerkPkEntity::getClerkA, clerkId) + .or() + .eq(PlayClerkPkEntity::getClerkB, clerkId)) + .eq(PlayClerkPkEntity::getWinnerClerkId, clerkId); + long winCount = clerkPkMapper.selectCount(winWrapper); + summary.setWinCount(winCount); + BigDecimal rate = BigDecimal.valueOf(winCount) + .multiply(PkWxQueryConstants.WIN_RATE_MULTIPLIER) + .divide(BigDecimal.valueOf(totalCount), PkWxQueryConstants.WIN_RATE_SCALE, RoundingMode.HALF_UP); + summary.setWinRate(rate.stripTrailingZeros().toPlainString() + PkWxQueryConstants.PERCENT_SUFFIX); + return summary; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/setting/constants/PkSettingApiConstants.java b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/constants/PkSettingApiConstants.java new file mode 100644 index 0000000..21e6f26 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/constants/PkSettingApiConstants.java @@ -0,0 +1,16 @@ +package com.starry.admin.modules.pk.setting.constants; + +public final class PkSettingApiConstants { + + public static final String BASE_PATH = "/play/pk/settings"; + public static final String LIST_PATH = "/list"; + public static final String DETAIL_PATH = "/{id}"; + public static final String CREATE_PATH = "/create"; + public static final String BULK_CREATE_PATH = "/bulk-create"; + public static final String UPDATE_PATH = "/update/{id}"; + public static final String ENABLE_PATH = "/{id}/enable"; + public static final String DISABLE_PATH = "/{id}/disable"; + + private PkSettingApiConstants() { + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/setting/constants/PkSettingValidationConstants.java b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/constants/PkSettingValidationConstants.java new file mode 100644 index 0000000..6cd72b9 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/constants/PkSettingValidationConstants.java @@ -0,0 +1,16 @@ +package com.starry.admin.modules.pk.setting.constants; + +public final class PkSettingValidationConstants { + + public static final int MIN_DURATION_MINUTES = 1; + public static final int MAX_YEARS_AHEAD = 3; + public static final int MIN_DAY_OF_MONTH = 1; + public static final int MAX_DAY_OF_MONTH = 31; + public static final int MIN_MONTH_OF_YEAR = 1; + public static final int MAX_MONTH_OF_YEAR = 12; + public static final int DEFAULT_PAGE_NUMBER = 1; + public static final int DEFAULT_PAGE_SIZE = 10; + + private PkSettingValidationConstants() { + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/setting/controller/PlayClerkPkSettingController.java b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/controller/PlayClerkPkSettingController.java new file mode 100644 index 0000000..fbc1ebc --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/controller/PlayClerkPkSettingController.java @@ -0,0 +1,118 @@ +package com.starry.admin.modules.pk.setting.controller; + +import com.starry.admin.modules.pk.setting.constants.PkSettingApiConstants; +import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingBulkCreateRequest; +import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingUpsertRequest; +import com.starry.admin.modules.pk.setting.service.IPlayClerkPkSettingService; +import com.starry.common.annotation.Log; +import com.starry.common.enums.BusinessType; +import com.starry.common.result.R; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiImplicitParam; +import io.swagger.annotations.ApiOperation; +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.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; + +@Api(tags = "店员PK排期设置", description = "店员PK排期设置相关接口") +@RestController +@RequestMapping(PkSettingApiConstants.BASE_PATH) +public class PlayClerkPkSettingController { + + @Resource + private IPlayClerkPkSettingService pkSettingService; + + /** + * 查询PK排期设置列表 + */ + @ApiOperation(value = "查询PK排期设置列表", notes = "分页查询PK排期设置") + @ApiResponses({@ApiResponse(code = 200, message = "操作成功")}) + @GetMapping(PkSettingApiConstants.LIST_PATH) + public R list() { + return R.ok(pkSettingService.listSettings()); + } + + /** + * 获取PK排期设置详情 + */ + @ApiOperation(value = "获取PK排期设置详情", notes = "根据ID获取PK排期设置详情") + @ApiImplicitParam(name = "id", value = "设置ID", required = true, paramType = "path", dataType = "String") + @ApiResponses({@ApiResponse(code = 200, message = "操作成功")}) + @GetMapping(PkSettingApiConstants.DETAIL_PATH) + public R getInfo(@PathVariable("id") String id) { + return R.ok(pkSettingService.getSetting(id)); + } + + /** + * 新增PK排期设置 + */ + @ApiOperation(value = "新增PK排期设置", notes = "创建新的PK排期设置") + @ApiResponses({@ApiResponse(code = 200, message = "操作成功"), @ApiResponse(code = 500, message = "添加失败")}) + @Log(title = "店员PK排期设置", businessType = BusinessType.INSERT) + @PostMapping(PkSettingApiConstants.CREATE_PATH) + public R create( + @ApiParam(value = "PK排期设置", required = true) @RequestBody PlayClerkPkSettingUpsertRequest request) { + String id = pkSettingService.createSetting(request); + return R.ok(id); + } + + /** + * 批量新增PK排期设置 + */ + @ApiOperation(value = "批量新增PK排期设置", notes = "批量创建多个PK排期设置") + @ApiResponses({@ApiResponse(code = 200, message = "操作成功"), @ApiResponse(code = 500, message = "添加失败")}) + @Log(title = "店员PK排期设置", businessType = BusinessType.INSERT) + @PostMapping(PkSettingApiConstants.BULK_CREATE_PATH) + public R bulkCreate( + @ApiParam(value = "PK排期设置列表", required = true) + @RequestBody PlayClerkPkSettingBulkCreateRequest request) { + return R.ok(pkSettingService.createSettings(request)); + } + + /** + * 修改PK排期设置 + */ + @ApiOperation(value = "修改PK排期设置", notes = "根据ID修改PK排期设置") + @ApiImplicitParam(name = "id", value = "设置ID", required = true, paramType = "path", dataType = "String") + @ApiResponses({@ApiResponse(code = 200, message = "操作成功"), @ApiResponse(code = 500, message = "修改失败")}) + @Log(title = "店员PK排期设置", businessType = BusinessType.UPDATE) + @PostMapping(PkSettingApiConstants.UPDATE_PATH) + public R update(@PathVariable("id") String id, + @ApiParam(value = "PK排期设置", required = true) @RequestBody PlayClerkPkSettingUpsertRequest request) { + pkSettingService.updateSetting(id, request); + return R.ok(); + } + + /** + * 启用PK排期设置 + */ + @ApiOperation(value = "启用PK排期设置", notes = "启用指定PK排期设置") + @ApiImplicitParam(name = "id", value = "设置ID", required = true, paramType = "path", dataType = "String") + @ApiResponses({@ApiResponse(code = 200, message = "操作成功")}) + @Log(title = "店员PK排期设置", businessType = BusinessType.UPDATE) + @PostMapping(PkSettingApiConstants.ENABLE_PATH) + public R enable(@PathVariable("id") String id) { + pkSettingService.enableSetting(id); + return R.ok(); + } + + /** + * 停用PK排期设置 + */ + @ApiOperation(value = "停用PK排期设置", notes = "停用指定PK排期设置") + @ApiImplicitParam(name = "id", value = "设置ID", required = true, paramType = "path", dataType = "String") + @ApiResponses({@ApiResponse(code = 200, message = "操作成功")}) + @Log(title = "店员PK排期设置", businessType = BusinessType.UPDATE) + @PostMapping(PkSettingApiConstants.DISABLE_PATH) + public R disable(@PathVariable("id") String id) { + pkSettingService.disableSetting(id); + return R.ok(); + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/setting/dto/PlayClerkPkSettingBulkCreateRequest.java b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/dto/PlayClerkPkSettingBulkCreateRequest.java new file mode 100644 index 0000000..69cec93 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/dto/PlayClerkPkSettingBulkCreateRequest.java @@ -0,0 +1,14 @@ +package com.starry.admin.modules.pk.setting.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import java.util.List; +import lombok.Data; + +@ApiModel(value = "PlayClerkPkSettingBulkCreateRequest", description = "店员PK排期设置批量创建请求") +@Data +public class PlayClerkPkSettingBulkCreateRequest { + + @ApiModelProperty(value = "批量排期设置列表", required = true) + private List settings; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/setting/dto/PlayClerkPkSettingUpsertRequest.java b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/dto/PlayClerkPkSettingUpsertRequest.java new file mode 100644 index 0000000..ec67f47 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/dto/PlayClerkPkSettingUpsertRequest.java @@ -0,0 +1,54 @@ +package com.starry.admin.modules.pk.setting.dto; + +import com.starry.admin.modules.pk.setting.enums.PkRecurrenceType; +import com.starry.admin.modules.pk.setting.enums.PkScheduleDayOfWeek; +import com.starry.admin.modules.pk.setting.enums.PkSettingStatus; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import java.time.LocalDate; +import java.time.LocalTime; +import lombok.Data; + +@ApiModel(value = "PlayClerkPkSettingUpsertRequest", description = "店员PK排期设置创建/更新请求") +@Data +public class PlayClerkPkSettingUpsertRequest { + + @ApiModelProperty(value = "设置名称", required = true) + private String name; + + @ApiModelProperty(value = "循环类型", required = true) + private PkRecurrenceType recurrenceType; + + @ApiModelProperty(value = "星期几") + private PkScheduleDayOfWeek dayOfWeek; + + @ApiModelProperty(value = "每月第N天") + private Integer dayOfMonth; + + @ApiModelProperty(value = "每年月份") + private Integer monthOfYear; + + @ApiModelProperty(value = "每日开始时间", required = true) + private LocalTime startTimeOfDay; + + @ApiModelProperty(value = "持续分钟数", required = true) + private Integer durationMinutes; + + @ApiModelProperty(value = "生效开始日期", required = true) + private LocalDate effectiveStartDate; + + @ApiModelProperty(value = "生效结束日期") + private LocalDate effectiveEndDate; + + @ApiModelProperty(value = "时区", required = true) + private String timezone; + + @ApiModelProperty(value = "店员A ID", required = true) + private String clerkAId; + + @ApiModelProperty(value = "店员B ID", required = true) + private String clerkBId; + + @ApiModelProperty(value = "状态", required = true) + private PkSettingStatus status; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/setting/entity/PlayClerkPkSettingEntity.java b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/entity/PlayClerkPkSettingEntity.java new file mode 100644 index 0000000..ef70242 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/entity/PlayClerkPkSettingEntity.java @@ -0,0 +1,62 @@ +package com.starry.admin.modules.pk.setting.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.starry.common.domain.BaseEntity; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import java.time.LocalDate; +import java.time.LocalTime; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@ApiModel(value = "PlayClerkPkSettingEntity", description = "店员PK排期设置") +@Data +@EqualsAndHashCode(callSuper = false) +@TableName("play_clerk_pk_settings") +public class PlayClerkPkSettingEntity extends BaseEntity { + + @ApiModelProperty(value = "UUID", required = true) + private String id; + + @ApiModelProperty(value = "租户ID", required = true) + private String tenantId; + + @ApiModelProperty(value = "设置名称", required = true) + private String name; + + @ApiModelProperty(value = "循环类型", required = true) + private String recurrenceType; + + @ApiModelProperty(value = "星期几") + private String dayOfWeek; + + @ApiModelProperty(value = "每月第N天") + private Integer dayOfMonth; + + @ApiModelProperty(value = "每年月份") + private Integer monthOfYear; + + @ApiModelProperty(value = "每日开始时间", required = true) + private LocalTime startTimeOfDay; + + @ApiModelProperty(value = "持续分钟数", required = true) + private Integer durationMinutes; + + @ApiModelProperty(value = "生效开始日期", required = true) + private LocalDate effectiveStartDate; + + @ApiModelProperty(value = "生效结束日期") + private LocalDate effectiveEndDate; + + @ApiModelProperty(value = "时区", required = true) + private String timezone; + + @ApiModelProperty(value = "店员A ID", required = true) + private String clerkAId; + + @ApiModelProperty(value = "店员B ID", required = true) + private String clerkBId; + + @ApiModelProperty(value = "状态", required = true) + private String status; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/setting/enums/PkRecurrenceType.java b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/enums/PkRecurrenceType.java new file mode 100644 index 0000000..63e2578 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/enums/PkRecurrenceType.java @@ -0,0 +1,18 @@ +package com.starry.admin.modules.pk.setting.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +public enum PkRecurrenceType { + ONCE("ONCE", "单次"), + DAILY("DAILY", "每日"), + WEEKLY("WEEKLY", "每周"), + MONTHLY("MONTHLY", "每月"), + YEARLY("YEARLY", "每年"); + + @Getter + private final String value; + @Getter + private final String desc; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/setting/enums/PkScheduleDayOfWeek.java b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/enums/PkScheduleDayOfWeek.java new file mode 100644 index 0000000..ee33066 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/enums/PkScheduleDayOfWeek.java @@ -0,0 +1,20 @@ +package com.starry.admin.modules.pk.setting.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +public enum PkScheduleDayOfWeek { + MONDAY("MONDAY", "周一"), + TUESDAY("TUESDAY", "周二"), + WEDNESDAY("WEDNESDAY", "周三"), + THURSDAY("THURSDAY", "周四"), + FRIDAY("FRIDAY", "周五"), + SATURDAY("SATURDAY", "周六"), + SUNDAY("SUNDAY", "周日"); + + @Getter + private final String value; + @Getter + private final String desc; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/setting/enums/PkSettingErrorCode.java b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/enums/PkSettingErrorCode.java new file mode 100644 index 0000000..1b94b17 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/enums/PkSettingErrorCode.java @@ -0,0 +1,20 @@ +package com.starry.admin.modules.pk.setting.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +public enum PkSettingErrorCode { + NOT_IMPLEMENTED("PK_SETTING_NOT_IMPLEMENTED", "PK排期设置功能尚未实现"), + TENANT_MISSING("PK_SETTING_TENANT_MISSING", "租户信息不能为空"), + SETTING_NOT_FOUND("PK_SETTING_NOT_FOUND", "PK排期设置不存在"), + REQUEST_INVALID("PK_SETTING_REQUEST_INVALID", "PK排期设置参数非法"), + RECURRENCE_INVALID("PK_SETTING_RECURRENCE_INVALID", "PK排期循环规则非法"), + TIME_RANGE_INVALID("PK_SETTING_TIME_RANGE_INVALID", "PK排期时间范围非法"), + CLERK_CONFLICT("PK_SETTING_CLERK_CONFLICT", "店员排期冲突"); + + @Getter + private final String code; + @Getter + private final String message; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/setting/enums/PkSettingStatus.java b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/enums/PkSettingStatus.java new file mode 100644 index 0000000..5887a7b --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/enums/PkSettingStatus.java @@ -0,0 +1,15 @@ +package com.starry.admin.modules.pk.setting.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +public enum PkSettingStatus { + ENABLED("ENABLED", "启用"), + DISABLED("DISABLED", "停用"); + + @Getter + private final String value; + @Getter + private final String desc; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/setting/mapper/PlayClerkPkSettingMapper.java b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/mapper/PlayClerkPkSettingMapper.java new file mode 100644 index 0000000..a73f33e --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/mapper/PlayClerkPkSettingMapper.java @@ -0,0 +1,7 @@ +package com.starry.admin.modules.pk.setting.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.starry.admin.modules.pk.setting.entity.PlayClerkPkSettingEntity; + +public interface PlayClerkPkSettingMapper extends BaseMapper { +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/setting/service/IPlayClerkPkSettingService.java b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/service/IPlayClerkPkSettingService.java new file mode 100644 index 0000000..3b3b816 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/service/IPlayClerkPkSettingService.java @@ -0,0 +1,27 @@ +package com.starry.admin.modules.pk.setting.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.IService; +import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingBulkCreateRequest; +import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingUpsertRequest; +import com.starry.admin.modules.pk.setting.entity.PlayClerkPkSettingEntity; +import java.util.List; + +public interface IPlayClerkPkSettingService extends IService { + + String createSetting(PlayClerkPkSettingUpsertRequest request); + + List createSettings(PlayClerkPkSettingBulkCreateRequest request); + + void updateSetting(String id, PlayClerkPkSettingUpsertRequest request); + + void enableSetting(String id); + + void disableSetting(String id); + + PlayClerkPkSettingEntity getSetting(String id); + + IPage listSettings(); + + int generateInstances(String id); +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/pk/setting/service/impl/PlayClerkPkSettingServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/service/impl/PlayClerkPkSettingServiceImpl.java new file mode 100644 index 0000000..770b1a5 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/pk/setting/service/impl/PlayClerkPkSettingServiceImpl.java @@ -0,0 +1,419 @@ +package com.starry.admin.modules.pk.setting.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +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.setting.constants.PkSettingValidationConstants; +import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingBulkCreateRequest; +import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingUpsertRequest; +import com.starry.admin.modules.pk.setting.entity.PlayClerkPkSettingEntity; +import com.starry.admin.modules.pk.setting.enums.PkRecurrenceType; +import com.starry.admin.modules.pk.setting.enums.PkScheduleDayOfWeek; +import com.starry.admin.modules.pk.setting.enums.PkSettingErrorCode; +import com.starry.admin.modules.pk.setting.enums.PkSettingStatus; +import com.starry.admin.modules.pk.setting.mapper.PlayClerkPkSettingMapper; +import com.starry.admin.modules.pk.setting.service.IPlayClerkPkSettingService; +import com.starry.admin.utils.SecurityUtils; +import com.starry.common.utils.IdUtils; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class PlayClerkPkSettingServiceImpl + extends ServiceImpl + implements IPlayClerkPkSettingService { + + private final IPlayClerkPkService clerkPkService; + private final StringRedisTemplate stringRedisTemplate; + private static final String PAIR_KEY_SEPARATOR = "::"; + + public PlayClerkPkSettingServiceImpl(IPlayClerkPkService clerkPkService, + StringRedisTemplate stringRedisTemplate) { + this.clerkPkService = clerkPkService; + this.stringRedisTemplate = stringRedisTemplate; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String createSetting(PlayClerkPkSettingUpsertRequest request) { + PlayClerkPkSettingEntity entity = buildEntity(request, null); + entity.setId(IdUtils.getUuid()); + if (!save(entity)) { + throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage()); + } + if (PkSettingStatus.ENABLED.getValue().equals(entity.getStatus())) { + generateInstances(entity.getId()); + } + return entity.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public List createSettings(PlayClerkPkSettingBulkCreateRequest request) { + List settings = validateBulkRequest(request); + List createdIds = new ArrayList<>(); + for (PlayClerkPkSettingUpsertRequest settingRequest : settings) { + PlayClerkPkSettingEntity entity = buildEntity(settingRequest, null); + entity.setId(IdUtils.getUuid()); + if (!save(entity)) { + throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage()); + } + if (PkSettingStatus.ENABLED.getValue().equals(entity.getStatus())) { + generateInstances(entity.getId()); + } + createdIds.add(entity.getId()); + } + return createdIds; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateSetting(String id, PlayClerkPkSettingUpsertRequest request) { + PlayClerkPkSettingEntity existing = getSetting(id); + PlayClerkPkSettingEntity entity = buildEntity(request, existing); + entity.setId(existing.getId()); + entity.setTenantId(existing.getTenantId()); + if (!updateById(entity)) { + throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void enableSetting(String id) { + PlayClerkPkSettingEntity setting = getSetting(id); + setting.setStatus(PkSettingStatus.ENABLED.getValue()); + updateById(setting); + generateInstances(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void disableSetting(String id) { + PlayClerkPkSettingEntity setting = getSetting(id); + setting.setStatus(PkSettingStatus.DISABLED.getValue()); + updateById(setting); + } + + @Override + public PlayClerkPkSettingEntity getSetting(String id) { + if (StrUtil.isBlank(id)) { + throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage()); + } + PlayClerkPkSettingEntity setting = getById(id); + if (setting == null) { + throw new CustomException(PkSettingErrorCode.SETTING_NOT_FOUND.getMessage()); + } + return setting; + } + + @Override + public IPage listSettings() { + Page page = new Page<>( + PkSettingValidationConstants.DEFAULT_PAGE_NUMBER, + PkSettingValidationConstants.DEFAULT_PAGE_SIZE); + return baseMapper.selectPage(page, new LambdaQueryWrapper<>()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int generateInstances(String id) { + PlayClerkPkSettingEntity setting = getSetting(id); + if (!PkSettingStatus.ENABLED.getValue().equals(setting.getStatus())) { + throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage()); + } + List dates = generateScheduleDates(setting); + ZoneId zoneId = ZoneId.of(setting.getTimezone()); + int createdCount = 0; + for (LocalDate date : dates) { + LocalDateTime startTime = LocalDateTime.of(date, setting.getStartTimeOfDay()); + LocalDateTime endTime = startTime.plusMinutes(setting.getDurationMinutes()); + Date beginTime = Date.from(startTime.atZone(zoneId).toInstant()); + Date finishTime = Date.from(endTime.atZone(zoneId).toInstant()); + + if (existsForSettingAt(setting.getId(), beginTime)) { + continue; + } + if (hasClerkConflict(setting, beginTime, finishTime)) { + throw new CustomException(PkSettingErrorCode.CLERK_CONFLICT.getMessage()); + } + PlayClerkPkEntity pk = new PlayClerkPkEntity(); + pk.setId(IdUtils.getUuid()); + pk.setTenantId(setting.getTenantId()); + pk.setClerkA(setting.getClerkAId()); + pk.setClerkB(setting.getClerkBId()); + pk.setPkBeginTime(beginTime); + pk.setPkEndTime(finishTime); + pk.setStatus(ClerkPkEnum.TO_BE_STARTED.name()); + pk.setSettled(0); + pk.setSettingId(setting.getId()); + clerkPkService.save(pk); + scheduleStart(pk); + createdCount++; + } + return createdCount; + } + + private PlayClerkPkSettingEntity buildEntity(PlayClerkPkSettingUpsertRequest request, + PlayClerkPkSettingEntity existing) { + validateRequest(request); + PlayClerkPkSettingEntity entity = Optional.ofNullable(existing).orElseGet(PlayClerkPkSettingEntity::new); + entity.setName(request.getName()); + entity.setRecurrenceType(request.getRecurrenceType().getValue()); + entity.setDayOfWeek(Optional.ofNullable(request.getDayOfWeek()) + .map(PkScheduleDayOfWeek::getValue) + .orElse(null)); + entity.setDayOfMonth(request.getDayOfMonth()); + entity.setMonthOfYear(request.getMonthOfYear()); + entity.setStartTimeOfDay(request.getStartTimeOfDay()); + entity.setDurationMinutes(request.getDurationMinutes()); + entity.setEffectiveStartDate(request.getEffectiveStartDate()); + entity.setEffectiveEndDate(request.getEffectiveEndDate()); + entity.setTimezone(request.getTimezone()); + entity.setClerkAId(request.getClerkAId()); + entity.setClerkBId(request.getClerkBId()); + entity.setStatus(request.getStatus().getValue()); + if (existing == null) { + entity.setTenantId(resolveTenantId()); + } + return entity; + } + + private void validateRequest(PlayClerkPkSettingUpsertRequest request) { + if (request == null) { + throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage()); + } + if (StrUtil.isBlank(request.getName()) + || request.getRecurrenceType() == null + || request.getStartTimeOfDay() == null + || request.getDurationMinutes() == null + || request.getEffectiveStartDate() == null + || request.getTimezone() == null + || StrUtil.isBlank(request.getClerkAId()) + || StrUtil.isBlank(request.getClerkBId()) + || request.getStatus() == null) { + throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage()); + } + if (request.getDurationMinutes() < PkSettingValidationConstants.MIN_DURATION_MINUTES) { + throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage()); + } + if (request.getClerkAId().equals(request.getClerkBId())) { + throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage()); + } + ensureZoneValid(request.getTimezone()); + validateRecurrenceFields(request); + validateEffectiveRange(request.getEffectiveStartDate(), request.getEffectiveEndDate()); + } + + private void scheduleStart(PlayClerkPkEntity pk) { + if (pk == null || pk.getPkBeginTime() == null || pk.getId() == null) { + throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage()); + } + if (StrUtil.isBlank(pk.getTenantId())) { + throw new CustomException(PkSettingErrorCode.TENANT_MISSING.getMessage()); + } + String scheduleKey = PkRedisKeyConstants.startScheduleKey(pk.getTenantId()); + long startEpochSeconds = pk.getPkBeginTime().toInstant().getEpochSecond(); + stringRedisTemplate.opsForZSet().add(scheduleKey, pk.getId(), startEpochSeconds); + } + + private List validateBulkRequest(PlayClerkPkSettingBulkCreateRequest request) { + if (request == null || request.getSettings() == null || request.getSettings().isEmpty()) { + throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage()); + } + Set pairKeys = new HashSet<>(); + for (PlayClerkPkSettingUpsertRequest setting : request.getSettings()) { + validateRequest(setting); + String pairKey = buildPairKey(setting.getClerkAId(), setting.getClerkBId()); + if (!pairKeys.add(pairKey)) { + throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage()); + } + } + return request.getSettings(); + } + + private String buildPairKey(String clerkAId, String clerkBId) { + if (clerkAId.compareTo(clerkBId) <= 0) { + return clerkAId + PAIR_KEY_SEPARATOR + clerkBId; + } + return clerkBId + PAIR_KEY_SEPARATOR + clerkAId; + } + + private void ensureZoneValid(String timezone) { + try { + ZoneId.of(timezone); + } catch (Exception ex) { + throw new CustomException(PkSettingErrorCode.REQUEST_INVALID.getMessage()); + } + } + + private void validateRecurrenceFields(PlayClerkPkSettingUpsertRequest request) { + PkRecurrenceType type = request.getRecurrenceType(); + if (type == PkRecurrenceType.WEEKLY && request.getDayOfWeek() == null) { + throw new CustomException(PkSettingErrorCode.RECURRENCE_INVALID.getMessage()); + } + if (type == PkRecurrenceType.MONTHLY && request.getDayOfMonth() == null) { + throw new CustomException(PkSettingErrorCode.RECURRENCE_INVALID.getMessage()); + } + if (type == PkRecurrenceType.YEARLY + && (request.getDayOfMonth() == null || request.getMonthOfYear() == null)) { + throw new CustomException(PkSettingErrorCode.RECURRENCE_INVALID.getMessage()); + } + if (request.getDayOfMonth() != null) { + int dayOfMonth = request.getDayOfMonth(); + if (dayOfMonth < PkSettingValidationConstants.MIN_DAY_OF_MONTH + || dayOfMonth > PkSettingValidationConstants.MAX_DAY_OF_MONTH) { + throw new CustomException(PkSettingErrorCode.RECURRENCE_INVALID.getMessage()); + } + } + if (request.getMonthOfYear() != null) { + int monthOfYear = request.getMonthOfYear(); + if (monthOfYear < PkSettingValidationConstants.MIN_MONTH_OF_YEAR + || monthOfYear > PkSettingValidationConstants.MAX_MONTH_OF_YEAR) { + throw new CustomException(PkSettingErrorCode.RECURRENCE_INVALID.getMessage()); + } + } + } + + private void validateEffectiveRange(LocalDate start, LocalDate end) { + if (end != null && end.isBefore(start)) { + throw new CustomException(PkSettingErrorCode.TIME_RANGE_INVALID.getMessage()); + } + } + + private List generateScheduleDates(PlayClerkPkSettingEntity setting) { + LocalDate start = setting.getEffectiveStartDate(); + LocalDate maxEnd = start.plusYears(PkSettingValidationConstants.MAX_YEARS_AHEAD); + LocalDate end = Optional.ofNullable(setting.getEffectiveEndDate()).orElse(maxEnd); + if (end.isAfter(maxEnd)) { + end = maxEnd; + } + PkRecurrenceType type = PkRecurrenceType.valueOf(setting.getRecurrenceType()); + List dates = new ArrayList<>(); + switch (type) { + case ONCE: + if (!start.isAfter(end)) { + dates.add(start); + } + break; + case DAILY: + for (LocalDate cursor = start; !cursor.isAfter(end); cursor = cursor.plusDays(1)) { + dates.add(cursor); + } + break; + case WEEKLY: + dates.addAll(generateWeeklyDates(setting, start, end)); + break; + case MONTHLY: + dates.addAll(generateMonthlyDates(setting, start, end)); + break; + case YEARLY: + dates.addAll(generateYearlyDates(setting, start, end)); + break; + default: + throw new CustomException(PkSettingErrorCode.RECURRENCE_INVALID.getMessage()); + } + return dates; + } + + private List generateWeeklyDates(PlayClerkPkSettingEntity setting, LocalDate start, LocalDate end) { + List dates = new ArrayList<>(); + DayOfWeek target = DayOfWeek.valueOf(setting.getDayOfWeek()); + LocalDate cursor = start; + while (cursor.getDayOfWeek() != target) { + cursor = cursor.plusDays(1); + } + for (LocalDate date = cursor; !date.isAfter(end); date = date.plusWeeks(1)) { + dates.add(date); + } + return dates; + } + + private List generateMonthlyDates(PlayClerkPkSettingEntity setting, LocalDate start, LocalDate end) { + List dates = new ArrayList<>(); + int dayOfMonth = setting.getDayOfMonth(); + YearMonth startMonth = YearMonth.from(start); + YearMonth endMonth = YearMonth.from(end); + for (YearMonth cursor = startMonth; !cursor.isAfter(endMonth); cursor = cursor.plusMonths(1)) { + if (dayOfMonth > cursor.lengthOfMonth()) { + continue; + } + LocalDate date = cursor.atDay(dayOfMonth); + if (date.isBefore(start) || date.isAfter(end)) { + continue; + } + dates.add(date); + } + return dates; + } + + private List generateYearlyDates(PlayClerkPkSettingEntity setting, LocalDate start, LocalDate end) { + List dates = new ArrayList<>(); + int dayOfMonth = setting.getDayOfMonth(); + int monthOfYear = setting.getMonthOfYear(); + for (int year = start.getYear(); year <= end.getYear(); year++) { + YearMonth yearMonth = YearMonth.of(year, monthOfYear); + if (dayOfMonth > yearMonth.lengthOfMonth()) { + continue; + } + LocalDate date = LocalDate.of(year, monthOfYear, dayOfMonth); + if (date.isBefore(start) || date.isAfter(end)) { + continue; + } + dates.add(date); + } + return dates; + } + + private boolean existsForSettingAt(String settingId, Date beginTime) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(PlayClerkPkEntity.class) + .eq(PlayClerkPkEntity::getSettingId, settingId) + .eq(PlayClerkPkEntity::getPkBeginTime, beginTime); + return clerkPkService.count(wrapper) > 0; + } + + private boolean hasClerkConflict(PlayClerkPkSettingEntity setting, Date beginTime, Date endTime) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(PlayClerkPkEntity.class) + .in(PlayClerkPkEntity::getStatus, + ClerkPkEnum.TO_BE_STARTED.name(), + ClerkPkEnum.IN_PROGRESS.name()) + .and(time -> time.le(PlayClerkPkEntity::getPkBeginTime, endTime) + .ge(PlayClerkPkEntity::getPkEndTime, beginTime)) + .and(clerk -> clerk.eq(PlayClerkPkEntity::getClerkA, setting.getClerkAId()) + .or() + .eq(PlayClerkPkEntity::getClerkB, setting.getClerkAId()) + .or() + .eq(PlayClerkPkEntity::getClerkA, setting.getClerkBId()) + .or() + .eq(PlayClerkPkEntity::getClerkB, setting.getClerkBId())); + return clerkPkService.count(wrapper) > 0; + } + + private String resolveTenantId() { + String tenantId = SecurityUtils.getTenantId(); + if (StrUtil.isBlank(tenantId)) { + throw new CustomException(PkSettingErrorCode.TENANT_MISSING.getMessage()); + } + return tenantId; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkCommodityController.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkCommodityController.java index fcdf941..5bdbfab 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkCommodityController.java +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxClerkCommodityController.java @@ -17,6 +17,7 @@ import io.swagger.annotations.ApiImplicitParam; import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiResponse; import io.swagger.annotations.ApiResponses; +import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -40,6 +41,9 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/wx/commodity/") public class WxClerkCommodityController { + private static final String ROOT_PARENT_ID = "00"; + private static final String CLERK_COMMODITY_ENABLED = "1"; + @Resource private IPlayCommodityInfoService playCommodityInfoService; @@ -63,6 +67,12 @@ public class WxClerkCommodityController { if (levelId == null || levelId.isEmpty()) { return R.ok(tree); } + if (levelInfoEntities == null) { + throw new CustomException("商品等级信息缺失"); + } + if (tree == null) { + throw new CustomException("商品树缺失"); + } tree = formatPlayCommodityReturnVoTree(tree, levelInfoEntities, levelId); tree = formatPlayCommodityReturnVoTree(tree, null); return R.ok(tree); @@ -84,11 +94,23 @@ public class WxClerkCommodityController { throw new CustomException("请求参数异常,id不能为空"); } PlayClerkUserInfoEntity clerkUserInfo = clerkUserInfoService.selectById(clerkId); + if (clerkUserInfo == null) { + throw new CustomException("店员不存在"); + } + if (clerkUserInfo.getLevelId() == null || clerkUserInfo.getLevelId().isEmpty()) { + throw new CustomException("店员等级信息缺失"); + } Map> clerkCommodityEntities = playClerkCommodityService - .selectCommodityTypeByUser(clerkId, "1").stream() + .selectCommodityTypeByUser(clerkId, CLERK_COMMODITY_ENABLED).stream() .collect(Collectors.groupingBy(PlayClerkCommodityEntity::getCommodityId)); List levelInfoEntities = iPlayCommodityAndLevelInfoService.selectAll(); List tree = playCommodityInfoService.selectTree(); + if (levelInfoEntities == null) { + throw new CustomException("商品等级信息缺失"); + } + if (tree == null) { + throw new CustomException("商品树缺失"); + } tree = formatPlayCommodityReturnVoTree(tree, levelInfoEntities, clerkUserInfo.getLevelId()); tree = formatPlayCommodityReturnVoTree(tree, clerkCommodityEntities); return R.ok(tree); @@ -108,10 +130,16 @@ public class WxClerkCommodityController { String levelId = ThreadLocalRequestDetail.getClerkUserInfo().getLevelId(); List levelInfoEntities = iPlayCommodityAndLevelInfoService.selectAll(); Map> clerkCommodityEntities = playClerkCommodityService - .selectCommodityTypeByUser(ThreadLocalRequestDetail.getClerkUserInfo().getId(), "1").stream() + .selectCommodityTypeByUser(ThreadLocalRequestDetail.getClerkUserInfo().getId(), CLERK_COMMODITY_ENABLED).stream() .collect(Collectors.groupingBy(PlayClerkCommodityEntity::getCommodityId)); List tree = playCommodityInfoService.selectTree(); + if (levelInfoEntities == null) { + throw new CustomException("商品等级信息缺失"); + } + if (tree == null) { + throw new CustomException("商品树缺失"); + } tree = formatPlayCommodityReturnVoTree(tree, levelInfoEntities, levelId); tree = formatPlayCommodityReturnVoTree(tree, clerkCommodityEntities); return R.ok(tree); @@ -119,9 +147,21 @@ public class WxClerkCommodityController { public List formatPlayCommodityReturnVoTree(List tree, List levelInfoEntities, String levelId) { + if (tree == null) { + throw new CustomException("商品树缺失"); + } + if (levelInfoEntities == null) { + throw new CustomException("商品等级信息缺失"); + } + if (levelId == null || levelId.isEmpty()) { + throw new CustomException("等级信息缺失"); + } Iterator it = tree.iterator(); while (it.hasNext()) { PlayCommodityReturnVo item = it.next(); + if (item.getChild() == null) { + item.setChild(new ArrayList<>()); + } // 查找当前服务项目对应的价格 for (PlayCommodityAndLevelInfoEntity levelInfoEntity : levelInfoEntities) { if (item.getId().equals(levelInfoEntity.getCommodityId()) @@ -130,7 +170,7 @@ public class WxClerkCommodityController { } } // 如果未设置价格,删除元素 - if (!"00".equals(item.getPId()) && item.getPrice() == null) { + if (!ROOT_PARENT_ID.equals(item.getPId()) && item.getPrice() == null) { it.remove(); } formatPlayCommodityReturnVoTree(item.getChild(), levelInfoEntities, levelId); @@ -140,12 +180,18 @@ public class WxClerkCommodityController { public List formatPlayCommodityReturnVoTree(List tree, Map> clerkCommodityEntities) { + if (tree == null) { + throw new CustomException("商品树缺失"); + } Iterator it = tree.iterator(); while (it.hasNext()) { PlayCommodityReturnVo item = it.next(); - if ("00".equals(item.getPId()) && item.getChild().isEmpty()) { + if (item.getChild() == null) { + item.setChild(new ArrayList<>()); + } + if (ROOT_PARENT_ID.equals(item.getPId()) && item.getChild().isEmpty()) { it.remove(); - } else if (clerkCommodityEntities != null && "00".equals(item.getPId()) + } else if (clerkCommodityEntities != null && ROOT_PARENT_ID.equals(item.getPId()) && !clerkCommodityEntities.containsKey(item.getId())) { it.remove(); } diff --git a/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxPkController.java b/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxPkController.java new file mode 100644 index 0000000..12b1a5f --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/weichat/controller/WxPkController.java @@ -0,0 +1,279 @@ +package com.starry.admin.modules.weichat.controller; + +import cn.hutool.core.util.StrUtil; +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.module.entity.PlayClerkUserInfoEntity; +import com.starry.admin.modules.clerk.service.IPlayClerkPkService; +import com.starry.admin.modules.clerk.service.IPlayClerkUserInfoService; +import com.starry.admin.modules.pk.constants.PkWxQueryConstants; +import com.starry.admin.modules.pk.dto.PkScoreBoardDto; +import com.starry.admin.modules.pk.dto.WxPkClerkHistoryPageDto; +import com.starry.admin.modules.pk.dto.WxPkDetailDto; +import com.starry.admin.modules.pk.dto.WxPkLiveDto; +import com.starry.admin.modules.pk.dto.WxPkUpcomingDto; +import com.starry.admin.modules.pk.enums.PkWxState; +import com.starry.admin.modules.pk.redis.PkRedisKeyConstants; +import com.starry.admin.modules.pk.service.IPkDetailService; +import com.starry.admin.modules.pk.service.IPkScoreboardService; +import com.starry.admin.utils.SecurityUtils; +import com.starry.common.result.R; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Api(tags = "微信PK接口", description = "微信端PK展示相关接口") +@RestController +@RequestMapping("/wx/pk") +public class WxPkController { + + private final IPlayClerkPkService clerkPkService; + private final IPlayClerkUserInfoService clerkUserInfoService; + private final IPkScoreboardService pkScoreboardService; + private final IPkDetailService pkDetailService; + private final StringRedisTemplate stringRedisTemplate; + + public WxPkController(IPlayClerkPkService clerkPkService, + IPlayClerkUserInfoService clerkUserInfoService, + IPkScoreboardService pkScoreboardService, + IPkDetailService pkDetailService, + StringRedisTemplate stringRedisTemplate) { + this.clerkPkService = clerkPkService; + this.clerkUserInfoService = clerkUserInfoService; + this.pkScoreboardService = pkScoreboardService; + this.pkDetailService = pkDetailService; + this.stringRedisTemplate = stringRedisTemplate; + } + + @ApiOperation(value = "店员PK实时状态", notes = "查询指定店员是否正在PK") + @GetMapping("/clerk/live") + public R getClerkLive(@RequestParam("clerkId") String clerkId) { + if (StrUtil.isBlank(clerkId)) { + throw new CustomException("店员ID不能为空"); + } + Optional pkOptional = + clerkPkService.findActivePkForClerk(clerkId, LocalDateTime.now()); + if (!pkOptional.isPresent()) { + return R.ok(WxPkLiveDto.inactive()); + } + PlayClerkPkEntity pk = pkOptional.get(); + if (!ClerkPkEnum.IN_PROGRESS.name().equals(pk.getStatus())) { + return R.ok(WxPkLiveDto.inactive()); + } + PkScoreBoardDto scoreboard = pkScoreboardService.getScoreboard(pk.getId()); + return R.ok(buildLiveDto(pk, scoreboard)); + } + + @ApiOperation(value = "即将开战PK", notes = "返回即将开始的PK信息") + @GetMapping("/upcoming") + public R getUpcoming() { + String tenantId = SecurityUtils.getTenantId(); + if (StrUtil.isBlank(tenantId)) { + throw new CustomException("租户信息缺失"); + } + String key = PkRedisKeyConstants.upcomingKey(tenantId); + long nowEpochSeconds = Instant.now().getEpochSecond(); + Set pkIds = stringRedisTemplate.opsForZSet() + .rangeByScore(key, nowEpochSeconds, Double.POSITIVE_INFINITY, 0, 1); + if (pkIds == null || pkIds.isEmpty()) { + return R.ok(WxPkUpcomingDto.inactive()); + } + String pkId = pkIds.iterator().next(); + PlayClerkPkEntity pk = clerkPkService.selectPlayClerkPkById(pkId); + if (pk == null) { + return R.ok(WxPkUpcomingDto.inactive()); + } + return R.ok(buildUpcomingDto(pk)); + } + + @ApiOperation(value = "PK详情", notes = "查询PK详情用于详情页") + @GetMapping("/detail") + public R getDetail(@RequestParam("id") String id) { + if (StrUtil.isBlank(id)) { + throw new CustomException("PK ID不能为空"); + } + PlayClerkPkEntity pk = clerkPkService.selectPlayClerkPkById(id); + if (pk == null) { + return R.ok(WxPkDetailDto.inactive()); + } + PkScoreBoardDto scoreboard = pkScoreboardService.getScoreboard(pk.getId()); + return R.ok(buildDetailDto(pk, scoreboard)); + } + + @ApiOperation(value = "店员PK历史", notes = "查询指定店员PK历史") + @GetMapping("/clerk/history") + public R getClerkHistory(@RequestParam("clerkId") String clerkId, + @RequestParam(value = "pageNum", required = false) Integer pageNum, + @RequestParam(value = "pageSize", required = false) Integer pageSize) { + if (StrUtil.isBlank(clerkId)) { + throw new CustomException("店员ID不能为空"); + } + int safePageNum = pageNum == null ? PkWxQueryConstants.CLERK_HISTORY_PAGE_NUM : pageNum; + int safePageSize = pageSize == null ? PkWxQueryConstants.CLERK_HISTORY_PAGE_SIZE : pageSize; + WxPkClerkHistoryPageDto data = pkDetailService.getClerkHistory(clerkId, safePageNum, safePageSize); + return R.ok(data); + } + + @ApiOperation(value = "店员PK日程", notes = "查询指定店员未来PK安排") + @GetMapping("/clerk/schedule") + public R getClerkSchedule(@RequestParam("clerkId") String clerkId, + @RequestParam(value = "limit", required = false) Integer limit) { + if (StrUtil.isBlank(clerkId)) { + throw new CustomException("店员ID不能为空"); + } + String tenantId = SecurityUtils.getTenantId(); + if (StrUtil.isBlank(tenantId)) { + throw new CustomException("租户信息缺失"); + } + int safeLimit = normalizeLimit(limit); + List items = clerkPkService.selectUpcomingForClerk( + tenantId, + clerkId, + new Date(), + safeLimit); + if (items == null || items.isEmpty()) { + return R.ok(new ArrayList<>()); + } + List result = new ArrayList<>(); + for (PlayClerkPkEntity pk : items) { + result.add(buildUpcomingDto(pk)); + } + return R.ok(result); + } + + private WxPkLiveDto buildLiveDto(PlayClerkPkEntity pk, PkScoreBoardDto scoreboard) { + WxPkLiveDto dto = new WxPkLiveDto(); + fillBase(dto, pk); + dto.setState(PkWxState.ACTIVE.getValue()); + dto.setClerkAScore(scoreboard.getClerkAScore()); + dto.setClerkBScore(scoreboard.getClerkBScore()); + dto.setClerkAOrderCount(scoreboard.getClerkAOrderCount()); + dto.setClerkBOrderCount(scoreboard.getClerkBOrderCount()); + dto.setRemainingSeconds(scoreboard.getRemainingSeconds()); + dto.setServerEpochSeconds(Instant.now().getEpochSecond()); + if (pk.getPkEndTime() != null) { + dto.setPkEndEpochSeconds(pk.getPkEndTime().toInstant().getEpochSecond()); + } + return dto; + } + + private WxPkUpcomingDto buildUpcomingDto(PlayClerkPkEntity pk) { + WxPkUpcomingDto dto = new WxPkUpcomingDto(); + fillBase(dto, pk); + dto.setState(PkWxState.UPCOMING.getValue()); + dto.setPkBeginTime(pk.getPkBeginTime()); + return dto; + } + + private WxPkDetailDto buildDetailDto(PlayClerkPkEntity pk, PkScoreBoardDto scoreboard) { + WxPkDetailDto dto = new WxPkDetailDto(); + fillBase(dto, pk); + dto.setState(resolveState(pk.getStatus())); + dto.setClerkAScore(scoreboard.getClerkAScore()); + dto.setClerkBScore(scoreboard.getClerkBScore()); + dto.setClerkAOrderCount(scoreboard.getClerkAOrderCount()); + dto.setClerkBOrderCount(scoreboard.getClerkBOrderCount()); + dto.setRemainingSeconds(scoreboard.getRemainingSeconds()); + dto.setPkBeginTime(pk.getPkBeginTime()); + dto.setPkEndTime(pk.getPkEndTime()); + dto.setContributors(pkDetailService.getContributors(pk)); + dto.setHistory(pkDetailService.getHistory(pk)); + return dto; + } + + private int normalizeLimit(Integer limit) { + if (limit == null) { + return PkWxQueryConstants.CLERK_SCHEDULE_DEFAULT_LIMIT; + } + if (limit < PkWxQueryConstants.CLERK_SCHEDULE_MIN_LIMIT) { + return PkWxQueryConstants.CLERK_SCHEDULE_MIN_LIMIT; + } + if (limit > PkWxQueryConstants.CLERK_SCHEDULE_MAX_LIMIT) { + return PkWxQueryConstants.CLERK_SCHEDULE_MAX_LIMIT; + } + return limit; + } + + private String resolveState(String status) { + if (ClerkPkEnum.IN_PROGRESS.name().equals(status)) { + return PkWxState.ACTIVE.getValue(); + } + if (ClerkPkEnum.TO_BE_STARTED.name().equals(status)) { + return PkWxState.UPCOMING.getValue(); + } + return PkWxState.INACTIVE.getValue(); + } + + private void fillBase(WxPkLiveDto dto, PlayClerkPkEntity pk) { + dto.setId(pk.getId()); + dto.setClerkAId(pk.getClerkA()); + dto.setClerkBId(pk.getClerkB()); + fillClerkInfo(dto); + } + + private void fillBase(WxPkUpcomingDto dto, PlayClerkPkEntity pk) { + dto.setId(pk.getId()); + dto.setClerkAId(pk.getClerkA()); + dto.setClerkBId(pk.getClerkB()); + fillClerkInfo(dto); + } + + private void fillBase(WxPkDetailDto dto, PlayClerkPkEntity pk) { + dto.setId(pk.getId()); + dto.setClerkAId(pk.getClerkA()); + dto.setClerkBId(pk.getClerkB()); + fillClerkInfo(dto); + } + + private void fillClerkInfo(WxPkLiveDto dto) { + fillClerkInfo(dto.getClerkAId(), dto.getClerkBId(), dto); + } + + private void fillClerkInfo(WxPkUpcomingDto dto) { + fillClerkInfo(dto.getClerkAId(), dto.getClerkBId(), dto); + } + + private void fillClerkInfo(WxPkDetailDto dto) { + fillClerkInfo(dto.getClerkAId(), dto.getClerkBId(), dto); + } + + private void fillClerkInfo(String clerkAId, String clerkBId, Object target) { + PlayClerkUserInfoEntity clerkA = clerkUserInfoService.getById(clerkAId); + PlayClerkUserInfoEntity clerkB = clerkUserInfoService.getById(clerkBId); + if (target instanceof WxPkLiveDto) { + WxPkLiveDto dto = (WxPkLiveDto) target; + dto.setClerkAName(clerkA == null ? "" : clerkA.getNickname()); + dto.setClerkBName(clerkB == null ? "" : clerkB.getNickname()); + dto.setClerkAAvatar(clerkA == null ? "" : clerkA.getAvatar()); + dto.setClerkBAvatar(clerkB == null ? "" : clerkB.getAvatar()); + return; + } + if (target instanceof WxPkUpcomingDto) { + WxPkUpcomingDto dto = (WxPkUpcomingDto) target; + dto.setClerkAName(clerkA == null ? "" : clerkA.getNickname()); + dto.setClerkBName(clerkB == null ? "" : clerkB.getNickname()); + dto.setClerkAAvatar(clerkA == null ? "" : clerkA.getAvatar()); + dto.setClerkBAvatar(clerkB == null ? "" : clerkB.getAvatar()); + return; + } + if (target instanceof WxPkDetailDto) { + WxPkDetailDto dto = (WxPkDetailDto) target; + dto.setClerkAName(clerkA == null ? "" : clerkA.getNickname()); + dto.setClerkBName(clerkB == null ? "" : clerkB.getNickname()); + dto.setClerkAAvatar(clerkA == null ? "" : clerkA.getAvatar()); + dto.setClerkBAvatar(clerkB == null ? "" : clerkB.getAvatar()); + } + } +} diff --git a/play-admin/src/main/java/com/starry/admin/utils/TenantScope.java b/play-admin/src/main/java/com/starry/admin/utils/TenantScope.java new file mode 100644 index 0000000..d4414f1 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/utils/TenantScope.java @@ -0,0 +1,19 @@ +package com.starry.admin.utils; + +public final class TenantScope implements AutoCloseable { + private final String previousTenantId; + + private TenantScope(String tenantId) { + this.previousTenantId = SecurityUtils.getTenantId(); + SecurityUtils.setTenantId(tenantId); + } + + public static TenantScope use(String tenantId) { + return new TenantScope(tenantId); + } + + @Override + public void close() { + SecurityUtils.setTenantId(previousTenantId); + } +} 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/V19__add_pk_scores_and_winner.sql similarity index 100% rename from play-admin/src/main/resources/db/migration/V18__add_pk_scores_and_winner.sql rename to play-admin/src/main/resources/db/migration/V19__add_pk_scores_and_winner.sql diff --git a/play-admin/src/main/resources/db/migration/V20__create_clerk_pk_settings.sql b/play-admin/src/main/resources/db/migration/V20__create_clerk_pk_settings.sql new file mode 100644 index 0000000..237f5e9 --- /dev/null +++ b/play-admin/src/main/resources/db/migration/V20__create_clerk_pk_settings.sql @@ -0,0 +1,26 @@ +CREATE TABLE `play_clerk_pk_settings` ( + `id` varchar(32) NOT NULL COMMENT 'UUID', + `tenant_id` varchar(32) NOT NULL COMMENT '租户ID', + `name` varchar(255) NOT NULL COMMENT '设置名称', + `recurrence_type` varchar(32) NOT NULL COMMENT '循环类型', + `day_of_week` varchar(16) DEFAULT NULL COMMENT '星期几', + `day_of_month` int DEFAULT NULL COMMENT '每月第N天', + `month_of_year` int DEFAULT NULL COMMENT '每年月份', + `start_time_of_day` time NOT NULL COMMENT '每日开始时间', + `duration_minutes` int NOT NULL COMMENT '持续分钟数', + `effective_start_date` date NOT NULL COMMENT '生效开始日期', + `effective_end_date` date DEFAULT NULL COMMENT '生效结束日期', + `timezone` varchar(64) NOT NULL COMMENT '时区', + `clerk_a_id` varchar(255) NOT NULL COMMENT '店员A ID', + `clerk_b_id` varchar(255) NOT NULL COMMENT '店员B ID', + `status` varchar(32) NOT NULL COMMENT '状态', + `created_by` varchar(32) DEFAULT NULL COMMENT '创建人的id', + `created_time` datetime DEFAULT NULL COMMENT '创建时间', + `updated_by` varchar(32) DEFAULT NULL COMMENT '修改人的id', + `updated_time` datetime DEFAULT NULL COMMENT '修改时间', + `deleted` varchar(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除 1已删除 0未删除', + `version` int NOT NULL DEFAULT '1' COMMENT '数据版本', + PRIMARY KEY (`id`) USING BTREE, + KEY `key_tenant_id` (`tenant_id`) USING BTREE, + KEY `idx_pk_settings_status` (`status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='店员PK排期设置'; diff --git a/play-admin/src/main/resources/db/migration/V21__add_pk_setting_id.sql b/play-admin/src/main/resources/db/migration/V21__add_pk_setting_id.sql new file mode 100644 index 0000000..dc18b14 --- /dev/null +++ b/play-admin/src/main/resources/db/migration/V21__add_pk_setting_id.sql @@ -0,0 +1,3 @@ +ALTER TABLE `play_clerk_pk` + ADD COLUMN `setting_id` varchar(32) DEFAULT NULL COMMENT 'PK排期设置ID' AFTER `status`, + ADD KEY `idx_pk_setting_id` (`setting_id`); diff --git a/play-admin/src/main/resources/db/migration/V22__add_pk_menu.sql b/play-admin/src/main/resources/db/migration/V22__add_pk_menu.sql new file mode 100644 index 0000000..a72ed78 --- /dev/null +++ b/play-admin/src/main/resources/db/migration/V22__add_pk_menu.sql @@ -0,0 +1,288 @@ +-- Add PK management menus for PC tenant. + +SET @pk_root_id := ( + SELECT menu_id + FROM sys_menu + WHERE menu_name = 'PK管理' AND parent_id = 0 AND deleted = 0 + LIMIT 1 +); + +INSERT INTO sys_menu ( + menu_name, + menu_code, + icon, + permission, + menu_level, + sort, + parent_id, + menu_type, + status, + remark, + path, + component, + router_query, + is_frame, + visible, + updated_time, + updated_by, + created_time, + created_by, + deleted, + version, + perms +) +SELECT + 'PK管理', + 'PkManage', + 'el-icon-trophy', + '', + 1, + 90, + 0, + 0, + 0, + 'PK管理', + 'play/pk', + 'Layout', + '', + 0, + 1, + NOW(), + 'migration_v22_pk_menu', + NOW(), + 'migration_v22_pk_menu', + 0, + 1, + '' +WHERE @pk_root_id IS NULL; + +SET @pk_root_id := ( + SELECT menu_id + FROM sys_menu + WHERE menu_name = 'PK管理' AND parent_id = 0 AND deleted = 0 + LIMIT 1 +); + +SET @pk_schedule_id := ( + SELECT menu_id + FROM sys_menu + WHERE menu_name = '排期管理' AND parent_id = @pk_root_id AND deleted = 0 + LIMIT 1 +); + +INSERT INTO sys_menu ( + menu_name, + menu_code, + icon, + permission, + menu_level, + sort, + parent_id, + menu_type, + status, + remark, + path, + component, + router_query, + is_frame, + visible, + updated_time, + updated_by, + created_time, + created_by, + deleted, + version, + perms +) +SELECT + '排期管理', + 'PkSchedule', + 'el-icon-date', + 'play:pk:schedule:list', + 2, + 1, + @pk_root_id, + 1, + 0, + '排期管理', + 'schedule', + 'play/pk/schedule/index', + '', + 0, + 1, + NOW(), + 'migration_v22_pk_menu', + NOW(), + 'migration_v22_pk_menu', + 0, + 1, + '' +WHERE @pk_root_id IS NOT NULL AND @pk_schedule_id IS NULL; + +SET @pk_live_id := ( + SELECT menu_id + FROM sys_menu + WHERE menu_name = '实时监控' AND parent_id = @pk_root_id AND deleted = 0 + LIMIT 1 +); + +INSERT INTO sys_menu ( + menu_name, + menu_code, + icon, + permission, + menu_level, + sort, + parent_id, + menu_type, + status, + remark, + path, + component, + router_query, + is_frame, + visible, + updated_time, + updated_by, + created_time, + created_by, + deleted, + version, + perms +) +SELECT + '实时监控', + 'PkLive', + 'el-icon-video-play', + 'play:pk:live:list', + 2, + 2, + @pk_root_id, + 1, + 0, + '实时监控', + 'live', + 'play/pk/live/index', + '', + 0, + 1, + NOW(), + 'migration_v22_pk_menu', + NOW(), + 'migration_v22_pk_menu', + 0, + 1, + '' +WHERE @pk_root_id IS NOT NULL AND @pk_live_id IS NULL; + +SET @pk_history_id := ( + SELECT menu_id + FROM sys_menu + WHERE menu_name = '战绩列表' AND parent_id = @pk_root_id AND deleted = 0 + LIMIT 1 +); + +INSERT INTO sys_menu ( + menu_name, + menu_code, + icon, + permission, + menu_level, + sort, + parent_id, + menu_type, + status, + remark, + path, + component, + router_query, + is_frame, + visible, + updated_time, + updated_by, + created_time, + created_by, + deleted, + version, + perms +) +SELECT + '战绩列表', + 'PkHistory', + 'el-icon-data-analysis', + 'play:pk:history:list', + 2, + 3, + @pk_root_id, + 1, + 0, + '战绩列表', + 'history', + 'play/pk/history/index', + '', + 0, + 1, + NOW(), + 'migration_v22_pk_menu', + NOW(), + 'migration_v22_pk_menu', + 0, + 1, + '' +WHERE @pk_root_id IS NOT NULL AND @pk_history_id IS NULL; + +SET @pk_settings_id := ( + SELECT menu_id + FROM sys_menu + WHERE menu_name = '规则设置' AND parent_id = @pk_root_id AND deleted = 0 + LIMIT 1 +); + +INSERT INTO sys_menu ( + menu_name, + menu_code, + icon, + permission, + menu_level, + sort, + parent_id, + menu_type, + status, + remark, + path, + component, + router_query, + is_frame, + visible, + updated_time, + updated_by, + created_time, + created_by, + deleted, + version, + perms +) +SELECT + '规则设置', + 'PkSettings', + 'el-icon-setting', + 'play:pk:settings:list', + 2, + 4, + @pk_root_id, + 1, + 0, + '规则设置', + 'settings', + 'play/pk/settings/index', + '', + 0, + 1, + NOW(), + 'migration_v22_pk_menu', + NOW(), + 'migration_v22_pk_menu', + 0, + 1, + '' +WHERE @pk_root_id IS NOT NULL AND @pk_settings_id IS NULL; diff --git a/play-admin/src/main/resources/db/migration/V23__fix_pk_menu_path.sql b/play-admin/src/main/resources/db/migration/V23__fix_pk_menu_path.sql new file mode 100644 index 0000000..fa376b5 --- /dev/null +++ b/play-admin/src/main/resources/db/migration/V23__fix_pk_menu_path.sql @@ -0,0 +1,10 @@ +-- Fix PK root menu path to ensure correct routing. + +UPDATE sys_menu +SET path = '/play/pk', + updated_time = NOW(), + updated_by = 'migration_v23_fix_pk_path' +WHERE menu_name = 'PK管理' + AND parent_id = 0 + AND deleted = 0 + AND path = 'play/pk'; 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 index 824fe5d..f6695f3 100644 --- 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 @@ -1,7 +1,9 @@ package com.starry.admin.modules.pk; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.starry.admin.api.AbstractApiTest; import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum; import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity; @@ -9,19 +11,37 @@ 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.reminder.task.ClerkPkUpcomingReminderJob; +import com.starry.admin.modules.pk.scheduler.constants.PkSchedulerConstants; +import com.starry.admin.modules.pk.scheduler.task.PkFinishSchedulerJob; +import com.starry.admin.modules.pk.scheduler.task.PkStartSchedulerJob; import com.starry.admin.modules.pk.service.ClerkPkLifecycleService; import com.starry.admin.modules.pk.service.IPkScoreboardService; +import com.starry.admin.modules.pk.service.impl.ClerkPkLifecycleServiceImpl; +import com.starry.admin.modules.pk.setting.dto.PlayClerkPkSettingUpsertRequest; +import com.starry.admin.modules.pk.setting.enums.PkRecurrenceType; +import com.starry.admin.modules.pk.setting.enums.PkSettingStatus; +import com.starry.admin.modules.pk.setting.service.IPlayClerkPkSettingService; +import com.starry.admin.modules.system.module.entity.SysTenantEntity; +import com.starry.admin.modules.system.service.ISysTenantService; import com.starry.admin.utils.SecurityUtils; import com.starry.common.utils.IdUtils; import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.ZoneId; +import java.util.Arrays; import java.util.Date; +import java.util.List; +import java.util.Set; 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.mockito.Mockito; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.data.redis.core.StringRedisTemplate; /** @@ -29,21 +49,96 @@ import org.springframework.data.redis.core.StringRedisTemplate; */ class PkIntegrationTest extends AbstractApiTest { + private static final String CLERK_PREFIX = "pk-clerk-"; + private static final String ORDER_ID_1 = "order-1"; + private static final String ORDER_ID_2 = "order-2"; + private static final String ORDER_ID_3 = "order-3"; + private static final String ORDER_ID_A1 = "order-a1"; + private static final String ORDER_ID_B1 = "order-b1"; + private static final String ORDER_ID_A2 = "order-a2"; + private static final String GIFT_ID_1 = "gift-1"; + private static final String GIFT_ID_DUP = "gift-dup"; + private static final String RECHARGE_ID_1 = "recharge-1"; + private static final String RECHARGE_ID_DUP = "recharge-dup"; + private static final String UPCOMING_TENANT = "tenant-upcoming"; + private static final String OTHER_TENANT = "tenant-other"; + private static final String EMPTY_UPCOMING_TENANT = "tenant-empty-upcoming"; + private static final String EMPTY_TENANT = ""; + private static final BigDecimal AMOUNT_100_50 = new BigDecimal("100.50"); + private static final BigDecimal AMOUNT_50_25 = new BigDecimal("50.25"); + private static final BigDecimal AMOUNT_20_00 = new BigDecimal("20.00"); + private static final BigDecimal AMOUNT_80_00 = new BigDecimal("80.00"); + private static final BigDecimal AMOUNT_30_00 = new BigDecimal("30.00"); + private static final BigDecimal AMOUNT_40_00 = new BigDecimal("40.00"); + private static final BigDecimal AMOUNT_10_00 = new BigDecimal("10.00"); + private static final BigDecimal AMOUNT_15_00 = new BigDecimal("15.00"); + private static final BigDecimal AMOUNT_12_00 = new BigDecimal("12.00"); + private static final BigDecimal AMOUNT_5_00 = new BigDecimal("5.00"); + private static final BigDecimal AMOUNT_0_00 = new BigDecimal("0.00"); + private static final BigDecimal TOTAL_SCORE_A = new BigDecimal("120.50"); + private static final BigDecimal TOTAL_SCORE_GIFT_RECHARGE = new BigDecimal("25.00"); + private static final BigDecimal TOTAL_SCORE_DUP_DEDUP = new BigDecimal("17.00"); + private static final int ORDER_COUNT_TWO = 2; + private static final int ORDER_COUNT_ONE = 1; + private static final int ORDER_COUNT_ZERO = 0; + private static final int SETTLED_TRUE = 1; + private static final int SETTLED_FALSE = 0; + private static final int MINUTES_BEFORE_START = 5; + private static final int MINUTES_AFTER_END = 10; + private static final int MINUTES_AFTER_START = 30; + private static final int MINUTES_FUTURE_START = 15; + private static final int SETTING_DURATION_MINUTES = 30; + private static final int EXPECTED_ONE = 1; + private static final int DAYS_PAST = 1; + private static final int DAYS_FUTURE = 1; + private static final int UPCOMING_MINUTES = 30; + private static final int PAST_MINUTES = 10; + private static final long REMAINING_SECONDS_ZERO = 0L; + private static final double ZSET_SCORE_PAST = 1D; + private static final String LEGACY_UPCOMING_ID = "legacy-upcoming"; + private static final String SCORE_KEY_PATTERN = "pk:*:score"; + private static final long MIN_TTL_SECONDS = 1L; + private static final int ATTEMPT_INDEX_START = 0; + private static final int ATTEMPT_STEP = 1; + private static final String TIMEZONE_SHANGHAI = "Asia/Shanghai"; + private static final String SETTING_NAME_PREFIX = "setting-"; + private static final String TENANT_ORIGIN = "tenant-origin"; + private static final String TENANT_A = "tenant-a"; + private static final String TENANT_B = "tenant-b"; + @Resource private IPlayClerkPkService clerkPkService; + @Resource + private IPlayClerkPkSettingService clerkPkSettingService; + @Resource private IPkScoreboardService pkScoreboardService; @Resource private ClerkPkLifecycleService clerkPkLifecycleService; + @SpyBean + private ClerkPkLifecycleServiceImpl clerkPkLifecycleServiceSpy; + + @SpyBean + private ISysTenantService sysTenantServiceSpy; + @Resource private com.starry.admin.modules.pk.listener.PkContributionListener pkContributionListener; @Resource private StringRedisTemplate stringRedisTemplate; + @Resource + private ClerkPkUpcomingReminderJob upcomingReminderJob; + + @Resource + private PkStartSchedulerJob startSchedulerJob; + + @Resource + private PkFinishSchedulerJob finishSchedulerJob; + @BeforeEach void clearPkKeys() { if (stringRedisTemplate.getConnectionFactory() == null) { @@ -57,8 +152,8 @@ class PkIntegrationTest extends AbstractApiTest { void contributionEventsShouldAccumulateScoresInRedis() { LocalDateTime now = LocalDateTime.now(); String pkId = IdUtils.getUuid(); - String clerkAId = "pk-clerk-a"; - String clerkBId = "pk-clerk-b"; + String clerkAId = newClerkId(); + String clerkBId = newClerkId(); SecurityUtils.setTenantId(DEFAULT_TENANT); PlayClerkPkEntity pk = buildActivePk(pkId, clerkAId, clerkBId, now); @@ -71,24 +166,24 @@ class PkIntegrationTest extends AbstractApiTest { assertThat(clerkPkService.findActivePkForClerk(clerkBId, now)).isPresent(); pkContributionListener.onContribution( - PkContributionEvent.orderContribution("order-1", clerkAId, new BigDecimal("100.50"), now)); + PkContributionEvent.orderContribution(ORDER_ID_1, clerkAId, AMOUNT_100_50, now)); // duplicate event for same order should be ignored pkContributionListener.onContribution( - PkContributionEvent.orderContribution("order-1", clerkAId, new BigDecimal("100.50"), now)); + PkContributionEvent.orderContribution(ORDER_ID_1, clerkAId, AMOUNT_100_50, now)); pkContributionListener.onContribution( - PkContributionEvent.orderContribution("order-2", clerkBId, new BigDecimal("50.25"), now)); + PkContributionEvent.orderContribution(ORDER_ID_2, clerkBId, AMOUNT_50_25, now)); pkContributionListener.onContribution( - PkContributionEvent.orderContribution("order-3", clerkAId, new BigDecimal("20.00"), now)); + PkContributionEvent.orderContribution(ORDER_ID_3, clerkAId, AMOUNT_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); + assertThat(scoreboard.getClerkAScore()).isEqualByComparingTo(TOTAL_SCORE_A); + assertThat(scoreboard.getClerkBScore()).isEqualByComparingTo(AMOUNT_50_25); + assertThat(scoreboard.getClerkAOrderCount()).isEqualTo(ORDER_COUNT_TWO); + assertThat(scoreboard.getClerkBOrderCount()).isEqualTo(ORDER_COUNT_ONE); } @Test @@ -96,8 +191,8 @@ class PkIntegrationTest extends AbstractApiTest { void finishPkShouldPersistScoresAndWinner() { LocalDateTime now = LocalDateTime.now(); String pkId = IdUtils.getUuid(); - String clerkAId = "pk-clerk-a2"; - String clerkBId = "pk-clerk-b2"; + String clerkAId = newClerkId(); + String clerkBId = newClerkId(); SecurityUtils.setTenantId(DEFAULT_TENANT); PlayClerkPkEntity pk = buildActivePk(pkId, clerkAId, clerkBId, now); @@ -107,11 +202,11 @@ class PkIntegrationTest extends AbstractApiTest { stringRedisTemplate.delete(scoreKey); pkContributionListener.onContribution( - PkContributionEvent.orderContribution("order-a1", clerkAId, new BigDecimal("80.00"), now)); + PkContributionEvent.orderContribution(ORDER_ID_A1, clerkAId, AMOUNT_80_00, now)); pkContributionListener.onContribution( - PkContributionEvent.orderContribution("order-b1", clerkBId, new BigDecimal("30.00"), now)); + PkContributionEvent.orderContribution(ORDER_ID_B1, clerkBId, AMOUNT_30_00, now)); pkContributionListener.onContribution( - PkContributionEvent.orderContribution("order-a2", clerkAId, new BigDecimal("40.00"), now)); + PkContributionEvent.orderContribution(ORDER_ID_A2, clerkAId, AMOUNT_40_00, now)); PkScoreBoardDto beforeFinish = pkScoreboardService.getScoreboard(pkId); @@ -119,7 +214,7 @@ class PkIntegrationTest extends AbstractApiTest { PlayClerkPkEntity persisted = clerkPkService.selectPlayClerkPkById(pkId); assertThat(persisted.getStatus()).isEqualTo(ClerkPkEnum.FINISHED.name()); - assertThat(persisted.getSettled()).isEqualTo(1); + assertThat(persisted.getSettled()).isEqualTo(SETTLED_TRUE); assertThat(persisted.getClerkAScore()).isEqualByComparingTo(beforeFinish.getClerkAScore()); assertThat(persisted.getClerkBScore()).isEqualByComparingTo(beforeFinish.getClerkBScore()); assertThat(persisted.getClerkAOrderCount()).isEqualTo(beforeFinish.getClerkAOrderCount()); @@ -135,17 +230,826 @@ class PkIntegrationTest extends AbstractApiTest { assertThat(persisted.getWinnerClerkId()).isEqualTo(expectedWinner); } + @Test + @DisplayName("startPk 在开始时间未到时应失败") + void startPkShouldRejectFutureStartTime() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + PlayClerkPkEntity pk = buildPk(pkId, newClerkId(), newClerkId(), + now.plusMinutes(MINUTES_FUTURE_START), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name(), + SETTLED_FALSE); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(pk); + + assertThatThrownBy(() -> clerkPkLifecycleService.startPk(pkId)) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("startPk 在状态非待开始时应保持不变") + void startPkShouldSkipWhenNotPending() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + PlayClerkPkEntity pk = buildPk(pkId, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.IN_PROGRESS.name(), + SETTLED_FALSE); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(pk); + + clerkPkLifecycleService.startPk(pkId); + PlayClerkPkEntity persisted = clerkPkService.selectPlayClerkPkById(pkId); + assertThat(persisted.getStatus()).isEqualTo(ClerkPkEnum.IN_PROGRESS.name()); + } + + @Test + @DisplayName("startPk 在允许条件下应变更为进行中") + void startPkShouldAdvanceStatus() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + PlayClerkPkEntity pk = buildPk(pkId, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name(), + SETTLED_FALSE); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(pk); + + clerkPkLifecycleService.startPk(pkId); + PlayClerkPkEntity persisted = clerkPkService.selectPlayClerkPkById(pkId); + assertThat(persisted.getStatus()).isEqualTo(ClerkPkEnum.IN_PROGRESS.name()); + } + + @Test + @DisplayName("finishPk 在已完成状态下应幂等") + void finishPkShouldBeIdempotent() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + PlayClerkPkEntity pk = buildPk(pkId, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.minusMinutes(PAST_MINUTES), + ClerkPkEnum.FINISHED.name(), + SETTLED_TRUE); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(pk); + + clerkPkLifecycleService.finishPk(pkId); + PlayClerkPkEntity persisted = clerkPkService.selectPlayClerkPkById(pkId); + assertThat(persisted.getStatus()).isEqualTo(ClerkPkEnum.FINISHED.name()); + } + + @Test + @DisplayName("比分相等时 winnerClerkId 应为空") + void finishPkShouldClearWinnerOnTie() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + String clerkAId = newClerkId(); + String clerkBId = newClerkId(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + PlayClerkPkEntity pk = buildActivePk(pkId, clerkAId, clerkBId, now); + clerkPkService.save(pk); + + String scoreKey = PkRedisKeyConstants.scoreKey(pkId); + stringRedisTemplate.opsForHash() + .put(scoreKey, PkRedisKeyConstants.FIELD_CLERK_A_SCORE, AMOUNT_10_00.toString()); + stringRedisTemplate.opsForHash() + .put(scoreKey, PkRedisKeyConstants.FIELD_CLERK_B_SCORE, AMOUNT_10_00.toString()); + + clerkPkLifecycleService.finishPk(pkId); + PlayClerkPkEntity persisted = clerkPkService.selectPlayClerkPkById(pkId); + assertThat(persisted.getWinnerClerkId()).isNull(); + } + + @Test + @DisplayName("scanAndUpdate 应自动开始并结束PK") + void scanAndUpdateShouldAdvanceAndFinish() { + LocalDateTime now = LocalDateTime.now(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + String startPkId = IdUtils.getUuid(); + PlayClerkPkEntity toStart = buildPk(startPkId, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name(), + SETTLED_FALSE); + clerkPkService.save(toStart); + + String finishPkId = IdUtils.getUuid(); + PlayClerkPkEntity toFinish = buildPk(finishPkId, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.minusMinutes(PAST_MINUTES), + ClerkPkEnum.IN_PROGRESS.name(), + SETTLED_FALSE); + clerkPkService.save(toFinish); + + clerkPkLifecycleService.scanAndUpdate(); + + PlayClerkPkEntity started = clerkPkService.selectPlayClerkPkById(startPkId); + PlayClerkPkEntity finished = clerkPkService.selectPlayClerkPkById(finishPkId); + assertThat(started.getStatus()).isEqualTo(ClerkPkEnum.IN_PROGRESS.name()); + assertThat(finished.getStatus()).isEqualTo(ClerkPkEnum.FINISHED.name()); + assertThat(finished.getSettled()).isEqualTo(SETTLED_TRUE); + } + + @Test + @DisplayName("scanAndUpdate 遇到异常不影响其他PK") + void scanAndUpdateShouldContinueOnFailure() { + LocalDateTime now = LocalDateTime.now(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + String futurePkId = IdUtils.getUuid(); + PlayClerkPkEntity futurePk = buildPk(futurePkId, newClerkId(), newClerkId(), + now.plusMinutes(MINUTES_FUTURE_START), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name(), + SETTLED_FALSE); + clerkPkService.save(futurePk); + + String eligiblePkId = IdUtils.getUuid(); + PlayClerkPkEntity eligiblePk = buildPk(eligiblePkId, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name(), + SETTLED_FALSE); + clerkPkService.save(eligiblePk); + + clerkPkLifecycleService.scanAndUpdate(); + PlayClerkPkEntity persisted = clerkPkService.selectPlayClerkPkById(eligiblePkId); + assertThat(persisted.getStatus()).isEqualTo(ClerkPkEnum.IN_PROGRESS.name()); + } + + @Test + @DisplayName("getScoreboard 在PK不存在时应抛出异常") + void getScoreboardShouldFailWhenMissing() { + assertThatThrownBy(() -> pkScoreboardService.getScoreboard(IdUtils.getUuid())) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("getScoreboard 在Redis无数据时应返回0") + void getScoreboardShouldReturnZeroWhenEmpty() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + String clerkAId = newClerkId(); + String clerkBId = newClerkId(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + PlayClerkPkEntity pk = buildActivePk(pkId, clerkAId, clerkBId, now); + clerkPkService.save(pk); + + PkScoreBoardDto scoreboard = pkScoreboardService.getScoreboard(pkId); + assertThat(scoreboard.getClerkAScore()).isEqualByComparingTo(AMOUNT_0_00); + assertThat(scoreboard.getClerkBScore()).isEqualByComparingTo(AMOUNT_0_00); + assertThat(scoreboard.getClerkAOrderCount()).isEqualTo(ORDER_COUNT_ZERO); + assertThat(scoreboard.getClerkBOrderCount()).isEqualTo(ORDER_COUNT_ZERO); + } + + @Test + @DisplayName("getScoreboard 在结束时间已过时剩余秒数为0") + void getScoreboardShouldReturnZeroRemainingSeconds() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + String clerkAId = newClerkId(); + String clerkBId = newClerkId(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + PlayClerkPkEntity pk = buildPk(pkId, clerkAId, clerkBId, + now.minusMinutes(MINUTES_BEFORE_START), + now.minusMinutes(PAST_MINUTES), + ClerkPkEnum.IN_PROGRESS.name(), + SETTLED_FALSE); + clerkPkService.save(pk); + + PkScoreBoardDto scoreboard = pkScoreboardService.getScoreboard(pkId); + assertThat(scoreboard.getRemainingSeconds()).isEqualTo(REMAINING_SECONDS_ZERO); + } + + @Test + @DisplayName("礼物与充值贡献应累加到比分") + void giftAndRechargeShouldAccumulate() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + String clerkAId = newClerkId(); + String clerkBId = newClerkId(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + PlayClerkPkEntity pk = buildActivePk(pkId, clerkAId, clerkBId, now); + clerkPkService.save(pk); + + pkContributionListener.onContribution( + PkContributionEvent.giftContribution(GIFT_ID_1, clerkAId, AMOUNT_10_00, now)); + pkContributionListener.onContribution( + PkContributionEvent.rechargeContribution(RECHARGE_ID_1, clerkAId, AMOUNT_15_00, now)); + + PkScoreBoardDto scoreboard = pkScoreboardService.getScoreboard(pkId); + assertThat(scoreboard.getClerkAScore()).isEqualByComparingTo(TOTAL_SCORE_GIFT_RECHARGE); + } + + @Test + @DisplayName("礼物与充值重复事件应去重") + void giftAndRechargeShouldDeduplicate() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + String clerkAId = newClerkId(); + String clerkBId = newClerkId(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + PlayClerkPkEntity pk = buildActivePk(pkId, clerkAId, clerkBId, now); + clerkPkService.save(pk); + + pkContributionListener.onContribution( + PkContributionEvent.giftContribution(GIFT_ID_DUP, clerkAId, AMOUNT_12_00, now)); + pkContributionListener.onContribution( + PkContributionEvent.giftContribution(GIFT_ID_DUP, clerkAId, AMOUNT_12_00, now)); + pkContributionListener.onContribution( + PkContributionEvent.rechargeContribution(RECHARGE_ID_DUP, clerkAId, AMOUNT_5_00, now)); + pkContributionListener.onContribution( + PkContributionEvent.rechargeContribution(RECHARGE_ID_DUP, clerkAId, AMOUNT_5_00, now)); + + PkScoreBoardDto scoreboard = pkScoreboardService.getScoreboard(pkId); + assertThat(scoreboard.getClerkAScore()).isEqualByComparingTo(TOTAL_SCORE_DUP_DEDUP); + assertThat(scoreboard.getClerkAOrderCount()).isEqualTo(ORDER_COUNT_TWO); + } + + @Test + @DisplayName("非PK时段贡献应被忽略") + void contributionShouldBeIgnoredWhenNoActivePk() { + LocalDateTime now = LocalDateTime.now(); + String clerkId = newClerkId(); + pkContributionListener.onContribution( + PkContributionEvent.orderContribution(ORDER_ID_1, clerkId, AMOUNT_10_00, now)); + assertThat(stringRedisTemplate.keys(SCORE_KEY_PATTERN)).isEmpty(); + } + + @Test + @DisplayName("scanAndUpdate 应批量开始PK") + void scanAndUpdateShouldStartMultiple() { + LocalDateTime now = LocalDateTime.now(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + String pkIdA = IdUtils.getUuid(); + String pkIdB = IdUtils.getUuid(); + PlayClerkPkEntity pkA = buildPk(pkIdA, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name(), + SETTLED_FALSE); + PlayClerkPkEntity pkB = buildPk(pkIdB, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name(), + SETTLED_FALSE); + clerkPkService.save(pkA); + clerkPkService.save(pkB); + + clerkPkLifecycleService.scanAndUpdate(); + + assertThat(clerkPkService.selectPlayClerkPkById(pkIdA).getStatus()) + .isEqualTo(ClerkPkEnum.IN_PROGRESS.name()); + assertThat(clerkPkService.selectPlayClerkPkById(pkIdB).getStatus()) + .isEqualTo(ClerkPkEnum.IN_PROGRESS.name()); + } + + @Test + @DisplayName("scanAndUpdate 应批量结束PK") + void scanAndUpdateShouldFinishMultiple() { + LocalDateTime now = LocalDateTime.now(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + String pkIdA = IdUtils.getUuid(); + String pkIdB = IdUtils.getUuid(); + PlayClerkPkEntity pkA = buildPk(pkIdA, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.minusMinutes(PAST_MINUTES), + ClerkPkEnum.IN_PROGRESS.name(), + SETTLED_FALSE); + PlayClerkPkEntity pkB = buildPk(pkIdB, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.minusMinutes(PAST_MINUTES), + ClerkPkEnum.IN_PROGRESS.name(), + SETTLED_FALSE); + clerkPkService.save(pkA); + clerkPkService.save(pkB); + + clerkPkLifecycleService.scanAndUpdate(); + + assertThat(clerkPkService.selectPlayClerkPkById(pkIdA).getStatus()) + .isEqualTo(ClerkPkEnum.FINISHED.name()); + assertThat(clerkPkService.selectPlayClerkPkById(pkIdB).getStatus()) + .isEqualTo(ClerkPkEnum.FINISHED.name()); + } + + @Test + @DisplayName("scanAndUpdate 结束异常不影响其他PK") + void scanAndUpdateShouldContinueWhenFinishFails() { + LocalDateTime now = LocalDateTime.now(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + String failingPkId = IdUtils.getUuid(); + String okPkId = IdUtils.getUuid(); + PlayClerkPkEntity failingPk = buildPk(failingPkId, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.minusMinutes(PAST_MINUTES), + ClerkPkEnum.IN_PROGRESS.name(), + SETTLED_FALSE); + PlayClerkPkEntity okPk = buildPk(okPkId, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.minusMinutes(PAST_MINUTES), + ClerkPkEnum.IN_PROGRESS.name(), + SETTLED_FALSE); + clerkPkService.save(failingPk); + clerkPkService.save(okPk); + + Mockito.doThrow(new RuntimeException("finish failed")) + .when(clerkPkLifecycleServiceSpy) + .finishPk(failingPkId); + try { + clerkPkLifecycleServiceSpy.scanAndUpdate(); + } finally { + Mockito.reset(clerkPkLifecycleServiceSpy); + } + + PlayClerkPkEntity persisted = clerkPkService.selectPlayClerkPkById(okPkId); + assertThat(persisted.getStatus()).isEqualTo(ClerkPkEnum.FINISHED.name()); + } + + @Test + @DisplayName("Upcoming 提醒应写入未来一小时PK并清理过期") + void upcomingReminderShouldWriteAndClean() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + PlayClerkPkEntity upcoming = buildPk(pkId, newClerkId(), newClerkId(), + now.plusMinutes(UPCOMING_MINUTES), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name(), + SETTLED_FALSE); + upcoming.setTenantId(UPCOMING_TENANT); + + PlayClerkPkEntity noTenant = buildPk(IdUtils.getUuid(), newClerkId(), newClerkId(), + now.plusMinutes(UPCOMING_MINUTES), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name(), + SETTLED_FALSE); + noTenant.setTenantId(EMPTY_TENANT); + + SecurityUtils.setTenantId(UPCOMING_TENANT); + clerkPkService.save(upcoming); + clerkPkService.save(noTenant); + + String key = PkRedisKeyConstants.upcomingKey(UPCOMING_TENANT); + stringRedisTemplate.opsForZSet().add(key, LEGACY_UPCOMING_ID, ZSET_SCORE_PAST); + upcomingReminderJob.refreshUpcomingPkReminders(); + + Set members = stringRedisTemplate.opsForZSet().range(key, 0, -1); + assertThat(members).contains(pkId); + assertThat(members).doesNotContain(LEGACY_UPCOMING_ID); + } + + @Test + @DisplayName("Upcoming 提醒无数据时不创建Key") + void upcomingReminderShouldSkipWhenEmpty() { + String key = PkRedisKeyConstants.upcomingKey(EMPTY_UPCOMING_TENANT); + upcomingReminderJob.refreshUpcomingPkReminders(); + Boolean exists = stringRedisTemplate.hasKey(key); + assertThat(exists).isNotNull(); + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("Upcoming 提醒应设置过期时间") + void upcomingReminderShouldSetTtl() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + PlayClerkPkEntity upcoming = buildPk(pkId, newClerkId(), newClerkId(), + now.plusMinutes(UPCOMING_MINUTES), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name(), + SETTLED_FALSE); + upcoming.setTenantId(UPCOMING_TENANT); + SecurityUtils.setTenantId(UPCOMING_TENANT); + clerkPkService.save(upcoming); + + String key = PkRedisKeyConstants.upcomingKey(UPCOMING_TENANT); + upcomingReminderJob.refreshUpcomingPkReminders(); + + Long ttl = stringRedisTemplate.getExpire(key); + assertThat(ttl).isNotNull(); + assertThat(ttl).isGreaterThanOrEqualTo(MIN_TTL_SECONDS); + assertThat(ttl).isLessThanOrEqualTo(PkRedisKeyConstants.UPCOMING_REMINDER_TTL_SECONDS); + } + + @Test + @DisplayName("Upcoming 提醒应按租户隔离") + void upcomingReminderShouldIsolateTenants() { + LocalDateTime now = LocalDateTime.now(); + String tenantA = UPCOMING_TENANT; + String tenantB = OTHER_TENANT; + String pkAId = IdUtils.getUuid(); + String pkBId = IdUtils.getUuid(); + + PlayClerkPkEntity pkA = buildPk(pkAId, newClerkId(), newClerkId(), + now.plusMinutes(UPCOMING_MINUTES), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name(), + SETTLED_FALSE); + pkA.setTenantId(tenantA); + SecurityUtils.setTenantId(tenantA); + clerkPkService.save(pkA); + + PlayClerkPkEntity pkB = buildPk(pkBId, newClerkId(), newClerkId(), + now.plusMinutes(UPCOMING_MINUTES), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name(), + SETTLED_FALSE); + pkB.setTenantId(tenantB); + SecurityUtils.setTenantId(tenantB); + clerkPkService.save(pkB); + + upcomingReminderJob.refreshUpcomingPkReminders(); + + Set tenantAKeys = stringRedisTemplate.opsForZSet() + .range(PkRedisKeyConstants.upcomingKey(tenantA), 0, -1); + Set tenantBKeys = stringRedisTemplate.opsForZSet() + .range(PkRedisKeyConstants.upcomingKey(tenantB), 0, -1); + assertThat(tenantAKeys).contains(pkAId); + assertThat(tenantAKeys).doesNotContain(pkBId); + assertThat(tenantBKeys).contains(pkBId); + assertThat(tenantBKeys).doesNotContain(pkAId); + } + + @Test + @DisplayName("Start Scheduler 应启动到期的PK并移除开始队列") + void startSchedulerShouldStartDuePk() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + PlayClerkPkEntity pk = buildPk(pkId, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name(), + SETTLED_FALSE); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(pk); + + String scheduleKey = PkRedisKeyConstants.startScheduleKey(DEFAULT_TENANT); + long beginEpochSeconds = pk.getPkBeginTime().toInstant().getEpochSecond(); + stringRedisTemplate.opsForZSet().add(scheduleKey, pkId, beginEpochSeconds); + + startSchedulerJob.scanStartSchedule(); + + PlayClerkPkEntity persisted = clerkPkService.selectPlayClerkPkById(pkId); + assertThat(persisted.getStatus()).isEqualTo(ClerkPkEnum.IN_PROGRESS.name()); + assertThat(stringRedisTemplate.opsForZSet().score(scheduleKey, pkId)).isNull(); + + String finishKey = PkRedisKeyConstants.finishScheduleKey(DEFAULT_TENANT); + assertThat(stringRedisTemplate.opsForZSet().score(finishKey, pkId)).isNotNull(); + } + + @Test + @DisplayName("设置生成的PK实例应写入开始队列并能被启动") + void settingShouldGeneratePkAndStart() { + SecurityUtils.setTenantId(DEFAULT_TENANT); + String clerkAId = newClerkId(); + String clerkBId = newClerkId(); + ZoneId zoneId = ZoneId.of(TIMEZONE_SHANGHAI); + LocalDate startDate = LocalDate.now(zoneId).minusDays(DAYS_PAST); + LocalTime startTime = LocalDateTime.now(zoneId).toLocalTime(); + + PlayClerkPkSettingUpsertRequest request = new PlayClerkPkSettingUpsertRequest(); + request.setName(SETTING_NAME_PREFIX + IdUtils.getUuid()); + request.setRecurrenceType(PkRecurrenceType.ONCE); + request.setStartTimeOfDay(startTime); + request.setDurationMinutes(SETTING_DURATION_MINUTES); + request.setEffectiveStartDate(startDate); + request.setEffectiveEndDate(startDate); + request.setTimezone(TIMEZONE_SHANGHAI); + request.setClerkAId(clerkAId); + request.setClerkBId(clerkBId); + request.setStatus(PkSettingStatus.ENABLED); + + String settingId = clerkPkSettingService.createSetting(request); + + List pkList = clerkPkService.list( + Wrappers.lambdaQuery() + .eq(PlayClerkPkEntity::getSettingId, settingId)); + assertThat(pkList).hasSize(EXPECTED_ONE); + PlayClerkPkEntity pk = pkList.get(0); + + String scheduleKey = PkRedisKeyConstants.startScheduleKey(DEFAULT_TENANT); + assertThat(stringRedisTemplate.opsForZSet().score(scheduleKey, pk.getId())).isNotNull(); + + startSchedulerJob.scanStartSchedule(); + + PlayClerkPkEntity persisted = clerkPkService.selectPlayClerkPkById(pk.getId()); + assertThat(persisted.getStatus()).isEqualTo(ClerkPkEnum.IN_PROGRESS.name()); + } + + @Test + @DisplayName("设置生成的未来PK不应被提前启动") + void settingShouldGenerateFuturePkAndSkipStart() { + SecurityUtils.setTenantId(DEFAULT_TENANT); + String clerkAId = newClerkId(); + String clerkBId = newClerkId(); + ZoneId zoneId = ZoneId.of(TIMEZONE_SHANGHAI); + LocalDate startDate = LocalDate.now(zoneId).plusDays(DAYS_FUTURE); + LocalTime startTime = LocalDateTime.now(zoneId).toLocalTime(); + + PlayClerkPkSettingUpsertRequest request = new PlayClerkPkSettingUpsertRequest(); + request.setName(SETTING_NAME_PREFIX + IdUtils.getUuid()); + request.setRecurrenceType(PkRecurrenceType.ONCE); + request.setStartTimeOfDay(startTime); + request.setDurationMinutes(SETTING_DURATION_MINUTES); + request.setEffectiveStartDate(startDate); + request.setEffectiveEndDate(startDate); + request.setTimezone(TIMEZONE_SHANGHAI); + request.setClerkAId(clerkAId); + request.setClerkBId(clerkBId); + request.setStatus(PkSettingStatus.ENABLED); + + String settingId = clerkPkSettingService.createSetting(request); + + List pkList = clerkPkService.list( + Wrappers.lambdaQuery() + .eq(PlayClerkPkEntity::getSettingId, settingId)); + assertThat(pkList).hasSize(EXPECTED_ONE); + PlayClerkPkEntity pk = pkList.get(0); + + String scheduleKey = PkRedisKeyConstants.startScheduleKey(DEFAULT_TENANT); + assertThat(stringRedisTemplate.opsForZSet().score(scheduleKey, pk.getId())).isNotNull(); + + startSchedulerJob.scanStartSchedule(); + + PlayClerkPkEntity persisted = clerkPkService.selectPlayClerkPkById(pk.getId()); + assertThat(persisted.getStatus()).isEqualTo(ClerkPkEnum.TO_BE_STARTED.name()); + } + + @Test + @DisplayName("Start Scheduler 应恢复租户上下文") + void startSchedulerShouldRestoreTenantContext() { + SecurityUtils.setTenantId(TENANT_ORIGIN); + SysTenantEntity tenantA = buildTenant(TENANT_A); + try { + Mockito.doReturn(Arrays.asList(tenantA)) + .when(sysTenantServiceSpy).listAll(); + startSchedulerJob.scanStartSchedule(); + } finally { + Mockito.reset(sysTenantServiceSpy); + } + + assertThat(SecurityUtils.getTenantId()).isEqualTo(TENANT_ORIGIN); + } + + @Test + @DisplayName("Start Scheduler 不应启动未到时间的PK") + void startSchedulerShouldSkipFuturePk() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + PlayClerkPkEntity pk = buildPk(pkId, newClerkId(), newClerkId(), + now.plusMinutes(MINUTES_FUTURE_START), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name(), + SETTLED_FALSE); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(pk); + + String scheduleKey = PkRedisKeyConstants.startScheduleKey(DEFAULT_TENANT); + long beginEpochSeconds = pk.getPkBeginTime().toInstant().getEpochSecond(); + stringRedisTemplate.opsForZSet().add(scheduleKey, pkId, beginEpochSeconds); + + startSchedulerJob.scanStartSchedule(); + + PlayClerkPkEntity persisted = clerkPkService.selectPlayClerkPkById(pkId); + assertThat(persisted.getStatus()).isEqualTo(ClerkPkEnum.TO_BE_STARTED.name()); + assertThat(stringRedisTemplate.opsForZSet().score(scheduleKey, pkId)).isNotNull(); + } + + @Test + @DisplayName("Start Scheduler 遇到空租户仍应继续处理后续租户") + void startSchedulerShouldContinueWhenTenantMissing() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + PlayClerkPkEntity pk = buildPk(pkId, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name(), + SETTLED_FALSE); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(pk); + + String scheduleKey = PkRedisKeyConstants.startScheduleKey(DEFAULT_TENANT); + long beginEpochSeconds = pk.getPkBeginTime().toInstant().getEpochSecond(); + stringRedisTemplate.opsForZSet().add(scheduleKey, pkId, beginEpochSeconds); + + SysTenantEntity emptyTenant = buildTenant(EMPTY_TENANT); + SysTenantEntity validTenant = buildTenant(DEFAULT_TENANT); + try { + Mockito.doReturn(Arrays.asList(emptyTenant, validTenant)) + .when(sysTenantServiceSpy).listAll(); + startSchedulerJob.scanStartSchedule(); + } finally { + Mockito.reset(sysTenantServiceSpy); + } + + PlayClerkPkEntity persisted = clerkPkService.selectPlayClerkPkById(pkId); + assertThat(persisted.getStatus()).isEqualTo(ClerkPkEnum.IN_PROGRESS.name()); + } + + @Test + @DisplayName("Finish Scheduler 应结算到期PK并移除结算队列") + void finishSchedulerShouldFinishDuePk() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + PlayClerkPkEntity pk = buildPk(pkId, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.minusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.IN_PROGRESS.name(), + SETTLED_FALSE); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(pk); + + String finishKey = PkRedisKeyConstants.finishScheduleKey(DEFAULT_TENANT); + long endEpochSeconds = pk.getPkEndTime().toInstant().getEpochSecond(); + stringRedisTemplate.opsForZSet().add(finishKey, pkId, endEpochSeconds); + + finishSchedulerJob.scanFinishSchedule(); + + PlayClerkPkEntity persisted = clerkPkService.selectPlayClerkPkById(pkId); + assertThat(persisted.getStatus()).isEqualTo(ClerkPkEnum.FINISHED.name()); + assertThat(persisted.getSettled()).isEqualTo(SETTLED_TRUE); + assertThat(stringRedisTemplate.opsForZSet().score(finishKey, pkId)).isNull(); + } + + @Test + @DisplayName("Finish Scheduler 应恢复租户上下文") + void finishSchedulerShouldRestoreTenantContext() { + SecurityUtils.setTenantId(TENANT_ORIGIN); + SysTenantEntity tenantA = buildTenant(TENANT_A); + try { + Mockito.doReturn(Arrays.asList(tenantA)) + .when(sysTenantServiceSpy).listAll(); + finishSchedulerJob.scanFinishSchedule(); + } finally { + Mockito.reset(sysTenantServiceSpy); + } + + assertThat(SecurityUtils.getTenantId()).isEqualTo(TENANT_ORIGIN); + } + + @Test + @DisplayName("scheduledScan 应恢复租户上下文") + void scheduledScanShouldRestoreTenantContext() { + SecurityUtils.setTenantId(TENANT_ORIGIN); + SysTenantEntity tenantA = buildTenant(TENANT_A); + SysTenantEntity tenantB = buildTenant(TENANT_B); + try { + Mockito.doReturn(Arrays.asList(tenantA, tenantB)) + .when(sysTenantServiceSpy).listAll(); + clerkPkLifecycleServiceSpy.scheduledScan(); + } finally { + Mockito.reset(sysTenantServiceSpy); + } + + assertThat(SecurityUtils.getTenantId()).isEqualTo(TENANT_ORIGIN); + } + + @Test + @DisplayName("Finish Scheduler 遇到空租户仍应继续处理后续租户") + void finishSchedulerShouldContinueWhenTenantMissing() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + PlayClerkPkEntity pk = buildPk(pkId, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.minusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.IN_PROGRESS.name(), + SETTLED_FALSE); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(pk); + + String finishKey = PkRedisKeyConstants.finishScheduleKey(DEFAULT_TENANT); + long endEpochSeconds = pk.getPkEndTime().toInstant().getEpochSecond(); + stringRedisTemplate.opsForZSet().add(finishKey, pkId, endEpochSeconds); + + SysTenantEntity emptyTenant = buildTenant(EMPTY_TENANT); + SysTenantEntity validTenant = buildTenant(DEFAULT_TENANT); + try { + Mockito.doReturn(Arrays.asList(emptyTenant, validTenant)) + .when(sysTenantServiceSpy).listAll(); + finishSchedulerJob.scanFinishSchedule(); + } finally { + Mockito.reset(sysTenantServiceSpy); + } + + PlayClerkPkEntity persisted = clerkPkService.selectPlayClerkPkById(pkId); + assertThat(persisted.getStatus()).isEqualTo(ClerkPkEnum.FINISHED.name()); + assertThat(persisted.getSettled()).isEqualTo(SETTLED_TRUE); + } + + @Test + @DisplayName("Finish Scheduler 失败时应记录重试次数并保留队列") + void finishSchedulerShouldRetryWhenFailure() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + PlayClerkPkEntity pk = buildPk(pkId, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.minusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.IN_PROGRESS.name(), + SETTLED_FALSE); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(pk); + + String finishKey = PkRedisKeyConstants.finishScheduleKey(DEFAULT_TENANT); + long endEpochSeconds = pk.getPkEndTime().toInstant().getEpochSecond(); + stringRedisTemplate.opsForZSet().add(finishKey, pkId, endEpochSeconds); + + Mockito.doThrow(new RuntimeException("finish failed")) + .when(clerkPkLifecycleServiceSpy) + .finishPk(pkId); + try { + SysTenantEntity validTenant = buildTenant(DEFAULT_TENANT); + Mockito.doReturn(Arrays.asList(validTenant)) + .when(sysTenantServiceSpy).listAll(); + finishSchedulerJob.scanFinishSchedule(); + } finally { + Mockito.reset(sysTenantServiceSpy); + Mockito.reset(clerkPkLifecycleServiceSpy); + } + + String retryKey = PkRedisKeyConstants.finishRetryKey(DEFAULT_TENANT); + Object attempts = stringRedisTemplate.opsForHash().get(retryKey, pkId); + assertThat(attempts).isNotNull(); + assertThat(Integer.parseInt(attempts.toString())) + .isEqualTo(ATTEMPT_STEP); + assertThat(stringRedisTemplate.opsForZSet().score(finishKey, pkId)).isNotNull(); + + String failedKey = PkRedisKeyConstants.finishFailedKey(DEFAULT_TENANT); + assertThat(stringRedisTemplate.opsForZSet().score(failedKey, pkId)).isNull(); + } + + @Test + @DisplayName("Finish Scheduler 超过重试上限应进入失败集合") + void finishSchedulerShouldMoveToFailedAfterMaxRetries() { + LocalDateTime now = LocalDateTime.now(); + String pkId = IdUtils.getUuid(); + PlayClerkPkEntity pk = buildPk(pkId, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.minusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.IN_PROGRESS.name(), + SETTLED_FALSE); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(pk); + + String finishKey = PkRedisKeyConstants.finishScheduleKey(DEFAULT_TENANT); + Mockito.doThrow(new RuntimeException("finish failed")) + .when(clerkPkLifecycleServiceSpy) + .finishPk(pkId); + try { + for (int attempt = ATTEMPT_INDEX_START; + attempt < PkSchedulerConstants.FINISH_RETRY_MAX_ATTEMPTS; + attempt += ATTEMPT_STEP) { + long score = Instant.now().getEpochSecond(); + stringRedisTemplate.opsForZSet().add(finishKey, pkId, score); + finishSchedulerJob.scanFinishSchedule(); + } + } finally { + Mockito.reset(clerkPkLifecycleServiceSpy); + } + + String failedKey = PkRedisKeyConstants.finishFailedKey(DEFAULT_TENANT); + assertThat(stringRedisTemplate.opsForZSet().score(failedKey, pkId)).isNotNull(); + assertThat(stringRedisTemplate.opsForZSet().score(finishKey, pkId)).isNull(); + + String retryKey = PkRedisKeyConstants.finishRetryKey(DEFAULT_TENANT); + Boolean retryExists = stringRedisTemplate.opsForHash().hasKey(retryKey, pkId); + assertThat(retryExists).isNotNull(); + assertThat(retryExists).isFalse(); + } + private static PlayClerkPkEntity buildActivePk(String pkId, String clerkAId, String clerkBId, LocalDateTime now) { PlayClerkPkEntity pk = new PlayClerkPkEntity(); pk.setId(pkId); - pk.setTenantId("tenant-apitest"); + pk.setTenantId(DEFAULT_TENANT); 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.setPkBeginTime(Date.from(now.minusMinutes(MINUTES_BEFORE_START).atZone(ZoneId.systemDefault()).toInstant())); + pk.setPkEndTime(Date.from(now.plusMinutes(MINUTES_AFTER_START).atZone(ZoneId.systemDefault()).toInstant())); pk.setStatus(ClerkPkEnum.IN_PROGRESS.name()); - pk.setSettled(0); + pk.setSettled(SETTLED_FALSE); return pk; } + + private static PlayClerkPkEntity buildPk(String pkId, String clerkAId, String clerkBId, + LocalDateTime begin, LocalDateTime end, String status, int settled) { + PlayClerkPkEntity pk = new PlayClerkPkEntity(); + pk.setId(pkId); + pk.setTenantId(DEFAULT_TENANT); + pk.setClerkA(clerkAId); + pk.setClerkB(clerkBId); + pk.setPkBeginTime(Date.from(begin.atZone(ZoneId.systemDefault()).toInstant())); + pk.setPkEndTime(Date.from(end.atZone(ZoneId.systemDefault()).toInstant())); + pk.setStatus(status); + pk.setSettled(settled); + return pk; + } + + private static SysTenantEntity buildTenant(String tenantId) { + return new SysTenantEntity().setTenantId(tenantId); + } + + private static String newClerkId() { + return CLERK_PREFIX + IdUtils.getUuid(); + } } diff --git a/play-admin/src/test/java/com/starry/admin/modules/pk/PlayClerkPkApiTest.java b/play-admin/src/test/java/com/starry/admin/modules/pk/PlayClerkPkApiTest.java new file mode 100644 index 0000000..49fb52f --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/modules/pk/PlayClerkPkApiTest.java @@ -0,0 +1,529 @@ +package com.starry.admin.modules.pk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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.enums.PkLifecycleErrorCode; +import com.starry.admin.modules.pk.redis.PkRedisKeyConstants; +import com.starry.admin.utils.SecurityUtils; +import com.starry.common.result.ResultCodeEnum; +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.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; + +class PlayClerkPkApiTest extends AbstractApiTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String MSG_PK_NOT_FOUND = "PK不存在"; + private static final String MSG_PK_START_NOT_REACHED = "PK开始时间尚未到达"; + private static final String CLERK_PREFIX = "pk-api-clerk-"; + private static final int MINUTES_BEFORE_START = 5; + private static final int MINUTES_AFTER_START = 30; + private static final int MINUTES_FUTURE_START = 15; + private static final int MINUTES_PAST_END = 10; + private static final int FORCE_START_DURATION_MINUTES = 30; + private static final int SETTLED_TRUE = 1; + private static final int SETTLED_FALSE = 0; + private static final BigDecimal TIE_SCORE = new BigDecimal("10.00"); + private static final BigDecimal SCORE_A = new BigDecimal("12.50"); + private static final BigDecimal SCORE_B = new BigDecimal("7.25"); + private static final BigDecimal ZERO_SCORE = new BigDecimal("0.00"); + private static final int ORDER_COUNT_A = 3; + private static final int ORDER_COUNT_B = 1; + private static final long REMAINING_SECONDS_ZERO = 0L; + private static final int ZERO_COUNT = 0; + private static final int ZERO_VALUE = 0; + + @Resource + private IPlayClerkPkService clerkPkService; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + @Test + void forceStartShouldCreateInProgressPk() throws Exception { + String clerkAId = newClerkId(); + String clerkBId = newClerkId(); + String requestBody = buildForceStartBody(clerkAId, clerkBId, FORCE_START_DURATION_MINUTES); + + MvcResult result = mockMvc.perform(post(buildForceStartPath()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())) + .andReturn(); + + String pkId = extractDataAsText(result); + PlayClerkPkEntity persisted = loadPk(pkId); + assertThat(persisted.getStatus()).isEqualTo(ClerkPkEnum.IN_PROGRESS.name()); + assertThat(persisted.getClerkA()).isEqualTo(clerkAId); + assertThat(persisted.getClerkB()).isEqualTo(clerkBId); + assertThat(persisted.getPkBeginTime()).isNotNull(); + assertThat(persisted.getPkEndTime()).isNotNull(); + assertThat(persisted.getTenantId()).isEqualTo(DEFAULT_TENANT); + assertThat(persisted.getCreatedBy()).isNotNull(); + assertThat(persisted.getPkEndTime()).isAfter(persisted.getPkBeginTime()); + } + + @Test + void forceStartShouldRejectWhenConflict() throws Exception { + String clerkAId = newClerkId(); + String clerkBId = newClerkId(); + LocalDateTime now = LocalDateTime.now(); + createPkForClerks(clerkAId, clerkBId, now.minusMinutes(MINUTES_BEFORE_START), + now.plusMinutes(MINUTES_AFTER_START), ClerkPkEnum.IN_PROGRESS.name()); + + String requestBody = buildForceStartBody(clerkAId, clerkBId, FORCE_START_DURATION_MINUTES); + mockMvc.perform(post(buildForceStartPath()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkLifecycleErrorCode.CLERK_CONFLICT.getMessage())); + } + + @Test + void forceStartShouldRejectWhenMissingClerkA() throws Exception { + String requestBody = "{\"clerkAId\":\"\",\"clerkBId\":\"" + newClerkId() + + "\",\"durationMinutes\":" + FORCE_START_DURATION_MINUTES + "}"; + + mockMvc.perform(post(buildForceStartPath()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkLifecycleErrorCode.REQUEST_INVALID.getMessage())); + } + + @Test + void forceStartShouldRejectWhenMissingClerkB() throws Exception { + String requestBody = "{\"clerkAId\":\"" + newClerkId() + + "\",\"clerkBId\":\"\",\"durationMinutes\":" + FORCE_START_DURATION_MINUTES + "}"; + + mockMvc.perform(post(buildForceStartPath()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkLifecycleErrorCode.REQUEST_INVALID.getMessage())); + } + + @Test + void forceStartShouldRejectWhenMissingDuration() throws Exception { + String requestBody = "{\"clerkAId\":\"" + newClerkId() + + "\",\"clerkBId\":\"" + newClerkId() + "\"}"; + + mockMvc.perform(post(buildForceStartPath()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkLifecycleErrorCode.REQUEST_INVALID.getMessage())); + } + + @Test + void forceStartShouldRejectWhenSameClerk() throws Exception { + String clerkId = newClerkId(); + String requestBody = buildForceStartBody(clerkId, clerkId, FORCE_START_DURATION_MINUTES); + + mockMvc.perform(post(buildForceStartPath()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkLifecycleErrorCode.REQUEST_INVALID.getMessage())); + } + + @Test + void forceStartShouldRejectWhenDurationInvalid() throws Exception { + String requestBody = buildForceStartBody(newClerkId(), newClerkId(), 0); + + mockMvc.perform(post(buildForceStartPath()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkLifecycleErrorCode.REQUEST_INVALID.getMessage())); + } + + @Test + void forceStartShouldRejectWhenConflictOnClerkAOnly() throws Exception { + String clerkAId = newClerkId(); + String clerkBId = newClerkId(); + String otherClerk = newClerkId(); + LocalDateTime now = LocalDateTime.now(); + createPkForClerks(clerkAId, otherClerk, now.minusMinutes(MINUTES_BEFORE_START), + now.plusMinutes(MINUTES_AFTER_START), ClerkPkEnum.IN_PROGRESS.name()); + + String requestBody = buildForceStartBody(clerkAId, clerkBId, FORCE_START_DURATION_MINUTES); + mockMvc.perform(post(buildForceStartPath()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkLifecycleErrorCode.CLERK_CONFLICT.getMessage())); + } + + @Test + void forceStartShouldRejectWhenConflictOnClerkBOnly() throws Exception { + String clerkAId = newClerkId(); + String clerkBId = newClerkId(); + String otherClerk = newClerkId(); + LocalDateTime now = LocalDateTime.now(); + createPkForClerks(otherClerk, clerkBId, now.minusMinutes(MINUTES_BEFORE_START), + now.plusMinutes(MINUTES_AFTER_START), ClerkPkEnum.IN_PROGRESS.name()); + + String requestBody = buildForceStartBody(clerkAId, clerkBId, FORCE_START_DURATION_MINUTES); + mockMvc.perform(post(buildForceStartPath()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkLifecycleErrorCode.CLERK_CONFLICT.getMessage())); + } + + @Test + void startPkShouldRejectWhenStartTimeNotReached() throws Exception { + LocalDateTime now = LocalDateTime.now(); + PlayClerkPkEntity pk = buildPk(ClerkPkEnum.TO_BE_STARTED.name(), + now.plusMinutes(MINUTES_FUTURE_START), + now.plusMinutes(MINUTES_AFTER_START), + SETTLED_FALSE); + + mockMvc.perform(post(buildStartPath(pk.getId())) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(MSG_PK_START_NOT_REACHED)); + } + + @Test + void startPkShouldFailWhenMissing() throws Exception { + String pkId = IdUtils.getUuid(); + mockMvc.perform(post(buildStartPath(pkId)) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(MSG_PK_NOT_FOUND)); + } + + @Test + void startPkShouldAdvanceStatusWhenEligible() throws Exception { + LocalDateTime now = LocalDateTime.now(); + PlayClerkPkEntity pk = buildPk(ClerkPkEnum.TO_BE_STARTED.name(), + now.minusMinutes(MINUTES_BEFORE_START), + now.plusMinutes(MINUTES_AFTER_START), + SETTLED_FALSE); + + mockMvc.perform(post(buildStartPath(pk.getId())) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())); + + PlayClerkPkEntity persisted = loadPk(pk.getId()); + assertThat(persisted.getStatus()).isEqualTo(ClerkPkEnum.IN_PROGRESS.name()); + } + + @Test + void finishPkShouldFailWhenMissing() throws Exception { + String pkId = IdUtils.getUuid(); + mockMvc.perform(post(buildFinishPath(pkId)) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(MSG_PK_NOT_FOUND)); + } + + @Test + void finishPkShouldBeIdempotent() throws Exception { + LocalDateTime now = LocalDateTime.now(); + PlayClerkPkEntity pk = buildPk(ClerkPkEnum.FINISHED.name(), + now.minusMinutes(MINUTES_BEFORE_START), + now.minusMinutes(MINUTES_BEFORE_START), + SETTLED_TRUE); + + mockMvc.perform(post(buildFinishPath(pk.getId())) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())); + + PlayClerkPkEntity persisted = loadPk(pk.getId()); + assertThat(persisted.getStatus()).isEqualTo(ClerkPkEnum.FINISHED.name()); + assertThat(persisted.getSettled()).isEqualTo(SETTLED_TRUE); + } + + @Test + void finishPkShouldPersistZeroWhenRedisEmpty() throws Exception { + LocalDateTime now = LocalDateTime.now(); + PlayClerkPkEntity pk = buildPk(ClerkPkEnum.IN_PROGRESS.name(), + now.minusMinutes(MINUTES_BEFORE_START), + now.minusMinutes(MINUTES_PAST_END), + SETTLED_FALSE); + + mockMvc.perform(post(buildFinishPath(pk.getId())) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())); + + PlayClerkPkEntity persisted = loadPk(pk.getId()); + assertThat(persisted.getClerkAScore()).isEqualByComparingTo(ZERO_SCORE); + assertThat(persisted.getClerkBScore()).isEqualByComparingTo(ZERO_SCORE); + assertThat(persisted.getClerkAOrderCount()).isEqualTo(ZERO_COUNT); + assertThat(persisted.getClerkBOrderCount()).isEqualTo(ZERO_COUNT); + assertThat(persisted.getWinnerClerkId()).isNull(); + } + + @Test + void finishPkShouldAssignWinnerForClerkA() throws Exception { + LocalDateTime now = LocalDateTime.now(); + PlayClerkPkEntity pk = buildPk(ClerkPkEnum.IN_PROGRESS.name(), + now.minusMinutes(MINUTES_BEFORE_START), + now.minusMinutes(MINUTES_PAST_END), + SETTLED_FALSE); + + String scoreKey = PkRedisKeyConstants.scoreKey(pk.getId()); + stringRedisTemplate.opsForHash().put(scoreKey, PkRedisKeyConstants.FIELD_CLERK_A_SCORE, SCORE_A.toString()); + stringRedisTemplate.opsForHash().put(scoreKey, PkRedisKeyConstants.FIELD_CLERK_B_SCORE, SCORE_B.toString()); + + mockMvc.perform(post(buildFinishPath(pk.getId())) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())); + + PlayClerkPkEntity persisted = loadPk(pk.getId()); + assertThat(persisted.getWinnerClerkId()).isEqualTo(pk.getClerkA()); + } + + @Test + void finishPkShouldAssignWinnerForClerkB() throws Exception { + LocalDateTime now = LocalDateTime.now(); + PlayClerkPkEntity pk = buildPk(ClerkPkEnum.IN_PROGRESS.name(), + now.minusMinutes(MINUTES_BEFORE_START), + now.minusMinutes(MINUTES_PAST_END), + SETTLED_FALSE); + + String scoreKey = PkRedisKeyConstants.scoreKey(pk.getId()); + stringRedisTemplate.opsForHash().put(scoreKey, PkRedisKeyConstants.FIELD_CLERK_A_SCORE, SCORE_B.toString()); + stringRedisTemplate.opsForHash().put(scoreKey, PkRedisKeyConstants.FIELD_CLERK_B_SCORE, SCORE_A.toString()); + + mockMvc.perform(post(buildFinishPath(pk.getId())) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())); + + PlayClerkPkEntity persisted = loadPk(pk.getId()); + assertThat(persisted.getWinnerClerkId()).isEqualTo(pk.getClerkB()); + } + + @Test + void finishPkShouldKeepWinnerNullOnTie() throws Exception { + LocalDateTime now = LocalDateTime.now(); + PlayClerkPkEntity pk = buildPk(ClerkPkEnum.IN_PROGRESS.name(), + now.minusMinutes(MINUTES_BEFORE_START), + now.minusMinutes(MINUTES_BEFORE_START), + SETTLED_FALSE); + + String scoreKey = PkRedisKeyConstants.scoreKey(pk.getId()); + stringRedisTemplate.opsForHash().put(scoreKey, PkRedisKeyConstants.FIELD_CLERK_A_SCORE, TIE_SCORE.toString()); + stringRedisTemplate.opsForHash().put(scoreKey, PkRedisKeyConstants.FIELD_CLERK_B_SCORE, TIE_SCORE.toString()); + + mockMvc.perform(post(buildFinishPath(pk.getId())) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())); + + PlayClerkPkEntity persisted = loadPk(pk.getId()); + assertThat(persisted.getWinnerClerkId()).isNull(); + } + + @Test + void scoreboardShouldReturnRedisValues() throws Exception { + LocalDateTime now = LocalDateTime.now(); + PlayClerkPkEntity pk = buildPk(ClerkPkEnum.IN_PROGRESS.name(), + now.minusMinutes(MINUTES_BEFORE_START), + now.plusMinutes(MINUTES_AFTER_START), + SETTLED_FALSE); + + String scoreKey = PkRedisKeyConstants.scoreKey(pk.getId()); + stringRedisTemplate.opsForHash().put(scoreKey, PkRedisKeyConstants.FIELD_CLERK_A_SCORE, SCORE_A.toString()); + stringRedisTemplate.opsForHash().put(scoreKey, PkRedisKeyConstants.FIELD_CLERK_B_SCORE, SCORE_B.toString()); + stringRedisTemplate.opsForHash().put(scoreKey, + PkRedisKeyConstants.FIELD_CLERK_A_ORDER_COUNT, String.valueOf(ORDER_COUNT_A)); + stringRedisTemplate.opsForHash().put(scoreKey, + PkRedisKeyConstants.FIELD_CLERK_B_ORDER_COUNT, String.valueOf(ORDER_COUNT_B)); + + mockMvc.perform(get(buildScoreboardPath(pk.getId())) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())) + .andExpect(jsonPath("$.data.clerkAScore").value(SCORE_A.doubleValue())) + .andExpect(jsonPath("$.data.clerkBScore").value(SCORE_B.doubleValue())) + .andExpect(jsonPath("$.data.clerkAOrderCount").value(ORDER_COUNT_A)) + .andExpect(jsonPath("$.data.clerkBOrderCount").value(ORDER_COUNT_B)); + } + + @Test + void scoreboardShouldReturnZeroWhenRedisEmpty() throws Exception { + LocalDateTime now = LocalDateTime.now(); + PlayClerkPkEntity pk = buildPk(ClerkPkEnum.IN_PROGRESS.name(), + now.minusMinutes(MINUTES_BEFORE_START), + now.plusMinutes(MINUTES_AFTER_START), + SETTLED_FALSE); + + mockMvc.perform(get(buildScoreboardPath(pk.getId())) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())) + .andExpect(jsonPath("$.data.clerkAScore").value(ZERO_VALUE)) + .andExpect(jsonPath("$.data.clerkBScore").value(ZERO_VALUE)) + .andExpect(jsonPath("$.data.clerkAOrderCount").value(ZERO_COUNT)) + .andExpect(jsonPath("$.data.clerkBOrderCount").value(ZERO_COUNT)); + } + + @Test + void scoreboardShouldReturnZeroRemainingSecondsWhenEnded() throws Exception { + LocalDateTime now = LocalDateTime.now(); + PlayClerkPkEntity pk = buildPk(ClerkPkEnum.IN_PROGRESS.name(), + now.minusMinutes(MINUTES_BEFORE_START), + now.minusMinutes(MINUTES_PAST_END), + SETTLED_FALSE); + + mockMvc.perform(get(buildScoreboardPath(pk.getId())) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())) + .andExpect(jsonPath("$.data.remainingSeconds").value(REMAINING_SECONDS_ZERO)); + } + + @Test + void scoreboardShouldFailWhenMissingPk() throws Exception { + mockMvc.perform(get(buildScoreboardPath(IdUtils.getUuid())) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(MSG_PK_NOT_FOUND)); + } + + private PlayClerkPkEntity buildPk(String status, LocalDateTime beginTime, LocalDateTime endTime, int settled) { + String pkId = IdUtils.getUuid(); + String clerkAId = newClerkId(); + String clerkBId = newClerkId(); + PlayClerkPkEntity pk = new PlayClerkPkEntity(); + pk.setId(pkId); + pk.setTenantId(DEFAULT_TENANT); + pk.setClerkA(clerkAId); + pk.setClerkB(clerkBId); + pk.setPkBeginTime(Date.from(beginTime.atZone(ZoneId.systemDefault()).toInstant())); + pk.setPkEndTime(Date.from(endTime.atZone(ZoneId.systemDefault()).toInstant())); + pk.setStatus(status); + pk.setSettled(settled); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(pk); + return pk; + } + + private PlayClerkPkEntity createPkForClerks(String clerkAId, String clerkBId, LocalDateTime beginTime, + LocalDateTime endTime, String status) { + PlayClerkPkEntity pk = new PlayClerkPkEntity(); + pk.setId(IdUtils.getUuid()); + pk.setTenantId(DEFAULT_TENANT); + pk.setClerkA(clerkAId); + pk.setClerkB(clerkBId); + pk.setPkBeginTime(Date.from(beginTime.atZone(ZoneId.systemDefault()).toInstant())); + pk.setPkEndTime(Date.from(endTime.atZone(ZoneId.systemDefault()).toInstant())); + pk.setStatus(status); + pk.setSettled(SETTLED_FALSE); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(pk); + return pk; + } + + private PlayClerkPkEntity loadPk(String pkId) { + SecurityUtils.setTenantId(DEFAULT_TENANT); + PlayClerkPkEntity persisted = clerkPkService.selectPlayClerkPkById(pkId); + assertThat(persisted).isNotNull(); + return persisted; + } + + private static String newClerkId() { + return CLERK_PREFIX + IdUtils.getUuid(); + } + + private static String buildStartPath(String pkId) { + return "/play/pk/" + pkId + "/start"; + } + + private static String buildFinishPath(String pkId) { + return "/play/pk/" + pkId + "/finish"; + } + + private static String buildScoreboardPath(String pkId) { + return "/play/pk/" + pkId + "/scoreboard"; + } + + private static String buildForceStartPath() { + return "/play/pk/force-start"; + } + + private static String buildForceStartBody(String clerkAId, String clerkBId, int durationMinutes) { + StringBuilder builder = new StringBuilder(); + builder.append("{"); + builder.append("\"clerkAId\":\"").append(clerkAId).append("\","); + builder.append("\"clerkBId\":\"").append(clerkBId).append("\","); + builder.append("\"durationMinutes\":").append(durationMinutes); + builder.append("}"); + return builder.toString(); + } + + private static String extractDataAsText(MvcResult result) throws Exception { + JsonNode root = OBJECT_MAPPER.readTree(result.getResponse().getContentAsString()); + return root.path("data").asText(); + } +} diff --git a/play-admin/src/test/java/com/starry/admin/modules/pk/PlayClerkPkSettingApiTest.java b/play-admin/src/test/java/com/starry/admin/modules/pk/PlayClerkPkSettingApiTest.java new file mode 100644 index 0000000..8a96584 --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/modules/pk/PlayClerkPkSettingApiTest.java @@ -0,0 +1,583 @@ +package com.starry.admin.modules.pk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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.setting.constants.PkSettingApiConstants; +import com.starry.admin.modules.pk.setting.enums.PkRecurrenceType; +import com.starry.admin.modules.pk.setting.enums.PkSettingErrorCode; +import com.starry.admin.modules.pk.setting.enums.PkSettingStatus; +import com.starry.admin.utils.SecurityUtils; +import com.starry.common.result.ResultCodeEnum; +import com.starry.common.utils.IdUtils; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.List; +import javax.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; + +class PlayClerkPkSettingApiTest extends AbstractApiTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String SETTING_NAME = "PK排期测试"; + private static final String START_TIME_OF_DAY = "08:00:00"; + private static final int DURATION_MINUTES = 30; + private static final String EFFECTIVE_START_DATE = "2025-01-01"; + private static final String EFFECTIVE_END_DATE = "2025-01-03"; + private static final String TIMEZONE = "Asia/Shanghai"; + private static final int EXPECTED_GENERATED_COUNT = 3; + private static final int EXPECTED_ONCE_COUNT = 1; + private static final int EXPECTED_ZERO_COUNT = 0; + private static final int EXPECTED_BULK_COUNT = 2; + private static final int INVALID_DAY_OF_MONTH = 32; + private static final int INVALID_MONTH_OF_YEAR = 13; + private static final int MONTH_OF_YEAR_JANUARY = 1; + private static final int DAY_OF_MONTH_FIRST = 1; + private static final int DAY_OF_MONTH_THIRTY_FIRST = 31; + private static final int SETTLED_FALSE = 0; + private static final String INVALID_TIMEZONE = "Invalid/Zone"; + private static final String EARLY_END_DATE = "2024-12-31"; + private static final String LONG_RANGE_START_DATE = "2025-01-01"; + private static final String LONG_RANGE_END_DATE = "2029-01-01"; + private static final String MONTHLY_SHORT_START_DATE = "2025-04-01"; + private static final String MONTHLY_SHORT_END_DATE = "2025-04-30"; + private static final String CONFLICT_START_DATE = "2025-01-01"; + private static final String CONFLICT_END_DATE = "2025-01-02"; + private static final String UPDATED_NAME = "PK排期更新"; + + @Resource + private IPlayClerkPkService clerkPkService; + + @Test + void pkSettingFlowShouldWorkEndToEnd() throws Exception { + String clerkAId = "clerk-" + IdUtils.getUuid(); + String clerkBId = "clerk-" + IdUtils.getUuid(); + String createRequestBody = buildRequestBody(SETTING_NAME, PkRecurrenceType.DAILY, + null, null, null, PkSettingStatus.ENABLED, clerkAId, clerkBId, EFFECTIVE_START_DATE, + EFFECTIVE_END_DATE, TIMEZONE); + String updateRequestBody = buildRequestBody(UPDATED_NAME, PkRecurrenceType.DAILY, + null, null, null, PkSettingStatus.DISABLED, clerkAId, clerkBId, EFFECTIVE_START_DATE, + EFFECTIVE_END_DATE, TIMEZONE); + String createPath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.CREATE_PATH; + MvcResult createResult = mockMvc.perform(post(createPath) + .contentType(MediaType.APPLICATION_JSON) + .content(createRequestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())) + .andReturn(); + + String settingId = extractDataAsText(createResult); + + String listPath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.LIST_PATH; + mockMvc.perform(get(listPath) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())) + .andExpect(jsonPath("$.data").isArray()); + + String detailPath = PkSettingApiConstants.BASE_PATH + "/" + + settingId; + mockMvc.perform(get(detailPath) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())) + .andExpect(jsonPath("$.data.id").value(settingId)) + .andExpect(jsonPath("$.data.name").value(SETTING_NAME)); + + String updatePath = PkSettingApiConstants.BASE_PATH + "/update/" + settingId; + mockMvc.perform(post(updatePath) + .contentType(MediaType.APPLICATION_JSON) + .content(updateRequestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())); + + mockMvc.perform(get(detailPath) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())) + .andExpect(jsonPath("$.data.name").value(UPDATED_NAME)); + + String enablePath = PkSettingApiConstants.BASE_PATH + "/" + settingId + "/enable"; + mockMvc.perform(post(enablePath) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())); + + SecurityUtils.setTenantId(DEFAULT_TENANT); + List generated = clerkPkService.list( + Wrappers.lambdaQuery().eq(PlayClerkPkEntity::getSettingId, settingId)); + assertThat(generated).hasSize(EXPECTED_GENERATED_COUNT); + } + + @Test + void createSettingShouldRejectMissingWeeklyFields() throws Exception { + String createPath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.CREATE_PATH; + String clerkAId = "clerk-" + IdUtils.getUuid(); + String clerkBId = "clerk-" + IdUtils.getUuid(); + String invalidWeeklyRequestBody = buildRequestBody("无效周排期", PkRecurrenceType.WEEKLY, + null, null, null, PkSettingStatus.ENABLED, clerkAId, clerkBId, EFFECTIVE_START_DATE, + EFFECTIVE_END_DATE, TIMEZONE); + mockMvc.perform(post(createPath) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidWeeklyRequestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkSettingErrorCode.RECURRENCE_INVALID.getMessage())); + } + + @Test + void createSettingShouldRejectInvalidMonthlyDay() throws Exception { + String createPath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.CREATE_PATH; + String clerkAId = "clerk-" + IdUtils.getUuid(); + String clerkBId = "clerk-" + IdUtils.getUuid(); + String invalidMonthlyRequestBody = buildRequestBody("无效月排期", PkRecurrenceType.MONTHLY, + null, INVALID_DAY_OF_MONTH, null, PkSettingStatus.ENABLED, clerkAId, clerkBId, EFFECTIVE_START_DATE, + EFFECTIVE_END_DATE, TIMEZONE); + mockMvc.perform(post(createPath) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidMonthlyRequestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkSettingErrorCode.RECURRENCE_INVALID.getMessage())); + } + + @Test + void generateShouldRejectClerkConflict() throws Exception { + String createPath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.CREATE_PATH; + String clerkAId = "clerk-" + IdUtils.getUuid(); + String clerkBId = "clerk-" + IdUtils.getUuid(); + String createRequestBody = buildRequestBody(SETTING_NAME, PkRecurrenceType.DAILY, + null, null, null, PkSettingStatus.ENABLED, clerkAId, clerkBId, EFFECTIVE_START_DATE, + EFFECTIVE_END_DATE, TIMEZONE); + String conflictRequestBody = buildRequestBody("冲突排期", PkRecurrenceType.DAILY, + null, null, null, PkSettingStatus.ENABLED, clerkAId, clerkBId, EFFECTIVE_START_DATE, + EFFECTIVE_END_DATE, TIMEZONE); + + mockMvc.perform(post(createPath) + .contentType(MediaType.APPLICATION_JSON) + .content(createRequestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())); + + mockMvc.perform(post(createPath) + .contentType(MediaType.APPLICATION_JSON) + .content(conflictRequestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkSettingErrorCode.CLERK_CONFLICT.getMessage())); + } + + @Test + void bulkCreateShouldCreateMultipleSettings() throws Exception { + String bulkCreatePath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.BULK_CREATE_PATH; + String clerkAId = "clerk-" + IdUtils.getUuid(); + String clerkBId = "clerk-" + IdUtils.getUuid(); + String clerkCId = "clerk-" + IdUtils.getUuid(); + String clerkDId = "clerk-" + IdUtils.getUuid(); + String firstRequestBody = buildRequestBody("批量排期A", PkRecurrenceType.DAILY, + null, null, null, PkSettingStatus.ENABLED, clerkAId, clerkBId, EFFECTIVE_START_DATE, + EFFECTIVE_END_DATE, TIMEZONE); + String secondRequestBody = buildRequestBody("批量排期B", PkRecurrenceType.DAILY, + null, null, null, PkSettingStatus.ENABLED, clerkCId, clerkDId, EFFECTIVE_START_DATE, + EFFECTIVE_END_DATE, TIMEZONE); + String bulkRequestBody = buildBulkRequestBody(List.of(firstRequestBody, secondRequestBody)); + + MvcResult result = mockMvc.perform(post(bulkCreatePath) + .contentType(MediaType.APPLICATION_JSON) + .content(bulkRequestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())) + .andReturn(); + + JsonNode data = OBJECT_MAPPER.readTree(result.getResponse().getContentAsString()).path("data"); + assertThat(data.isArray()).isTrue(); + assertThat(data.size()).isEqualTo(EXPECTED_BULK_COUNT); + } + + @Test + void bulkCreateShouldRejectDuplicatePairs() throws Exception { + String bulkCreatePath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.BULK_CREATE_PATH; + String clerkAId = "clerk-" + IdUtils.getUuid(); + String clerkBId = "clerk-" + IdUtils.getUuid(); + String firstRequestBody = buildRequestBody("批量排期重复A", PkRecurrenceType.DAILY, + null, null, null, PkSettingStatus.ENABLED, clerkAId, clerkBId, EFFECTIVE_START_DATE, + EFFECTIVE_END_DATE, TIMEZONE); + String secondRequestBody = buildRequestBody("批量排期重复B", PkRecurrenceType.DAILY, + null, null, null, PkSettingStatus.ENABLED, clerkBId, clerkAId, EFFECTIVE_START_DATE, + EFFECTIVE_END_DATE, TIMEZONE); + String bulkRequestBody = buildBulkRequestBody(List.of(firstRequestBody, secondRequestBody)); + + mockMvc.perform(post(bulkCreatePath) + .contentType(MediaType.APPLICATION_JSON) + .content(bulkRequestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkSettingErrorCode.REQUEST_INVALID.getMessage())); + } + + @Test + void createSettingShouldRejectMissingMonthlyDay() throws Exception { + String createPath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.CREATE_PATH; + String clerkAId = "clerk-" + IdUtils.getUuid(); + String clerkBId = "clerk-" + IdUtils.getUuid(); + String invalidMonthlyRequestBody = buildRequestBody("缺失月日", PkRecurrenceType.MONTHLY, + null, null, null, PkSettingStatus.ENABLED, clerkAId, clerkBId, EFFECTIVE_START_DATE, + EFFECTIVE_END_DATE, TIMEZONE); + mockMvc.perform(post(createPath) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidMonthlyRequestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkSettingErrorCode.RECURRENCE_INVALID.getMessage())); + } + + @Test + void createSettingShouldRejectMissingYearlyFields() throws Exception { + String createPath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.CREATE_PATH; + String clerkAId = "clerk-" + IdUtils.getUuid(); + String clerkBId = "clerk-" + IdUtils.getUuid(); + String invalidYearlyRequestBody = buildRequestBody("缺失年字段", PkRecurrenceType.YEARLY, + null, DAY_OF_MONTH_FIRST, null, PkSettingStatus.ENABLED, clerkAId, clerkBId, EFFECTIVE_START_DATE, + EFFECTIVE_END_DATE, TIMEZONE); + mockMvc.perform(post(createPath) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidYearlyRequestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkSettingErrorCode.RECURRENCE_INVALID.getMessage())); + } + + @Test + void createSettingShouldRejectInvalidMonthOfYear() throws Exception { + String createPath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.CREATE_PATH; + String clerkAId = "clerk-" + IdUtils.getUuid(); + String clerkBId = "clerk-" + IdUtils.getUuid(); + String invalidYearlyRequestBody = buildRequestBody("无效月份", PkRecurrenceType.YEARLY, + null, DAY_OF_MONTH_FIRST, INVALID_MONTH_OF_YEAR, PkSettingStatus.ENABLED, clerkAId, clerkBId, + EFFECTIVE_START_DATE, EFFECTIVE_END_DATE, TIMEZONE); + mockMvc.perform(post(createPath) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidYearlyRequestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkSettingErrorCode.RECURRENCE_INVALID.getMessage())); + } + + @Test + void createSettingShouldRejectInvalidTimeRange() throws Exception { + String createPath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.CREATE_PATH; + String clerkAId = "clerk-" + IdUtils.getUuid(); + String clerkBId = "clerk-" + IdUtils.getUuid(); + String invalidRangeRequestBody = buildRequestBody("无效时间范围", PkRecurrenceType.DAILY, + null, null, null, PkSettingStatus.ENABLED, clerkAId, clerkBId, EFFECTIVE_START_DATE, + EARLY_END_DATE, TIMEZONE); + mockMvc.perform(post(createPath) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidRangeRequestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkSettingErrorCode.TIME_RANGE_INVALID.getMessage())); + } + + @Test + void createSettingShouldRejectSameClerk() throws Exception { + String createPath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.CREATE_PATH; + String clerkId = "clerk-" + IdUtils.getUuid(); + String invalidRequestBody = buildRequestBody("同店员", PkRecurrenceType.DAILY, + null, null, null, PkSettingStatus.ENABLED, clerkId, clerkId, EFFECTIVE_START_DATE, + EFFECTIVE_END_DATE, TIMEZONE); + mockMvc.perform(post(createPath) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidRequestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkSettingErrorCode.REQUEST_INVALID.getMessage())); + } + + @Test + void createSettingShouldRejectInvalidTimezone() throws Exception { + String createPath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.CREATE_PATH; + String clerkAId = "clerk-" + IdUtils.getUuid(); + String clerkBId = "clerk-" + IdUtils.getUuid(); + String invalidRequestBody = buildRequestBody("无效时区", PkRecurrenceType.DAILY, + null, null, null, PkSettingStatus.ENABLED, clerkAId, clerkBId, EFFECTIVE_START_DATE, + EFFECTIVE_END_DATE, INVALID_TIMEZONE); + mockMvc.perform(post(createPath) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidRequestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkSettingErrorCode.REQUEST_INVALID.getMessage())); + } + + @Test + void generateShouldRespectThreeYearLimit() throws Exception { + String clerkAId = "clerk-" + IdUtils.getUuid(); + String clerkBId = "clerk-" + IdUtils.getUuid(); + String requestBody = buildRequestBody("三年上限", PkRecurrenceType.YEARLY, + null, DAY_OF_MONTH_FIRST, MONTH_OF_YEAR_JANUARY, PkSettingStatus.ENABLED, clerkAId, clerkBId, + LONG_RANGE_START_DATE, LONG_RANGE_END_DATE, TIMEZONE); + String createPath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.CREATE_PATH; + String settingId = extractDataAsText(mockMvc.perform(post(createPath) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())) + .andReturn()); + + SecurityUtils.setTenantId(DEFAULT_TENANT); + List generated = clerkPkService.list( + Wrappers.lambdaQuery().eq(PlayClerkPkEntity::getSettingId, settingId)); + assertThat(generated).isNotEmpty(); + LocalDate maxDate = generated.stream() + .map(PlayClerkPkEntity::getPkBeginTime) + .filter(date -> date != null) + .map(date -> date.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate()) + .max(LocalDate::compareTo) + .orElse(LocalDate.parse(LONG_RANGE_START_DATE)); + LocalDate maxAllowed = LocalDate.parse(LONG_RANGE_START_DATE).plusYears(3); + assertThat(maxDate).isBefore(maxAllowed.plusDays(1)); + } + + @Test + void monthlyInvalidDayShouldSkipWithoutError() throws Exception { + String clerkAId = "clerk-" + IdUtils.getUuid(); + String clerkBId = "clerk-" + IdUtils.getUuid(); + String requestBody = buildRequestBody("无效日期跳过", PkRecurrenceType.MONTHLY, + null, DAY_OF_MONTH_THIRTY_FIRST, null, PkSettingStatus.ENABLED, clerkAId, clerkBId, + MONTHLY_SHORT_START_DATE, MONTHLY_SHORT_END_DATE, TIMEZONE); + String createPath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.CREATE_PATH; + String settingId = extractDataAsText(mockMvc.perform(post(createPath) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())) + .andReturn()); + SecurityUtils.setTenantId(DEFAULT_TENANT); + List generated = clerkPkService.list( + Wrappers.lambdaQuery().eq(PlayClerkPkEntity::getSettingId, settingId)); + assertThat(generated).hasSize(EXPECTED_ZERO_COUNT); + } + + @Test + void onceScheduleShouldGenerateSingleInstance() throws Exception { + String clerkAId = "clerk-" + IdUtils.getUuid(); + String clerkBId = "clerk-" + IdUtils.getUuid(); + String requestBody = buildRequestBody("单次排期", PkRecurrenceType.ONCE, + null, null, null, PkSettingStatus.ENABLED, clerkAId, clerkBId, EFFECTIVE_START_DATE, + EFFECTIVE_START_DATE, TIMEZONE); + String createPath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.CREATE_PATH; + String settingId = extractDataAsText(mockMvc.perform(post(createPath) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())) + .andReturn()); + SecurityUtils.setTenantId(DEFAULT_TENANT); + List generated = clerkPkService.list( + Wrappers.lambdaQuery().eq(PlayClerkPkEntity::getSettingId, settingId)); + assertThat(generated).hasSize(EXPECTED_ONCE_COUNT); + } + + @Test + void disabledSettingShouldNotGenerateInstances() throws Exception { + String clerkAId = "clerk-" + IdUtils.getUuid(); + String clerkBId = "clerk-" + IdUtils.getUuid(); + String requestBody = buildRequestBody("禁用生成", PkRecurrenceType.DAILY, + null, null, null, PkSettingStatus.DISABLED, clerkAId, clerkBId, EFFECTIVE_START_DATE, + EFFECTIVE_END_DATE, TIMEZONE); + String createPath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.CREATE_PATH; + String settingId = extractDataAsText(mockMvc.perform(post(createPath) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())) + .andReturn()); + SecurityUtils.setTenantId(DEFAULT_TENANT); + List generated = clerkPkService.list( + Wrappers.lambdaQuery().eq(PlayClerkPkEntity::getSettingId, settingId)); + assertThat(generated).hasSize(EXPECTED_ZERO_COUNT); + } + + @Test + void generateShouldRollbackWhenConflictDetected() throws Exception { + String clerkAId = "clerk-" + IdUtils.getUuid(); + String clerkBId = "clerk-" + IdUtils.getUuid(); + String requestBody = buildRequestBody("冲突回滚", PkRecurrenceType.DAILY, + null, null, null, PkSettingStatus.DISABLED, clerkAId, clerkBId, CONFLICT_START_DATE, + CONFLICT_END_DATE, TIMEZONE); + String createPath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.CREATE_PATH; + String settingId = extractDataAsText(mockMvc.perform(post(createPath) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())) + .andReturn()); + + LocalDateTime conflictStart = LocalDateTime.of(LocalDate.parse(CONFLICT_END_DATE), + LocalTime.parse(START_TIME_OF_DAY)); + LocalDateTime conflictEnd = conflictStart.plusMinutes(DURATION_MINUTES); + ZoneId zoneId = ZoneId.of(TIMEZONE); + PlayClerkPkEntity conflictPk = new PlayClerkPkEntity(); + conflictPk.setId(IdUtils.getUuid()); + conflictPk.setTenantId(DEFAULT_TENANT); + conflictPk.setClerkA(clerkAId); + conflictPk.setClerkB(clerkBId); + conflictPk.setPkBeginTime(Date.from(conflictStart.atZone(zoneId).toInstant())); + conflictPk.setPkEndTime(Date.from(conflictEnd.atZone(zoneId).toInstant())); + conflictPk.setStatus(ClerkPkEnum.TO_BE_STARTED.name()); + conflictPk.setSettled(SETTLED_FALSE); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(conflictPk); + + String enablePath = PkSettingApiConstants.BASE_PATH + "/" + settingId + "/enable"; + mockMvc.perform(post(enablePath) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(PkSettingErrorCode.CLERK_CONFLICT.getMessage())); + + SecurityUtils.setTenantId(DEFAULT_TENANT); + List generated = clerkPkService.list( + Wrappers.lambdaQuery().eq(PlayClerkPkEntity::getSettingId, settingId)); + assertThat(generated).isEmpty(); + } + + @Test + void listShouldExcludeOtherTenantSettings() throws Exception { + String tenantA = "tenant-" + IdUtils.getUuid(); + String tenantB = "tenant-" + IdUtils.getUuid(); + String clerkAId = "clerk-" + IdUtils.getUuid(); + String clerkBId = "clerk-" + IdUtils.getUuid(); + String requestBody = buildRequestBody("多租户排期", PkRecurrenceType.DAILY, + null, null, null, PkSettingStatus.ENABLED, clerkAId, clerkBId, EFFECTIVE_START_DATE, + EFFECTIVE_END_DATE, TIMEZONE); + String createPath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.CREATE_PATH; + mockMvc.perform(post(createPath) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, tenantB)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())); + + String listPath = PkSettingApiConstants.BASE_PATH + PkSettingApiConstants.LIST_PATH; + MvcResult listResult = mockMvc.perform(get(listPath) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, tenantA)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.SUCCESS.getCode())) + .andReturn(); + JsonNode data = OBJECT_MAPPER.readTree(listResult.getResponse().getContentAsString()).path("data"); + if (data.isArray()) { + for (JsonNode node : data) { + String tenantId = node.path("tenantId").asText(); + assertThat(tenantId).isNotEqualTo(tenantB); + } + } + } + + private static String extractDataAsText(MvcResult result) throws Exception { + JsonNode root = OBJECT_MAPPER.readTree(result.getResponse().getContentAsString()); + return root.path("data").asText(); + } + + private static String buildRequestBody(String name, PkRecurrenceType recurrenceType, String dayOfWeek, + Integer dayOfMonth, Integer monthOfYear, PkSettingStatus status, String clerkAId, String clerkBId, + String effectiveStartDate, String effectiveEndDate, String timezone) { + StringBuilder builder = new StringBuilder(); + builder.append("{"); + builder.append("\"name\":\"").append(name).append("\","); + builder.append("\"recurrenceType\":\"").append(recurrenceType.getValue()).append("\","); + if (dayOfWeek != null) { + builder.append("\"dayOfWeek\":\"").append(dayOfWeek).append("\","); + } + if (dayOfMonth != null) { + builder.append("\"dayOfMonth\":").append(dayOfMonth).append(","); + } + if (monthOfYear != null) { + builder.append("\"monthOfYear\":").append(monthOfYear).append(","); + } + builder.append("\"startTimeOfDay\":\"").append(START_TIME_OF_DAY).append("\","); + builder.append("\"durationMinutes\":").append(DURATION_MINUTES).append(","); + builder.append("\"effectiveStartDate\":\"").append(effectiveStartDate).append("\","); + builder.append("\"effectiveEndDate\":\"").append(effectiveEndDate).append("\","); + builder.append("\"timezone\":\"").append(timezone).append("\","); + builder.append("\"clerkAId\":\"").append(clerkAId).append("\","); + builder.append("\"clerkBId\":\"").append(clerkBId).append("\","); + builder.append("\"status\":\"").append(status.getValue()).append("\""); + builder.append("}"); + return builder.toString(); + } + + private static String buildBulkRequestBody(List settings) { + StringBuilder builder = new StringBuilder(); + builder.append("{\"settings\":["); + for (int i = 0; i < settings.size(); i++) { + builder.append(settings.get(i)); + if (i < settings.size() - 1) { + builder.append(","); + } + } + builder.append("]}"); + return builder.toString(); + } +} diff --git a/play-admin/src/test/java/com/starry/admin/modules/pk/WxPkApiTest.java b/play-admin/src/test/java/com/starry/admin/modules/pk/WxPkApiTest.java new file mode 100644 index 0000000..1af1acb --- /dev/null +++ b/play-admin/src/test/java/com/starry/admin/modules/pk/WxPkApiTest.java @@ -0,0 +1,642 @@ +package com.starry.admin.modules.pk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.starry.admin.api.AbstractApiTest; +import com.starry.admin.common.exception.handler.GlobalExceptionHandler; +import com.starry.admin.modules.clerk.module.entity.ClerkPkEnum; +import com.starry.admin.modules.clerk.module.entity.PlayClerkPkEntity; +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.admin.modules.custom.module.entity.PlayCustomUserInfoEntity; +import com.starry.admin.modules.custom.service.IPlayCustomUserInfoService; +import com.starry.admin.modules.order.module.constant.OrderConstant; +import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.order.service.IPlayOrderInfoService; +import com.starry.admin.modules.pk.enums.PkWxState; +import com.starry.admin.modules.pk.redis.PkRedisKeyConstants; +import com.starry.admin.utils.SecurityUtils; +import com.starry.common.result.ResultCodeEnum; +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.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.web.servlet.MvcResult; + +class WxPkApiTest extends AbstractApiTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String CLERK_PREFIX = "wx-pk-clerk-"; + private static final String CUSTOM_PREFIX = "wx-pk-custom-"; + private static final String OPENID_PREFIX = "openid-"; + private static final String CLERK_NAME_PREFIX = "店员-"; + private static final String CUSTOM_NAME_PREFIX = "贡献者-"; + private static final String PK_PREFIX = "wx-pk-"; + private static final String PARAM_CLERK_ID = "clerkId"; + private static final String PARAM_ID = "id"; + private static final String PARAM_LIMIT = "limit"; + private static final String MSG_PARAM_INVALID = GlobalExceptionHandler.PARAMETER_FORMAT_ERROR; + private static final String PARAM_PAGE_NUM = "pageNum"; + private static final String PARAM_PAGE_SIZE = "pageSize"; + private static final int MINUTES_BEFORE_START = 5; + private static final int MINUTES_AFTER_START = 30; + private static final int MINUTES_FUTURE_START = 10; + private static final int MINUTES_ORDER_OFFSET = 2; + private static final int HISTORY_FIRST_DAY_OFFSET = 2; + private static final int HISTORY_SECOND_DAY_OFFSET = 1; + private static final int FUTURE_FIRST_OFFSET_MINUTES = 30; + private static final int FUTURE_SECOND_OFFSET_MINUTES = 60; + private static final int PAST_OFFSET_MINUTES = 10; + private static final int FUTURE_THIRD_OFFSET_MINUTES = 90; + private static final int DEFAULT_LIMIT = 3; + private static final int LIMIT_ZERO = 0; + private static final int LIMIT_NEGATIVE = -1; + private static final int LIMIT_LARGE = 100; + private static final int SCHEDULE_LIMIT = 2; + private static final int HISTORY_PAGE_NUM = 1; + private static final int HISTORY_PAGE_SIZE = 10; + private static final long REMAINING_SECONDS_MIN = 1L; + private static final int SETTLED_FALSE = 0; + private static final int SETTLED_TRUE = 1; + private static final double ZSET_SCORE_PAST = 1D; + private static final BigDecimal AMOUNT_HIGH = new BigDecimal("120.00"); + private static final BigDecimal AMOUNT_MEDIUM = new BigDecimal("100.00"); + private static final BigDecimal AMOUNT_LOW = new BigDecimal("50.00"); + private static final int EXPECTED_HISTORY_COUNT = 2; + + @Resource + private IPlayClerkPkService clerkPkService; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + @Resource + private IPlayClerkUserInfoService clerkUserInfoService; + + @Resource + private IPlayCustomUserInfoService customUserInfoService; + + @Resource + private IPlayOrderInfoService orderInfoService; + + @BeforeEach + void clearRedis() { + if (stringRedisTemplate.getConnectionFactory() == null) { + return; + } + stringRedisTemplate.getConnectionFactory().getConnection().flushDb(); + } + + @Test + void clerkLiveShouldRejectWhenClerkIdMissing() throws Exception { + mockMvc.perform(get("/wx/pk/clerk/live") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(MSG_PARAM_INVALID)); + } + + @Test + void clerkLiveShouldReturnInactiveWhenNoActivePk() throws Exception { + String clerkId = newClerkId(); + mockMvc.perform(get("/wx/pk/clerk/live") + .param(PARAM_CLERK_ID, clerkId) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.state").value(PkWxState.INACTIVE.getValue())); + } + + @Test + void clerkLiveShouldReturnActiveWhenInProgress() throws Exception { + LocalDateTime now = LocalDateTime.now(); + String pkId = newPkId(); + String clerkAId = newClerkId(); + String clerkBId = newClerkId(); + + SecurityUtils.setTenantId(DEFAULT_TENANT); + PlayClerkPkEntity pk = buildPk(pkId, clerkAId, clerkBId, + now.minusMinutes(MINUTES_BEFORE_START), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.IN_PROGRESS.name()); + clerkPkService.save(pk); + + MvcResult result = mockMvc.perform(get("/wx/pk/clerk/live") + .param(PARAM_CLERK_ID, clerkAId) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.state").value(PkWxState.ACTIVE.getValue())) + .andExpect(jsonPath("$.data.id").value(pkId)) + .andReturn(); + + JsonNode data = extractData(result); + assertThat(data.get("remainingSeconds").asLong()).isGreaterThanOrEqualTo(REMAINING_SECONDS_MIN); + assertThat(data.get("serverEpochSeconds").asLong()).isGreaterThanOrEqualTo(REMAINING_SECONDS_MIN); + assertThat(data.get("pkEndEpochSeconds").asLong()).isGreaterThanOrEqualTo(REMAINING_SECONDS_MIN); + } + + @Test + void clerkLiveShouldReturnInactiveWhenToBeStarted() throws Exception { + LocalDateTime now = LocalDateTime.now(); + String clerkId = newClerkId(); + PlayClerkPkEntity pk = buildPk(newPkId(), clerkId, newClerkId(), + now.plusMinutes(MINUTES_FUTURE_START), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name()); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(pk); + + mockMvc.perform(get("/wx/pk/clerk/live") + .param(PARAM_CLERK_ID, clerkId) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.state").value(PkWxState.INACTIVE.getValue())); + } + + @Test + void upcomingShouldReturnInactiveWhenRedisEmpty() throws Exception { + mockMvc.perform(get("/wx/pk/upcoming") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.state").value(PkWxState.INACTIVE.getValue())); + } + + @Test + void upcomingShouldReturnUpcomingWhenRedisHasPk() throws Exception { + LocalDateTime now = LocalDateTime.now(); + String pkId = newPkId(); + PlayClerkPkEntity pk = buildPk(pkId, newClerkId(), newClerkId(), + now.plusMinutes(MINUTES_FUTURE_START), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name()); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(pk); + + String key = PkRedisKeyConstants.upcomingKey(DEFAULT_TENANT); + long score = pk.getPkBeginTime().toInstant().getEpochSecond(); + stringRedisTemplate.opsForZSet().add(key, pkId, score); + + mockMvc.perform(get("/wx/pk/upcoming") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.state").value(PkWxState.UPCOMING.getValue())) + .andExpect(jsonPath("$.data.id").value(pkId)); + } + + @Test + void upcomingShouldReturnInactiveWhenPkMissing() throws Exception { + String key = PkRedisKeyConstants.upcomingKey(DEFAULT_TENANT); + String pkId = newPkId(); + stringRedisTemplate.opsForZSet().add(key, pkId, ZSET_SCORE_PAST); + + mockMvc.perform(get("/wx/pk/upcoming") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.state").value(PkWxState.INACTIVE.getValue())); + } + + @Test + void detailShouldReturnInactiveWhenNotFound() throws Exception { + mockMvc.perform(get("/wx/pk/detail") + .param(PARAM_ID, newPkId()) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.state").value(PkWxState.INACTIVE.getValue())); + } + + @Test + void detailShouldReturnActiveWhenInProgress() throws Exception { + LocalDateTime now = LocalDateTime.now(); + String pkId = newPkId(); + PlayClerkPkEntity pk = buildPk(pkId, newClerkId(), newClerkId(), + now.minusMinutes(MINUTES_BEFORE_START), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.IN_PROGRESS.name()); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(pk); + + MvcResult result = mockMvc.perform(get("/wx/pk/detail") + .param(PARAM_ID, pkId) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.state").value(PkWxState.ACTIVE.getValue())) + .andExpect(jsonPath("$.data.id").value(pkId)) + .andReturn(); + + JsonNode data = extractData(result); + assertThat(data.get("remainingSeconds").asLong()).isGreaterThanOrEqualTo(REMAINING_SECONDS_MIN); + } + + @Test + void detailShouldReturnUpcomingWhenToBeStarted() throws Exception { + LocalDateTime now = LocalDateTime.now(); + String pkId = newPkId(); + PlayClerkPkEntity pk = buildPk(pkId, newClerkId(), newClerkId(), + now.plusMinutes(MINUTES_FUTURE_START), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name()); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(pk); + + mockMvc.perform(get("/wx/pk/detail") + .param(PARAM_ID, pkId) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.state").value(PkWxState.UPCOMING.getValue())) + .andExpect(jsonPath("$.data.id").value(pkId)); + } + + @Test + void detailShouldReturnContributorsAndHistory() throws Exception { + LocalDateTime now = LocalDateTime.now(); + String pkId = newPkId(); + String clerkAId = newClerkId(); + String clerkBId = newClerkId(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + + PlayClerkPkEntity pk = buildPk(pkId, clerkAId, clerkBId, + now.minusMinutes(MINUTES_AFTER_START), + now.plusMinutes(MINUTES_AFTER_START), + ClerkPkEnum.IN_PROGRESS.name()); + clerkPkService.save(pk); + + String clerkAName = CLERK_NAME_PREFIX + clerkAId; + String clerkBName = CLERK_NAME_PREFIX + clerkBId; + clerkUserInfoService.save(buildClerk(clerkAId, clerkAName)); + clerkUserInfoService.save(buildClerk(clerkBId, clerkBName)); + + String customAId = newCustomId(); + String customBId = newCustomId(); + customUserInfoService.save(buildCustomUser(customAId, CUSTOM_NAME_PREFIX + "A")); + customUserInfoService.save(buildCustomUser(customBId, CUSTOM_NAME_PREFIX + "B")); + + orderInfoService.save(buildOrder(customAId, clerkAId, AMOUNT_MEDIUM, now.minusMinutes(MINUTES_ORDER_OFFSET))); + orderInfoService.save(buildOrder(customBId, clerkAId, AMOUNT_LOW, now.minusMinutes(MINUTES_ORDER_OFFSET))); + orderInfoService.save(buildOrder(customBId, clerkBId, AMOUNT_HIGH, now.minusMinutes(MINUTES_ORDER_OFFSET))); + + clerkPkService.save(buildFinishedPk(newPkId(), clerkAId, clerkBId, + now.minusDays(HISTORY_FIRST_DAY_OFFSET), AMOUNT_LOW, AMOUNT_HIGH)); + clerkPkService.save(buildFinishedPk(newPkId(), clerkBId, clerkAId, + now.minusDays(HISTORY_SECOND_DAY_OFFSET), AMOUNT_HIGH, AMOUNT_LOW)); + + mockMvc.perform(get("/wx/pk/detail") + .param(PARAM_ID, pkId) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.contributors[0].userId").value(customBId)) + .andExpect(jsonPath("$.data.contributors[0].amount").value(AMOUNT_HIGH.add(AMOUNT_LOW).doubleValue())) + .andExpect(jsonPath("$.data.history.length()").value(EXPECTED_HISTORY_COUNT)) + .andExpect(jsonPath("$.data.history[0].clerkAName").value(clerkBName)); + } + + @Test + void clerkHistoryShouldReturnSummaryAndTopContributor() throws Exception { + LocalDateTime now = LocalDateTime.now(); + String clerkAId = newClerkId(); + String clerkBId = newClerkId(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + + PlayClerkPkEntity newerPk = buildFinishedPk(newPkId(), clerkAId, clerkBId, + now.minusDays(HISTORY_SECOND_DAY_OFFSET), AMOUNT_HIGH, AMOUNT_LOW); + newerPk.setWinnerClerkId(clerkAId); + clerkPkService.save(newerPk); + + PlayClerkPkEntity olderPk = buildFinishedPk(newPkId(), clerkAId, clerkBId, + now.minusDays(HISTORY_FIRST_DAY_OFFSET), AMOUNT_LOW, AMOUNT_HIGH); + olderPk.setWinnerClerkId(clerkBId); + clerkPkService.save(olderPk); + + String customHighId = newCustomId(); + String customLowId = newCustomId(); + customUserInfoService.save(buildCustomUser(customHighId, CUSTOM_NAME_PREFIX + "高")); + customUserInfoService.save(buildCustomUser(customLowId, CUSTOM_NAME_PREFIX + "低")); + + orderInfoService.save(buildOrder(customLowId, clerkAId, AMOUNT_LOW, now.minusDays(HISTORY_SECOND_DAY_OFFSET) + .plusMinutes(MINUTES_ORDER_OFFSET))); + orderInfoService.save(buildOrder(customHighId, clerkBId, AMOUNT_HIGH, now.minusDays(HISTORY_SECOND_DAY_OFFSET) + .plusMinutes(MINUTES_ORDER_OFFSET))); + + mockMvc.perform(get("/wx/pk/clerk/history") + .param(PARAM_CLERK_ID, clerkAId) + .param(PARAM_PAGE_NUM, String.valueOf(HISTORY_PAGE_NUM)) + .param(PARAM_PAGE_SIZE, String.valueOf(HISTORY_PAGE_SIZE)) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items.length()").value(EXPECTED_HISTORY_COUNT)) + .andExpect(jsonPath("$.data.summary.totalCount").value(EXPECTED_HISTORY_COUNT)) + .andExpect(jsonPath("$.data.summary.winCount").value(1)) + .andExpect(jsonPath("$.data.items[0].topContributorName").value(CUSTOM_NAME_PREFIX + "高")) + .andExpect(jsonPath("$.data.items[0].winnerClerkId").value(clerkAId)) + .andExpect(jsonPath("$.data.items[0].id").isNotEmpty()) + .andExpect(jsonPath("$.data.items[0].clerkAId").value(clerkAId)) + .andExpect(jsonPath("$.data.items[0].clerkBId").value(clerkBId)) + .andExpect(jsonPath("$.data.items[0].topContributorAmount").value(AMOUNT_HIGH.doubleValue())); + } + + @Test + void clerkScheduleShouldRejectWhenClerkIdMissing() throws Exception { + mockMvc.perform(get("/wx/pk/clerk/schedule") + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResultCodeEnum.FAILED.getCode())) + .andExpect(jsonPath("$.message").value(MSG_PARAM_INVALID)); + } + + @Test + void clerkScheduleShouldReturnEmptyWhenNoFuturePk() throws Exception { + String clerkId = newClerkId(); + mockMvc.perform(get("/wx/pk/clerk/schedule") + .param(PARAM_CLERK_ID, clerkId) + .param(PARAM_LIMIT, String.valueOf(SCHEDULE_LIMIT)) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(0)); + } + + @Test + void clerkScheduleShouldReturnUpcomingOrdered() throws Exception { + LocalDateTime now = LocalDateTime.now(); + String clerkId = newClerkId(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + + PlayClerkPkEntity first = buildPk(newPkId(), clerkId, newClerkId(), + now.plusMinutes(FUTURE_FIRST_OFFSET_MINUTES), + now.plusMinutes(FUTURE_FIRST_OFFSET_MINUTES + MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name()); + PlayClerkPkEntity second = buildPk(newPkId(), clerkId, newClerkId(), + now.plusMinutes(FUTURE_SECOND_OFFSET_MINUTES), + now.plusMinutes(FUTURE_SECOND_OFFSET_MINUTES + MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name()); + clerkPkService.save(second); + clerkPkService.save(first); + + mockMvc.perform(get("/wx/pk/clerk/schedule") + .param(PARAM_CLERK_ID, clerkId) + .param(PARAM_LIMIT, String.valueOf(SCHEDULE_LIMIT)) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(SCHEDULE_LIMIT)) + .andExpect(jsonPath("$.data[0].id").value(first.getId())) + .andExpect(jsonPath("$.data[1].id").value(second.getId())) + .andExpect(jsonPath("$.data[0].state").value(PkWxState.UPCOMING.getValue())); + } + + @Test + void clerkScheduleShouldApplyLimitDefaultWhenMissing() throws Exception { + LocalDateTime now = LocalDateTime.now(); + String clerkId = newClerkId(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + + clerkPkService.save(buildPk(newPkId(), clerkId, newClerkId(), + now.plusMinutes(FUTURE_FIRST_OFFSET_MINUTES), + now.plusMinutes(FUTURE_FIRST_OFFSET_MINUTES + MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name())); + clerkPkService.save(buildPk(newPkId(), clerkId, newClerkId(), + now.plusMinutes(FUTURE_SECOND_OFFSET_MINUTES), + now.plusMinutes(FUTURE_SECOND_OFFSET_MINUTES + MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name())); + clerkPkService.save(buildPk(newPkId(), clerkId, newClerkId(), + now.plusMinutes(FUTURE_THIRD_OFFSET_MINUTES), + now.plusMinutes(FUTURE_THIRD_OFFSET_MINUTES + MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name())); + + mockMvc.perform(get("/wx/pk/clerk/schedule") + .param(PARAM_CLERK_ID, clerkId) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(DEFAULT_LIMIT)); + } + + @Test + void clerkScheduleShouldClampLimitWhenZeroOrNegative() throws Exception { + LocalDateTime now = LocalDateTime.now(); + String clerkId = newClerkId(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + + clerkPkService.save(buildPk(newPkId(), clerkId, newClerkId(), + now.plusMinutes(FUTURE_FIRST_OFFSET_MINUTES), + now.plusMinutes(FUTURE_FIRST_OFFSET_MINUTES + MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name())); + + mockMvc.perform(get("/wx/pk/clerk/schedule") + .param(PARAM_CLERK_ID, clerkId) + .param(PARAM_LIMIT, String.valueOf(LIMIT_ZERO)) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)); + + mockMvc.perform(get("/wx/pk/clerk/schedule") + .param(PARAM_CLERK_ID, clerkId) + .param(PARAM_LIMIT, String.valueOf(LIMIT_NEGATIVE)) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)); + } + + @Test + void clerkScheduleShouldClampLimitWhenTooLarge() throws Exception { + LocalDateTime now = LocalDateTime.now(); + String clerkId = newClerkId(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + + clerkPkService.save(buildPk(newPkId(), clerkId, newClerkId(), + now.plusMinutes(FUTURE_FIRST_OFFSET_MINUTES), + now.plusMinutes(FUTURE_FIRST_OFFSET_MINUTES + MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name())); + + mockMvc.perform(get("/wx/pk/clerk/schedule") + .param(PARAM_CLERK_ID, clerkId) + .param(PARAM_LIMIT, String.valueOf(LIMIT_LARGE)) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)); + } + + @Test + void clerkScheduleShouldExcludePastOrNonToBeStarted() throws Exception { + LocalDateTime now = LocalDateTime.now(); + String clerkId = newClerkId(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + + clerkPkService.save(buildPk(newPkId(), clerkId, newClerkId(), + now.minusMinutes(PAST_OFFSET_MINUTES), + now.minusMinutes(PAST_OFFSET_MINUTES - MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name())); + clerkPkService.save(buildPk(newPkId(), clerkId, newClerkId(), + now.plusMinutes(FUTURE_FIRST_OFFSET_MINUTES), + now.plusMinutes(FUTURE_FIRST_OFFSET_MINUTES + MINUTES_AFTER_START), + ClerkPkEnum.IN_PROGRESS.name())); + PlayClerkPkEntity future = buildPk(newPkId(), clerkId, newClerkId(), + now.plusMinutes(FUTURE_SECOND_OFFSET_MINUTES), + now.plusMinutes(FUTURE_SECOND_OFFSET_MINUTES + MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name()); + clerkPkService.save(future); + + mockMvc.perform(get("/wx/pk/clerk/schedule") + .param(PARAM_CLERK_ID, clerkId) + .param(PARAM_LIMIT, String.valueOf(SCHEDULE_LIMIT)) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].id").value(future.getId())); + } + + @Test + void clerkScheduleShouldIsolateTenants() throws Exception { + LocalDateTime now = LocalDateTime.now(); + String clerkId = newClerkId(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + clerkPkService.save(buildPk(newPkId(), clerkId, newClerkId(), + now.plusMinutes(FUTURE_FIRST_OFFSET_MINUTES), + now.plusMinutes(FUTURE_FIRST_OFFSET_MINUTES + MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name())); + + String otherTenant = "tenant-other"; + PlayClerkPkEntity other = buildPk(newPkId(), clerkId, newClerkId(), + now.plusMinutes(FUTURE_SECOND_OFFSET_MINUTES), + now.plusMinutes(FUTURE_SECOND_OFFSET_MINUTES + MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name()); + other.setTenantId(otherTenant); + clerkPkService.save(other); + + mockMvc.perform(get("/wx/pk/clerk/schedule") + .param(PARAM_CLERK_ID, clerkId) + .param(PARAM_LIMIT, String.valueOf(SCHEDULE_LIMIT)) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)); + } + + @Test + void clerkScheduleShouldIgnoreNonParticipant() throws Exception { + LocalDateTime now = LocalDateTime.now(); + String clerkId = newClerkId(); + SecurityUtils.setTenantId(DEFAULT_TENANT); + + clerkPkService.save(buildPk(newPkId(), newClerkId(), newClerkId(), + now.plusMinutes(FUTURE_FIRST_OFFSET_MINUTES), + now.plusMinutes(FUTURE_FIRST_OFFSET_MINUTES + MINUTES_AFTER_START), + ClerkPkEnum.TO_BE_STARTED.name())); + + mockMvc.perform(get("/wx/pk/clerk/schedule") + .param(PARAM_CLERK_ID, clerkId) + .param(PARAM_LIMIT, String.valueOf(SCHEDULE_LIMIT)) + .header(USER_HEADER, DEFAULT_USER) + .header(TENANT_HEADER, DEFAULT_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(0)); + } + + private static PlayClerkPkEntity buildPk(String pkId, String clerkAId, String clerkBId, + LocalDateTime begin, LocalDateTime end, String status) { + PlayClerkPkEntity pk = new PlayClerkPkEntity(); + pk.setId(pkId); + pk.setTenantId(DEFAULT_TENANT); + pk.setClerkA(clerkAId); + pk.setClerkB(clerkBId); + pk.setPkBeginTime(Date.from(begin.atZone(ZoneId.systemDefault()).toInstant())); + pk.setPkEndTime(Date.from(end.atZone(ZoneId.systemDefault()).toInstant())); + pk.setStatus(status); + pk.setSettled(SETTLED_FALSE); + return pk; + } + + private static PlayClerkPkEntity buildFinishedPk(String pkId, String clerkAId, String clerkBId, + LocalDateTime begin, BigDecimal scoreA, BigDecimal scoreB) { + PlayClerkPkEntity pk = new PlayClerkPkEntity(); + pk.setId(pkId); + pk.setTenantId(DEFAULT_TENANT); + pk.setClerkA(clerkAId); + pk.setClerkB(clerkBId); + pk.setPkBeginTime(Date.from(begin.atZone(ZoneId.systemDefault()).toInstant())); + pk.setPkEndTime(Date.from(begin.plusMinutes(MINUTES_AFTER_START).atZone(ZoneId.systemDefault()).toInstant())); + pk.setStatus(ClerkPkEnum.FINISHED.name()); + pk.setClerkAScore(scoreA); + pk.setClerkBScore(scoreB); + pk.setSettled(SETTLED_TRUE); + return pk; + } + + private static PlayClerkUserInfoEntity buildClerk(String id, String nickname) { + PlayClerkUserInfoEntity clerk = new PlayClerkUserInfoEntity(); + clerk.setId(id); + clerk.setTenantId(DEFAULT_TENANT); + clerk.setOpenid(OPENID_PREFIX + id); + clerk.setNickname(nickname); + clerk.setDeleted(false); + return clerk; + } + + private static PlayCustomUserInfoEntity buildCustomUser(String id, String nickname) { + PlayCustomUserInfoEntity custom = new PlayCustomUserInfoEntity(); + custom.setId(id); + custom.setTenantId(DEFAULT_TENANT); + custom.setNickname(nickname); + custom.setDeleted(false); + return custom; + } + + private static PlayOrderInfoEntity buildOrder(String customerId, String clerkId, BigDecimal amount, + LocalDateTime endTime) { + PlayOrderInfoEntity order = new PlayOrderInfoEntity(); + order.setId(IdUtils.getUuid()); + order.setTenantId(DEFAULT_TENANT); + order.setOrderStatus(OrderConstant.OrderStatus.COMPLETED.getCode()); + order.setOrderType(OrderConstant.OrderType.NORMAL.getCode()); + order.setPlaceType(OrderConstant.PlaceType.SPECIFIED.getCode()); + order.setPurchaserBy(customerId); + order.setAcceptBy(clerkId); + order.setFinalAmount(amount); + order.setOrderEndTime(endTime); + order.setDeleted(false); + return order; + } + + private static String newClerkId() { + return CLERK_PREFIX + IdUtils.getUuid(); + } + + private static String newCustomId() { + return CUSTOM_PREFIX + IdUtils.getUuid(); + } + + private static String newPkId() { + return PK_PREFIX + IdUtils.getUuid(); + } + + private static JsonNode extractData(MvcResult result) throws Exception { + JsonNode root = OBJECT_MAPPER.readTree(result.getResponse().getContentAsString()); + return root.get("data"); + } +}