Jelajahi Sumber

refactor(game-score): 重构成绩服务实现优化排名计算逻辑

- 提取独立的 saveOrUpdateScoreOnly 方法用于仅更新成绩不重算排名
- 实现事务同步器确保排名计算在事务提交后异步执行
- 添加分布式锁防止并发排名计算冲突
- 简化成绩明细查询条件优化性能
- 调整代码格式和缩进提升可读性
- 优化删除赛事逻辑避免大量关联数据锁表问题
zhou 1 Minggu lalu
induk
melakukan
04e06ef429

+ 1 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/controller/app_client/ToClientController.java

@@ -133,6 +133,7 @@ public class ToClientController {
      * 成绩预览列表删除成绩 (接口10)
      */
     @Log(title = "app客户端删除预览成绩", businessType = BusinessType.DELETE)
+    @RepeatSubmit()
     @DeleteMapping("/scorePreviewRemove/{scoreId}")
     public R<Void> scorePreviewRemove(@PathVariable Long scoreId) {
         TenantHelper.ignore(() -> toClientService.removeScorePreview(scoreId));

+ 7 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/IGameScoreService.java

@@ -117,6 +117,13 @@ public interface IGameScoreService {
      */
     Boolean updateScoreAndRecalculate(GameScoreBo bo);
 
+    /**
+     * 更新成绩(不重算排名积分)
+     * @param bo 成绩信息
+     * @return 是否成功
+     */
+    Boolean saveOrUpdateScoreOnly(GameScoreBo bo);
+
     /**
      * 重新计算项目排名和积分
      * @param eventId 赛事ID

+ 7 - 0
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/app/IToClientService.java

@@ -74,6 +74,13 @@ public interface IToClientService {
      */
     void removeScorePreview(Long scoreId);
 
+    /**
+     * 异步重新计算项目排名和积分
+     * @param eventId 赛事ID
+     * @param projectId 项目ID
+     */
+    void asyncRecalculateRankings(Long eventId, Long projectId);
+
     /**
      * 辅助接口:根据字典类型查询字典数据
      */

+ 53 - 38
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/GameScoreServiceImpl.java

@@ -258,11 +258,11 @@ public class GameScoreServiceImpl implements IGameScoreService {
         // 1. 删除成绩明细
         // 优先根据 scoreId 删除,如果是团体项目则尝试根据 teamId + projectId 删除
         LambdaQueryWrapper<GameScoreDetail> detailLqw = Wrappers.lambdaQuery(GameScoreDetail.class)
-            .eq(GameScoreDetail::getProjectId, score.getProjectId());
+                .eq(GameScoreDetail::getProjectId, score.getProjectId());
 
         if (score.getAthleteId() != null) {
             detailLqw.and(w -> w.eq(GameScoreDetail::getScoreId, scoreId)
-                .eq(GameScoreDetail::getAthleteId, score.getAthleteId()));
+                    .eq(GameScoreDetail::getAthleteId, score.getAthleteId()));
         } else if (score.getTeamId() != null) {
             detailLqw.or().eq(GameScoreDetail::getTeamId, score.getTeamId());
         } else {
@@ -533,50 +533,59 @@ public class GameScoreServiceImpl implements IGameScoreService {
     @Override
     @Transactional(rollbackFor = Exception.class)
     public Boolean updateScoreAndRecalculate(GameScoreBo bo) {
-        log.info("开始处理成绩更新,scoreId: {}, eventId: {}, projectId: {}",
-                bo.getScoreId(), bo.getEventId(), bo.getProjectId());
+        // 判断是否是“纯排名计算”请求(不带具体的录入对象)
+        boolean isOnlyRecalculate = bo.getAthleteId() == null && bo.getTeamId() == null
+                && (bo.getScoreId() == null || bo.getScoreId() == 0);
+
+        Boolean result = true;
+        if (!isOnlyRecalculate) {
+            result = saveOrUpdateScoreOnly(bo);
+        }
 
+        if (result) {
+            // 重新计算排名和积分
+            recalculateRankingsAndPoints(bo.getEventId(), bo.getProjectId());
+        }
+
+        return result;
+    }
+
+    /**
+     * 更新成绩(不重算排名积分)
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean saveOrUpdateScoreOnly(GameScoreBo bo) {
         // 1. 获取项目配置信息
         GameEventProjectVo project = gameEventProjectService.queryById(bo.getProjectId());
         if (project == null)
             return false;
 
-        // 判断是否是“纯排名计算”请求(不带具体的录入对象)
-        boolean isOnlyRecalculate = bo.getAthleteId() == null && bo.getTeamId() == null
-                && (bo.getScoreId() == null || bo.getScoreId() == 0);
-
         Boolean result = true;
 
-        if (!isOnlyRecalculate) {
-            // 自动分析汇总成绩和失误
-            if (bo.getDetails() != null && !bo.getDetails().isEmpty()) {
-                calculateAndSetAggregatePerformance(bo, project);
-            }
+        // 自动分析汇总成绩和失误
+        if (bo.getDetails() != null && !bo.getDetails().isEmpty()) {
+            calculateAndSetAggregatePerformance(bo, project);
+        }
 
-            if (ProjectClassification.TEAM.getValue().equals(project.getClassification())) {
-                // 团体项目:为队伍中的所有运动员创建或更新成绩记录
-                result = handleTeamScoreUpdate(bo);
+        if (ProjectClassification.TEAM.getValue().equals(project.getClassification())) {
+            // 团体项目:为队伍中的所有运动员创建或更新成绩记录
+            result = handleTeamScoreUpdate(bo);
+        } else {
+            // 个人项目:直接更新或插入
+            if (bo.getScoreId() != null && bo.getScoreId() > 0) {
+                result = updateByBo(bo);
             } else {
-                // 个人项目:直接更新或插入
-                if (bo.getScoreId() != null && bo.getScoreId() > 0) {
-                    result = updateByBo(bo);
-                } else {
-                    result = insertByBo(bo);
-                }
-            }
-
-            if (result) {
-                // 个人项目:如果传了明细,则保存 (团体项目已在 handleTeamScoreUpdate 中处理过)
-                if (ProjectClassification.SINGLE.getValue().equals(project.getClassification())
-                        && bo.getDetails() != null && !bo.getDetails().isEmpty()) {
-                    saveScoreDetails(bo.getScoreId(), bo.getProjectId(), bo.getAthleteId(), null, bo.getDetails());
-                }
+                result = insertByBo(bo);
             }
         }
 
         if (result) {
-            // 重新计算排名和积分
-            recalculateRankingsAndPoints(bo.getEventId(), bo.getProjectId());
+            // 个人项目:如果传了明细,则保存 (团体项目已在 handleTeamScoreUpdate 中处理过)
+            if (ProjectClassification.SINGLE.getValue().equals(project.getClassification())
+                    && bo.getDetails() != null && !bo.getDetails().isEmpty()) {
+                saveScoreDetails(bo.getScoreId(), bo.getProjectId(), bo.getAthleteId(), null, bo.getDetails());
+            }
         }
 
         return result;
@@ -873,8 +882,10 @@ public class GameScoreServiceImpl implements IGameScoreService {
             // 升序排列(通常是计时类):0 应该被视为最差成绩,排在最后
             boolean aIsZero = perfA.compareTo(BigDecimal.ZERO) == 0;
             boolean bIsZero = perfB.compareTo(BigDecimal.ZERO) == 0;
-            if (aIsZero && !bIsZero) return 1;
-            if (!aIsZero && bIsZero) return -1;
+            if (aIsZero && !bIsZero)
+                return 1;
+            if (!aIsZero && bIsZero)
+                return -1;
             res = perfA.compareTo(perfB);
         } else {
             res = perfB.compareTo(perfA); // 降序
@@ -902,13 +913,17 @@ public class GameScoreServiceImpl implements IGameScoreService {
         if (isTiming) {
             BigDecimal timeA = a.getCompleteTime();
             BigDecimal timeB = b.getCompleteTime();
-            if (timeA == null) timeA = BigDecimal.ZERO;
-            if (timeB == null) timeB = BigDecimal.ZERO;
+            if (timeA == null)
+                timeA = BigDecimal.ZERO;
+            if (timeB == null)
+                timeB = BigDecimal.ZERO;
 
             boolean aIsZero = timeA.compareTo(BigDecimal.ZERO) == 0;
             boolean bIsZero = timeB.compareTo(BigDecimal.ZERO) == 0;
-            if (aIsZero && !bIsZero) return 1;
-            if (!aIsZero && bIsZero) return -1;
+            if (aIsZero && !bIsZero)
+                return 1;
+            if (!aIsZero && bIsZero)
+                return -1;
 
             res = timeA.compareTo(timeB);
             if (res != 0)

+ 81 - 68
ruoyi-modules/ruoyi-game-event/src/main/java/org/dromara/system/service/impl/app/ToClientServiceImpl.java

@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.extension.toolkit.Db;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.redis.utils.RedisUtils;
 import org.dromara.system.domain.*;
 import org.dromara.system.domain.bo.*;
 import org.dromara.system.domain.constant.ProjectClassification;
@@ -22,13 +23,18 @@ import org.dromara.system.service.IGameScoreService;
 import org.dromara.system.service.IGameRankGroupService;
 import org.dromara.system.service.ISysDictTypeService;
 import org.dromara.system.service.app.IToClientService;
+import org.redisson.api.RLock;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import org.dromara.common.core.utils.SpringUtils;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
 
 import java.math.BigDecimal;
 import java.math.RoundingMode;
 import java.util.*;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 
 @Slf4j
@@ -64,11 +70,10 @@ public class ToClientServiceImpl implements IToClientService {
 
         /** 登录成功后,返回裁判相关信息 */
         List<Long> projectIds = JSONUtil.toList(referee.getProjectList(), Long.class);
-        if (CollectionUtils.isNotEmpty(projectIds)){
+        if (CollectionUtils.isNotEmpty(projectIds)) {
             List<GameEventProject> list = Db.list(Wrappers.lambdaQuery(GameEventProject.class)
-                .select(GameEventProject::getProjectName)
-                .in(GameEventProject::getProjectId, projectIds)
-            );
+                    .select(GameEventProject::getProjectName)
+                    .in(GameEventProject::getProjectId, projectIds));
             List<String> names = list.stream().map(GameEventProject::getProjectName).toList();
             vo.getRefereeInfo().setProjectNames(names);
         }
@@ -88,8 +93,7 @@ public class ToClientServiceImpl implements IToClientService {
         // 1. 获取或创建赛事
         GameEvent event = gameEventMapper.selectOne(Wrappers.lambdaQuery(GameEvent.class)
                 .eq(bo.getId() != null, GameEvent::getEventId, bo.getId())
-                .eq(GameEvent::getEventCode, bo.getBianhao())
-        );
+                .eq(GameEvent::getEventCode, bo.getBianhao()));
         if (event == null) {
             event = new GameEvent();
             event.setEventCode(bo.getBianhao());
@@ -180,40 +184,10 @@ public class ToClientServiceImpl implements IToClientService {
         if (event == null) {
             throw new RuntimeException("赛事不存在");
         }
-        // 异步执行删除操作
-        asyncRemoveEventData(eventId);
-    }
-
-    /**
-     * 异步删除赛事关联数据
-     */
-    @Async
-    @Transactional(rollbackFor = Exception.class)
-    public void asyncRemoveEventData(Long eventId) {
-        log.info("开始异步删除赛事数据,eventId: {}", eventId);
-        long startTime = System.currentTimeMillis();
-        try {
-            // 删除关联的组别
-            Db.remove(Wrappers.lambdaQuery(GameRankGroup.class)
-                    .eq(GameRankGroup::getEventId, eventId));
-            // 删除关联的配置信息
-            Db.remove(Wrappers.lambdaQuery(GameEventConfig.class)
-                    .eq(GameEventConfig::getEventId, eventId));
-            // 删除关联的成绩信息
-            Db.remove(Wrappers.lambdaQuery(GameScore.class)
-                    .eq(GameScore::getEventId, eventId));
-            // 删除关联的项目
-            int projectCount = projectMapper.delete(Wrappers.lambdaQuery(GameEventProject.class)
-                    .eq(GameEventProject::getEventId, eventId));
-            log.info("删除项目完成,eventId: {}, 删除数量: {}", eventId, projectCount);
-            // 删除赛事
-            gameEventMapper.deleteById(eventId);
-            long endTime = System.currentTimeMillis();
-            log.info("赛事数据删除完成,eventId: {}, 耗时: {}ms", eventId, (endTime - startTime));
-        } catch (Exception e) {
-            log.error("异步删除赛事数据失败,eventId: {}", eventId, e);
-            throw new RuntimeException("删除赛事数据失败: " + e.getMessage());
-        }
+        // 仅删除主表赛事。对于关联的从表数据(如成绩、明细等),由于业务查询均以赛事存在为前提,
+        // 赛事删除后关联数据已天然不可见。直接删除主表即可规避大量从表逻辑删除引发的锁表和性能瓶颈。
+        gameEventMapper.deleteById(eventId);
+        log.info("赛事主表删除成功,eventId: {}", eventId);
     }
 
     /**
@@ -222,11 +196,10 @@ public class ToClientServiceImpl implements IToClientService {
     @Override
     public List<GameAppEvent> getEventList(Long refereeId) {
         List<GameEvent> res = gameEventMapper.selectList(Wrappers.lambdaQuery(GameEvent.class)
-            .orderByDesc(GameEvent::getCreateTime)
+                .orderByDesc(GameEvent::getCreateTime)
                 .apply("event_id in (select event_id from game_referee where referee_id = {0})", refereeId)
-            .select(GameEvent::getEventId, GameEvent::getEventCode, GameEvent::getEventName,
-                GameEvent::getCreateTime)
-        );
+                .select(GameEvent::getEventId, GameEvent::getEventCode, GameEvent::getEventName,
+                        GameEvent::getCreateTime));
         return res.isEmpty() ? new ArrayList<>() : res.stream().map(e -> {
             GameAppEvent appEvent = BeanUtil.copyProperties(e, GameAppEvent.class);
             appEvent.setId(e.getEventId());
@@ -242,25 +215,23 @@ public class ToClientServiceImpl implements IToClientService {
     @Override
     public List<GameAppProject> getProjectList(Long eventId, Long refereeId) {
         String list = Db.getObj(Wrappers.lambdaQuery(GameReferee.class)
-            .eq(GameReferee::getRefereeId, refereeId)
-            .select(GameReferee::getProjectList), GameReferee::getProjectList
-        );
+                .eq(GameReferee::getRefereeId, refereeId)
+                .select(GameReferee::getProjectList), GameReferee::getProjectList);
         List<Long> projectIds = JSONUtil.toList(list, Long.class);
-        if (projectIds.isEmpty()){
+        if (projectIds.isEmpty()) {
             return new ArrayList<>();
         }
         List<SysDictDataVo> dictDataVos = dictService.selectDictDataByType("game_project_type");
         HashMap<String, String> typeMaps = new HashMap<>();
-        if (!dictDataVos.isEmpty()){
+        if (!dictDataVos.isEmpty()) {
             dictDataVos.forEach(d -> typeMaps.put(d.getDictValue(), d.getDictLabel()));
         }
         List<GameEventProject> res = projectMapper.selectList(Wrappers.lambdaQuery(GameEventProject.class)
-            .eq(GameEventProject::getEventId, eventId)
+                .eq(GameEventProject::getEventId, eventId)
                 .in(GameEventProject::getProjectId, projectIds)
-            .select(GameEventProject::getProjectId, GameEventProject::getProjectType,
-                GameEventProject::getClassification, GameEventProject::getProjectName,
-                GameEventProject::getStatus)
-        );
+                .select(GameEventProject::getProjectId, GameEventProject::getProjectType,
+                        GameEventProject::getClassification, GameEventProject::getProjectName,
+                        GameEventProject::getStatus));
         return res.isEmpty() ? new ArrayList<>() : res.stream().map(p -> {
             GameAppProject appProject = BeanUtil.copyProperties(p, GameAppProject.class);
             appProject.setId(p.getEventId());
@@ -290,8 +261,7 @@ public class ToClientServiceImpl implements IToClientService {
         // 批量查询字典数据,避免多次调用 Redis/MySQL 交互
         Map<String, List<SysDictDataVo>> dictMap = dictService.selectDictDataByTypes(List.of(
                 "game_project_type", "game_score_type", "game_order_type",
-                "sys_group_sex", "game_stage", "game_round"
-        ));
+                "sys_group_sex", "game_stage", "game_round"));
 
         vo.setType(getDictLabelFromMap(dictMap, "game_project_type", vo.getType()));
         vo.setChengjiType(getDictLabelFromMap(dictMap, "game_score_type", vo.getChengjiType()));
@@ -349,7 +319,7 @@ public class ToClientServiceImpl implements IToClientService {
                 if (StringUtils.isNotBlank(item.getScore()) && vo.getChengjiType().contains("计时")) {
                     item.setScore(convertDecimalToTimeScore(item.getScore(), vo.getGuize()));
                 }
-                if (StringUtils.isNotBlank(item.getYongshi())){
+                if (StringUtils.isNotBlank(item.getYongshi())) {
                     item.setYongshi(convertDecimalToTimeScore(item.getYongshi(), "0"));
                 }
                 // 格式化明细成绩
@@ -462,12 +432,13 @@ public class ToClientServiceImpl implements IToClientService {
 
         // 校验成绩是否为空
         for (ScoreSubmitItemBo item : items) {
-            String displayName = StringUtils.isNotBlank(item.getName()) ? item.getName() :
-                    (StringUtils.isNotBlank(item.getNum()) ? item.getNum() : "未知选手");
+            String displayName = StringUtils.isNotBlank(item.getName()) ? item.getName()
+                    : (StringUtils.isNotBlank(item.getNum()) ? item.getNum() : "未知选手");
             if (CollectionUtils.isNotEmpty(item.getChengji())) {
                 for (ScoreSubmitDetailBo detail : item.getChengji()) {
                     if (detail.getNum() == null) {
-                        String attemptStr = detail.getAttemptIndex() != null ? "第 " + detail.getAttemptIndex() + " 轮" : "明细";
+                        String attemptStr = detail.getAttemptIndex() != null ? "第 " + detail.getAttemptIndex() + " 轮"
+                                : "明细";
                         throw new RuntimeException("提交失败:" + displayName + " 的" + attemptStr + "成绩不能为空");
                     }
                 }
@@ -599,7 +570,22 @@ public class ToClientServiceImpl implements IToClientService {
                 }
             }
 
-            gameScoreService.updateScoreAndRecalculate(scoreBo);
+            // 仅写入数据库,暂不重新计算排名
+            gameScoreService.saveOrUpdateScoreOnly(scoreBo);
+        }
+
+        // 5. 注册事务同步器:在当前事务成功提交后,触发异步单次排名计算
+        if (TransactionSynchronizationManager.isActualTransactionActive()) {
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                @Override
+                public void afterCommit() {
+                    // 事务提交后,调用异步排名计算
+                    SpringUtils.getBean(IToClientService.class).asyncRecalculateRankings(eventId, projectId);
+                }
+            });
+        } else {
+            // 如果当前没有活跃事务,则直接同步计算(预防非事务环境)
+            gameScoreService.recalculateRankingsAndPoints(eventId, projectId);
         }
     }
 
@@ -642,20 +628,22 @@ public class ToClientServiceImpl implements IToClientService {
 
                 list.forEach(item -> item.setChengji(detailMap.get(item.getTeamId())));
             } else {
-                // 个人项目:按 athleteId 分组
+                // 个人项目:优先按 athleteId (即 detailVo.id) 分组,同时生成按 scoreId 的分组以便匹配
                 detailMap = allDetails.stream()
                         .filter(d -> d.getAthleteId() != null)
                         .map(this::convertToDetailVo)
                         .collect(Collectors.groupingBy(ScoreSheetDetailVo::getId));
 
+                Map<Long, List<ScoreSheetDetailVo>> scoreDetailMap = allDetails.stream()
+                        .filter(d -> d.getScoreId() != null)
+                        .map(this::convertToDetailVo)
+                        .collect(Collectors.groupingBy(ScoreSheetDetailVo::getScoreId));
+
                 list.forEach(item -> {
                     List<ScoreSheetDetailVo> details = detailMap.get(item.getId());
-                    if (details == null && item.getScoreId() != null) {
-                        // 兜底:按 scoreId 匹配
-                        details = allDetails.stream()
-                                .filter(d -> Objects.equals(d.getScoreId(), item.getScoreId()))
-                                .map(this::convertToDetailVo)
-                                .toList();
+                    if (CollectionUtils.isEmpty(details) && item.getScoreId() != null) {
+                        // 兜底:处理部分仅关联 scoreId 的旧数据
+                        details = scoreDetailMap.get(item.getScoreId());
                     }
                     item.setChengji(details);
                 });
@@ -830,4 +818,29 @@ public class ToClientServiceImpl implements IToClientService {
     public List<SysDictDataVo> selectDictDataByType(String dictType) {
         return dictService.selectDictDataByType(dictType);
     }
+
+    /**
+     * 异步重新计算项目排名和积分
+     */
+    @Async
+    @Override
+    public void asyncRecalculateRankings(Long eventId, Long projectId) {
+        String lockKey = "project:rank:lock:" + projectId;
+        RLock lock = RedisUtils.getClient().getLock(lockKey);
+        try {
+            // 尝试获取锁,最多等待 10 秒,上锁后 30 秒自动解锁(防死锁)
+            if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
+                gameScoreService.recalculateRankingsAndPoints(eventId, projectId);
+            } else {
+                log.warn("未能获取项目 {} 的排名计算锁,跳过或放弃本次计算", projectId);
+            }
+        } catch (InterruptedException e) {
+            log.error("获取项目 {} 排名锁被中断", projectId, e);
+            Thread.currentThread().interrupt();
+        } finally {
+            if (lock.isHeldByCurrentThread()) {
+                lock.unlock();
+            }
+        }
+    }
 }