From e9ae6aeb7e1edb1588a70bfdacf7b7e14dc8928f Mon Sep 17 00:00:00 2001 From: zhonghua Date: Thu, 9 Apr 2026 17:13:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=95=B0=E6=8D=AE=E7=BB=9F=E8=AE=A1=20?= =?UTF-8?q?API=20=E4=B8=8E=E7=A7=9F=E6=88=B7=E8=BF=87=E6=BB=A4=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=EF=BC=8C=E8=A1=A5=E5=85=85=20timeRange=20=E4=B8=8E?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- .../controller/AnalyticsController.java | 9 +- .../biz/review/mapper/AnalyticsMapper.java | 61 +++ .../biz/review/service/AnalyticsService.java | 458 +++++++++++++----- .../resources/mapper/biz/AnalyticsMapper.xml | 220 +++++++++ docs/design/README.md | 2 +- .../org-admin/data-analytics-dashboard.md | 29 +- frontend/src/api/analytics.ts | 2 +- frontend/src/views/analytics/Overview.vue | 241 +++++++-- frontend/src/views/analytics/Review.vue | 245 ++++++++-- 9 files changed, 1066 insertions(+), 201 deletions(-) create mode 100644 backend-java/src/main/java/com/competition/modules/biz/review/mapper/AnalyticsMapper.java create mode 100644 backend-java/src/main/resources/mapper/biz/AnalyticsMapper.xml diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/controller/AnalyticsController.java b/backend-java/src/main/java/com/competition/modules/biz/review/controller/AnalyticsController.java index 4281e90..e58f5ba 100644 --- a/backend-java/src/main/java/com/competition/modules/biz/review/controller/AnalyticsController.java +++ b/backend-java/src/main/java/com/competition/modules/biz/review/controller/AnalyticsController.java @@ -3,6 +3,7 @@ package com.competition.modules.biz.review.controller; import com.competition.common.result.Result; import com.competition.common.util.SecurityUtil; import com.competition.modules.biz.review.service.AnalyticsService; +import com.competition.modules.sys.service.ISysTenantService; import com.competition.security.annotation.RequirePermission; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -18,6 +19,7 @@ import java.util.Map; public class AnalyticsController { private final AnalyticsService analyticsService; + private final ISysTenantService tenantService; @GetMapping("/overview") @RequirePermission("contest:read") @@ -26,15 +28,18 @@ public class AnalyticsController { @RequestParam(required = false) String timeRange, @RequestParam(required = false) Long contestId) { Long tenantId = SecurityUtil.getCurrentTenantId(); - return Result.success(analyticsService.getOverview(tenantId, contestId)); + boolean isSuperTenant = tenantService.isSuperTenant(tenantId); + return Result.success(analyticsService.getOverview(tenantId, isSuperTenant, contestId, timeRange)); } @GetMapping("/review") @RequirePermission("contest:read") @Operation(summary = "评审分析") public Result> getReviewAnalysis( + @RequestParam(required = false) String timeRange, @RequestParam(required = false) Long contestId) { Long tenantId = SecurityUtil.getCurrentTenantId(); - return Result.success(analyticsService.getReviewAnalysis(tenantId, contestId)); + boolean isSuperTenant = tenantService.isSuperTenant(tenantId); + return Result.success(analyticsService.getReviewAnalysis(tenantId, isSuperTenant, contestId, timeRange)); } } diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/mapper/AnalyticsMapper.java b/backend-java/src/main/java/com/competition/modules/biz/review/mapper/AnalyticsMapper.java new file mode 100644 index 0000000..0bd476f --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/biz/review/mapper/AnalyticsMapper.java @@ -0,0 +1,61 @@ +package com.competition.modules.biz.review.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * 数据统计分析聚合查询(复杂 SQL) + */ +@Mapper +public interface AnalyticsMapper { + + /** 作品从提交到首次评分的平均天数 */ + BigDecimal selectAvgReviewCycleDays(@Param("contestIds") List contestIds, + @Param("tenantId") Long tenantId, + @Param("isSuperTenant") boolean isSuperTenant); + + /** 最近 30 天内评分记录条数(用于日均评审量) */ + long selectScoreCountLast30Days(@Param("contestIds") List contestIds, + @Param("tenantId") Long tenantId, + @Param("isSuperTenant") boolean isSuperTenant); + + /** 待评审积压:分配状态为 assigned */ + long selectPendingAssignments(@Param("contestIds") List contestIds); + + /** 每件作品多评委分数标准差,再取平均 */ + BigDecimal selectAvgStddevAcrossWorks(@Param("contestIds") List contestIds, + @Param("tenantId") Long tenantId, + @Param("isSuperTenant") boolean isSuperTenant); + + /** 按月份统计报名量(最近约 6 个月窗口由 SQL 限定) */ + List> selectMonthlyRegistrationCounts(@Param("contestIds") List contestIds, + @Param("tenantId") Long tenantId, + @Param("isSuperTenant") boolean isSuperTenant); + + /** 按月份统计作品提交量 */ + List> selectMonthlyWorkCounts(@Param("contestIds") List contestIds, + @Param("tenantId") Long tenantId, + @Param("isSuperTenant") boolean isSuperTenant); + + /** 奖项分布:按奖项聚合 */ + List> selectAwardDistribution(@Param("contestIds") List contestIds, + @Param("tenantId") Long tenantId, + @Param("isSuperTenant") boolean isSuperTenant); + + /** 评委工作量聚合 */ + List> selectJudgeWorkload(@Param("contestIds") List contestIds, + @Param("tenantId") Long tenantId, + @Param("isSuperTenant") boolean isSuperTenant); + + /** 活动对比:单行指标(单活动) */ + Map selectContestComparisonRow(@Param("contestId") Long contestId, + @Param("tenantId") Long tenantId, + @Param("isSuperTenant") boolean isSuperTenant, + @Param("rangeStart") LocalDateTime rangeStart, + @Param("rangeEnd") LocalDateTime rangeEnd); +} diff --git a/backend-java/src/main/java/com/competition/modules/biz/review/service/AnalyticsService.java b/backend-java/src/main/java/com/competition/modules/biz/review/service/AnalyticsService.java index f90cc5f..e79faa6 100644 --- a/backend-java/src/main/java/com/competition/modules/biz/review/service/AnalyticsService.java +++ b/backend-java/src/main/java/com/competition/modules/biz/review/service/AnalyticsService.java @@ -3,24 +3,29 @@ package com.competition.modules.biz.review.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.competition.common.enums.PublishStatus; import com.competition.common.enums.RegistrationStatus; +import com.competition.common.enums.WorkStatus; import com.competition.modules.biz.contest.entity.BizContest; import com.competition.modules.biz.contest.entity.BizContestRegistration; import com.competition.modules.biz.contest.entity.BizContestWork; import com.competition.modules.biz.contest.mapper.ContestMapper; import com.competition.modules.biz.contest.mapper.ContestRegistrationMapper; import com.competition.modules.biz.contest.mapper.ContestWorkMapper; -import com.competition.modules.biz.review.entity.BizContestJudge; -import com.competition.modules.biz.review.entity.BizContestWorkJudgeAssignment; -import com.competition.modules.biz.review.entity.BizContestWorkScore; -import com.competition.modules.biz.review.mapper.ContestJudgeMapper; -import com.competition.modules.biz.review.mapper.ContestWorkJudgeAssignmentMapper; -import com.competition.modules.biz.review.mapper.ContestWorkScoreMapper; +import com.competition.modules.biz.review.mapper.AnalyticsMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.YearMonth; import java.util.*; import java.util.stream.Collectors; +/** + * 租户端数据统计(运营概览 / 评审分析) + */ @Service @RequiredArgsConstructor public class AnalyticsService { @@ -28,45 +33,60 @@ public class AnalyticsService { private final ContestMapper contestMapper; private final ContestRegistrationMapper contestRegistrationMapper; private final ContestWorkMapper contestWorkMapper; - private final ContestWorkScoreMapper contestWorkScoreMapper; - private final ContestWorkJudgeAssignmentMapper contestWorkJudgeAssignmentMapper; - private final ContestJudgeMapper contestJudgeMapper; + private final AnalyticsMapper analyticsMapper; /** - * 数据概览 + * 运营概览 + * + * @param timeRange month / quarter / year / all(空或 all 表示不限时间) */ - public Map getOverview(Long tenantId, Long contestId) { - Map result = new HashMap<>(); + public Map getOverview(Long tenantId, boolean isSuperTenant, Long contestId, String timeRange) { + List contestIds = resolveVisibleContestIds(tenantId, isSuperTenant, contestId); + LocalDateTime[] range = resolveTimeRange(timeRange); + + Map result = new LinkedHashMap<>(); + if (contestIds.isEmpty()) { + result.put("summary", emptySummary()); + result.put("funnel", emptyFunnel()); + result.put("monthlyTrend", Collections.emptyList()); + result.put("contestComparison", Collections.emptyList()); + return result; + } // --- summary --- - long totalContests = contestMapper.selectCount( - new LambdaQueryWrapper() - .eq(tenantId != null, BizContest::getContestState, PublishStatus.PUBLISHED.getValue())); + long totalContests = contestIds.size(); - long totalRegistrations = contestRegistrationMapper.selectCount( - new LambdaQueryWrapper() - .eq(contestId != null, BizContestRegistration::getContestId, contestId)); + LambdaQueryWrapper regBase = new LambdaQueryWrapper() + .in(BizContestRegistration::getContestId, contestIds); + if (!isSuperTenant && tenantId != null) { + regBase.eq(BizContestRegistration::getTenantId, tenantId); + } + applyRegistrationTimeRange(regBase, range); - long passedRegistrations = contestRegistrationMapper.selectCount( - new LambdaQueryWrapper() - .eq(contestId != null, BizContestRegistration::getContestId, contestId) - .eq(BizContestRegistration::getRegistrationState, RegistrationStatus.PASSED.getValue())); + long totalRegistrations = contestRegistrationMapper.selectCount(regBase); - long totalWorks = contestWorkMapper.selectCount( - new LambdaQueryWrapper() - .eq(contestId != null, BizContestWork::getContestId, contestId) - .eq(BizContestWork::getIsLatest, true)); + LambdaQueryWrapper regPassed = new LambdaQueryWrapper() + .in(BizContestRegistration::getContestId, contestIds) + .eq(BizContestRegistration::getRegistrationState, RegistrationStatus.PASSED.getValue()); + if (!isSuperTenant && tenantId != null) { + regPassed.eq(BizContestRegistration::getTenantId, tenantId); + } + applyRegistrationTimeRange(regPassed, range); + long passedRegistrations = contestRegistrationMapper.selectCount(regPassed); - long reviewedWorks = contestWorkScoreMapper.selectCount( - new LambdaQueryWrapper() - .eq(contestId != null, BizContestWorkScore::getContestId, contestId)); + LambdaQueryWrapper workBase = workBaseWrapper(contestIds, tenantId, isSuperTenant); + applyWorkSubmitTimeRange(workBase, range); + long totalWorks = contestWorkMapper.selectCount(workBase); - long awardedWorks = contestWorkMapper.selectCount( - new LambdaQueryWrapper() - .eq(contestId != null, BizContestWork::getContestId, contestId) - .eq(BizContestWork::getIsLatest, true) - .isNotNull(BizContestWork::getAwardLevel) - .ne(BizContestWork::getAwardLevel, "none")); + LambdaQueryWrapper workReviewed = workBaseWrapper(contestIds, tenantId, isSuperTenant); + workReviewed.in(BizContestWork::getStatus, WorkStatus.ACCEPTED.getValue(), WorkStatus.AWARDED.getValue()); + applyWorkSubmitTimeRange(workReviewed, range); + long reviewedWorks = contestWorkMapper.selectCount(workReviewed); + + LambdaQueryWrapper workAwarded = workBaseWrapper(contestIds, tenantId, isSuperTenant); + workAwarded.isNotNull(BizContestWork::getAwardLevel).ne(BizContestWork::getAwardLevel, "none"); + applyWorkSubmitTimeRange(workAwarded, range); + long awardedWorks = contestWorkMapper.selectCount(workAwarded); Map summary = new LinkedHashMap<>(); summary.put("totalContests", totalContests); @@ -77,97 +97,319 @@ public class AnalyticsService { summary.put("awardedWorks", awardedWorks); result.put("summary", summary); - // --- funnel --- - List> funnel = new ArrayList<>(); - funnel.add(Map.of("stage", "报名", "count", totalRegistrations)); - funnel.add(Map.of("stage", "审核通过", "count", passedRegistrations)); - funnel.add(Map.of("stage", "提交作品", "count", totalWorks)); - funnel.add(Map.of("stage", "已评审", "count", reviewedWorks)); - funnel.add(Map.of("stage", "获奖", "count", awardedWorks)); + // --- funnel(与 summary 同一口径)--- + Map funnel = new LinkedHashMap<>(); + funnel.put("registered", totalRegistrations); + funnel.put("passed", passedRegistrations); + funnel.put("submitted", totalWorks); + funnel.put("reviewed", reviewedWorks); + funnel.put("awarded", awardedWorks); result.put("funnel", funnel); + // --- 月度趋势(最近 6 个自然月窗口由 SQL 限定)--- + result.put("monthlyTrend", buildMonthlyTrend(contestIds, tenantId, isSuperTenant)); + + // --- 活动对比 --- + result.put("contestComparison", buildContestComparison(contestIds, tenantId, isSuperTenant, range)); + return result; } /** * 评审分析 */ - public Map getReviewAnalysis(Long tenantId, Long contestId) { - Map result = new HashMap<>(); + public Map getReviewAnalysis(Long tenantId, boolean isSuperTenant, Long contestId, String timeRange) { + List contestIds = resolveVisibleContestIds(tenantId, isSuperTenant, contestId); + Map result = new LinkedHashMap<>(); - // --- efficiency --- - long totalAssignments = contestWorkJudgeAssignmentMapper.selectCount( - new LambdaQueryWrapper() - .eq(contestId != null, BizContestWorkJudgeAssignment::getContestId, contestId)); + if (contestIds.isEmpty()) { + result.put("efficiency", emptyEfficiency()); + result.put("judgeWorkload", Collections.emptyList()); + result.put("awardDistribution", Collections.emptyList()); + return result; + } - long completedAssignments = contestWorkJudgeAssignmentMapper.selectCount( - new LambdaQueryWrapper() - .eq(contestId != null, BizContestWorkJudgeAssignment::getContestId, contestId) - .eq(BizContestWorkJudgeAssignment::getStatus, "completed")); - - long totalScores = contestWorkScoreMapper.selectCount( - new LambdaQueryWrapper() - .eq(contestId != null, BizContestWorkScore::getContestId, contestId)); + BigDecimal avgDays = analyticsMapper.selectAvgReviewCycleDays(contestIds, tenantId, isSuperTenant); + long scores30 = analyticsMapper.selectScoreCountLast30Days(contestIds, tenantId, isSuperTenant); + long pending = analyticsMapper.selectPendingAssignments(contestIds); + BigDecimal avgStd = analyticsMapper.selectAvgStddevAcrossWorks(contestIds, tenantId, isSuperTenant); Map efficiency = new LinkedHashMap<>(); - efficiency.put("totalAssignments", totalAssignments); - efficiency.put("completedAssignments", completedAssignments); - efficiency.put("completionRate", totalAssignments > 0 - ? Math.round(completedAssignments * 10000.0 / totalAssignments) / 100.0 - : 0); - efficiency.put("totalScores", totalScores); + efficiency.put("avgReviewDays", avgDays == null ? 0.0 : round2(avgDays.doubleValue())); + efficiency.put("dailyReviewCount", Math.round(scores30 / 30.0 * 100.0) / 100.0); + efficiency.put("pendingAssignments", pending); + efficiency.put("avgScoreStddev", avgStd == null ? 0.0 : round2(avgStd.doubleValue())); + result.put("efficiency", efficiency); - // --- judgeWorkload --- - List judges = contestJudgeMapper.selectList( - new LambdaQueryWrapper() - .eq(contestId != null, BizContestJudge::getContestId, contestId)); - - List> judgeWorkload = judges.stream().map(judge -> { - long assigned = contestWorkJudgeAssignmentMapper.selectCount( - new LambdaQueryWrapper() - .eq(BizContestWorkJudgeAssignment::getJudgeId, judge.getJudgeId()) - .eq(contestId != null, BizContestWorkJudgeAssignment::getContestId, contestId)); - long completed = contestWorkJudgeAssignmentMapper.selectCount( - new LambdaQueryWrapper() - .eq(BizContestWorkJudgeAssignment::getJudgeId, judge.getJudgeId()) - .eq(contestId != null, BizContestWorkJudgeAssignment::getContestId, contestId) - .eq(BizContestWorkJudgeAssignment::getStatus, "completed")); + List> judgeRows = analyticsMapper.selectJudgeWorkload(contestIds, tenantId, isSuperTenant); + List> judgeWorkload = new ArrayList<>(); + for (Map row : judgeRows) { Map item = new LinkedHashMap<>(); - item.put("judgeId", judge.getJudgeId()); - item.put("specialty", judge.getSpecialty()); - item.put("assigned", assigned); - item.put("completed", completed); - item.put("completionRate", assigned > 0 - ? Math.round(completed * 10000.0 / assigned) / 100.0 - : 0); - return item; - }).collect(Collectors.toList()); + Long judgeId = toLong(row.get("judgeId")); + item.put("judgeId", judgeId != null ? judgeId : 0L); + item.put("judgeName", Objects.toString(row.get("judgeName"), "评委")); + item.put("contestCount", toLong(row.get("contestCount"))); + long assigned = toLong(row.get("assignedCount")); + long scored = toLong(row.get("scoredCount")); + item.put("assignedCount", assigned); + item.put("scoredCount", scored); + double completion = assigned > 0 ? Math.round(scored * 10000.0 / assigned) / 100.0 : 0; + item.put("completionRate", completion); + BigDecimal avgSc = toBigDecimal(row.get("avgScore")); + BigDecimal stdSc = toBigDecimal(row.get("scoreStddev")); + item.put("avgScore", avgSc == null ? null : round2(avgSc.doubleValue())); + item.put("scoreStddev", stdSc == null ? 0.0 : round2(stdSc.doubleValue())); + judgeWorkload.add(item); + } result.put("judgeWorkload", judgeWorkload); - // --- awardDistribution --- - long totalWorks = contestWorkMapper.selectCount( - new LambdaQueryWrapper() - .eq(contestId != null, BizContestWork::getContestId, contestId) - .eq(BizContestWork::getIsLatest, true)); - - List awardLevels = List.of("first", "second", "third", "excellent"); - List> awardDistribution = awardLevels.stream().map(level -> { - long count = contestWorkMapper.selectCount( - new LambdaQueryWrapper() - .eq(contestId != null, BizContestWork::getContestId, contestId) - .eq(BizContestWork::getIsLatest, true) - .eq(BizContestWork::getAwardLevel, level)); - Map item = new LinkedHashMap<>(); - item.put("level", level); - item.put("count", count); - item.put("ratio", totalWorks > 0 - ? Math.round(count * 10000.0 / totalWorks) / 100.0 - : 0); - return item; - }).collect(Collectors.toList()); - result.put("awardDistribution", awardDistribution); + result.put("awardDistribution", buildAwardDistribution(contestIds, tenantId, isSuperTenant)); return result; } + + // --- 可见活动 ID(已发布 + 租户授权)--- + + private List resolveVisibleContestIds(Long tenantId, boolean isSuperTenant, Long contestId) { + LambdaQueryWrapper w = new LambdaQueryWrapper<>(); + w.eq(BizContest::getValidState, 1); + w.eq(BizContest::getContestState, PublishStatus.PUBLISHED.getValue()); + if (!isSuperTenant && tenantId != null) { + w.apply("JSON_CONTAINS(contest_tenants, CAST({0} AS JSON))", tenantId); + } + if (contestId != null) { + w.eq(BizContest::getId, contestId); + } + return contestMapper.selectList(w).stream().map(BizContest::getId).filter(Objects::nonNull).toList(); + } + + private static LambdaQueryWrapper workBaseWrapper(List contestIds, Long tenantId, boolean isSuperTenant) { + LambdaQueryWrapper w = new LambdaQueryWrapper() + .in(BizContestWork::getContestId, contestIds) + .eq(BizContestWork::getIsLatest, true) + .eq(BizContestWork::getValidState, 1); + if (!isSuperTenant && tenantId != null) { + w.eq(BizContestWork::getTenantId, tenantId); + } + return w; + } + + private static void applyRegistrationTimeRange(LambdaQueryWrapper w, LocalDateTime[] range) { + if (range != null) { + w.ge(BizContestRegistration::getRegistrationTime, range[0]); + w.le(BizContestRegistration::getRegistrationTime, range[1]); + } + } + + private static void applyWorkSubmitTimeRange(LambdaQueryWrapper w, LocalDateTime[] range) { + if (range != null) { + w.ge(BizContestWork::getSubmitTime, range[0]); + w.le(BizContestWork::getSubmitTime, range[1]); + } + } + + /** + * 时间范围:本月 / 本季 / 本年;all 或空为不限 + */ + private static LocalDateTime[] resolveTimeRange(String timeRange) { + if (!StringUtils.hasText(timeRange) || "all".equalsIgnoreCase(timeRange.trim())) { + return null; + } + LocalDateTime now = LocalDateTime.now(); + LocalDate today = now.toLocalDate(); + LocalDateTime start; + switch (timeRange.trim().toLowerCase()) { + case "month" -> { + LocalDate first = today.withDayOfMonth(1); + start = LocalDateTime.of(first, LocalTime.MIN); + } + case "quarter" -> { + int m = today.getMonthValue(); + int qStart = ((m - 1) / 3) * 3 + 1; + LocalDate first = today.withMonth(qStart).withDayOfMonth(1); + start = LocalDateTime.of(first, LocalTime.MIN); + } + case "year" -> { + LocalDate first = today.withDayOfYear(1); + start = LocalDateTime.of(first, LocalTime.MIN); + } + default -> { + return null; + } + } + return new LocalDateTime[]{start, now}; + } + + private List> buildMonthlyTrend(List contestIds, Long tenantId, boolean isSuperTenant) { + List> regRows = analyticsMapper.selectMonthlyRegistrationCounts(contestIds, tenantId, isSuperTenant); + List> workRows = analyticsMapper.selectMonthlyWorkCounts(contestIds, tenantId, isSuperTenant); + + Map regMap = regRows.stream().collect(Collectors.toMap( + r -> Objects.toString(r.get("ym"), ""), + r -> toLong(r.get("cnt")), + Long::sum + )); + Map workMap = workRows.stream().collect(Collectors.toMap( + r -> Objects.toString(r.get("ym"), ""), + r -> toLong(r.get("cnt")), + Long::sum + )); + + YearMonth now = YearMonth.now(); + List> trend = new ArrayList<>(); + for (int i = 5; i >= 0; i--) { + YearMonth ym = now.minusMonths(i); + String key = ym.toString(); + Map row = new LinkedHashMap<>(); + row.put("month", key); + row.put("registrations", regMap.getOrDefault(key, 0L).intValue()); + row.put("works", workMap.getOrDefault(key, 0L).intValue()); + trend.add(row); + } + return trend; + } + + private List> buildContestComparison(List contestIds, Long tenantId, boolean isSuperTenant, + LocalDateTime[] range) { + List> list = new ArrayList<>(); + for (Long cid : contestIds) { + BizContest c = contestMapper.selectById(cid); + if (c == null) { + continue; + } + Map raw = analyticsMapper.selectContestComparisonRow(cid, tenantId, isSuperTenant, + range != null ? range[0] : null, range != null ? range[1] : null); + if (raw == null) { + continue; + } + long registrations = toLong(raw.get("registrations")); + long passedRegs = toLong(raw.get("passedRegs")); + long workCount = toLong(raw.get("workCount")); + long reviewedCount = toLong(raw.get("reviewedCount")); + long awardedCount = toLong(raw.get("awardedCount")); + + double passRate = registrations > 0 ? round2(passedRegs * 100.0 / registrations) : 0; + double submitRate = passedRegs > 0 ? round2(workCount * 100.0 / passedRegs) : 0; + double reviewRate = workCount > 0 ? round2(reviewedCount * 100.0 / workCount) : 0; + double awardRate = workCount > 0 ? round2(awardedCount * 100.0 / workCount) : 0; + + Map row = new LinkedHashMap<>(); + row.put("contestId", cid); + row.put("contestName", c.getContestName()); + row.put("registrations", (int) registrations); + row.put("passRate", passRate); + row.put("submitRate", submitRate); + row.put("reviewRate", reviewRate); + row.put("awardRate", awardRate); + BigDecimal avg = toBigDecimal(raw.get("avgScore")); + row.put("avgScore", avg != null ? round2(avg.doubleValue()) : null); + list.add(row); + } + return list; + } + + private List> buildAwardDistribution(List contestIds, Long tenantId, boolean isSuperTenant) { + List> rows = analyticsMapper.selectAwardDistribution(contestIds, tenantId, isSuperTenant); + Map merged = new LinkedHashMap<>(); + for (Map r : rows) { + String level = Objects.toString(r.get("awardLevel"), ""); + String rawName = r.get("awardNameRaw") != null ? Objects.toString(r.get("awardNameRaw"), "") : ""; + String label = awardDisplayName(level, rawName); + long cnt = toLong(r.get("cnt")); + merged.merge(label, cnt, Long::sum); + } + long total = merged.values().stream().mapToLong(Long::longValue).sum(); + List> out = new ArrayList<>(); + for (Map.Entry e : merged.entrySet()) { + Map item = new LinkedHashMap<>(); + item.put("awardName", e.getKey()); + item.put("count", e.getValue().intValue()); + double pct = total > 0 ? Math.round(e.getValue() * 10000.0 / total) / 100.0 : 0; + item.put("percentage", pct); + out.add(item); + } + return out; + } + + private static String awardDisplayName(String level, String awardName) { + if (StringUtils.hasText(awardName)) { + return awardName.trim(); + } + if (!StringUtils.hasText(level)) { + return "其他"; + } + return switch (level) { + case "first" -> "一等奖"; + case "second" -> "二等奖"; + case "third" -> "三等奖"; + case "excellent" -> "优秀奖"; + default -> level; + }; + } + + private static Map emptySummary() { + Map m = new LinkedHashMap<>(); + m.put("totalContests", 0); + m.put("totalRegistrations", 0); + m.put("passedRegistrations", 0); + m.put("totalWorks", 0); + m.put("reviewedWorks", 0); + m.put("awardedWorks", 0); + return m; + } + + private static Map emptyFunnel() { + Map m = new LinkedHashMap<>(); + m.put("registered", 0); + m.put("passed", 0); + m.put("submitted", 0); + m.put("reviewed", 0); + m.put("awarded", 0); + return m; + } + + private static Map emptyEfficiency() { + Map m = new LinkedHashMap<>(); + m.put("avgReviewDays", 0); + m.put("dailyReviewCount", 0); + m.put("pendingAssignments", 0); + m.put("avgScoreStddev", 0); + return m; + } + + private static long toLong(Object o) { + if (o == null) { + return 0L; + } + if (o instanceof Number n) { + return n.longValue(); + } + try { + return Long.parseLong(o.toString()); + } catch (NumberFormatException e) { + return 0L; + } + } + + private static BigDecimal toBigDecimal(Object o) { + if (o == null) { + return null; + } + if (o instanceof BigDecimal b) { + return b; + } + if (o instanceof Number n) { + return BigDecimal.valueOf(n.doubleValue()); + } + try { + return new BigDecimal(o.toString()); + } catch (Exception e) { + return null; + } + } + + private static double round2(double v) { + return Math.round(v * 100.0) / 100.0; + } } diff --git a/backend-java/src/main/resources/mapper/biz/AnalyticsMapper.xml b/backend-java/src/main/resources/mapper/biz/AnalyticsMapper.xml new file mode 100644 index 0000000..afca522 --- /dev/null +++ b/backend-java/src/main/resources/mapper/biz/AnalyticsMapper.xml @@ -0,0 +1,220 @@ + + + + + + + #{cid} + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/design/README.md b/docs/design/README.md index 29c5994..ea377a5 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -19,7 +19,7 @@ | 文档 | 模块 | 状态 | 日期 | |------|------|------|------| | [租户端全面优化](./org-admin/tenant-portal-optimization.md) | 全模块 | 已优化 | 2026-03-31 | -| [数据统计看板](./org-admin/data-analytics-dashboard.md) | 数据统计 | 已实现 | 2026-03-31 | +| [数据统计看板](./org-admin/data-analytics-dashboard.md) | 数据统计 | 已实现(API 契约与租户过滤见文档 §5) | 2026-04-09 | ## 用户端(公众端) diff --git a/docs/design/org-admin/data-analytics-dashboard.md b/docs/design/org-admin/data-analytics-dashboard.md index 25eeded..94fab1a 100644 --- a/docs/design/org-admin/data-analytics-dashboard.md +++ b/docs/design/org-admin/data-analytics-dashboard.md @@ -1,9 +1,9 @@ # 租户端数据统计分析看板 — 设计方案 > 所属端:租户端(机构管理端) -> 状态:已实现 +> 状态:已实现(后端契约与租户过滤已对齐 [2026-04-09]) > 创建日期:2026-03-31 -> 最后更新:2026-03-31 +> 最后更新:2026-04-09 --- @@ -197,16 +197,23 @@ GROUP BY award_name ## 5. 后端 API 设计 +上下文路径以部署为准(开发环境一般为 `/api`)。实现类:`AnalyticsController` / `AnalyticsService` / `AnalyticsMapper`。 + +**多租户**:非超级租户时,活动范围由 `t_biz_contest.contest_tenants`(JSON 授权)限定;报名/作品/评分等子表再按 `tenant_id` 过滤。超级租户不按 `contest_tenants` 限活动。 + ### 5.1 运营概览 ``` -GET /api/analytics/overview - 参数: timeRange(month/quarter/year/all), contestId?(可选) +GET /analytics/overview + 参数: + timeRange: 可选。month | quarter | year;不传或 all 表示不限时间。 + 作用于 summary、funnel、contestComparison 的时间过滤(报名按 registration_time,作品按 submit_time)。 + contestId: 可选。指定单个活动;须属于当前租户可见活动。 返回: { summary: { totalContests, totalRegistrations, passedRegistrations, totalWorks, reviewedWorks, awardedWorks }, - funnel: { registered, passed, submitted, reviewed, awarded }, - monthlyTrend: [{ month, registrations, works }], + funnel: { registered, passed, submitted, reviewed, awarded }, // 对象,非数组 + monthlyTrend: [{ month: 'YYYY-MM', registrations, works }], // 最近 6 个自然月,与 timeRange 独立 contestComparison: [{ contestId, contestName, registrations, passRate, submitRate, reviewRate, awardRate, avgScore @@ -214,11 +221,15 @@ GET /api/analytics/overview } ``` +指标口径简述:`reviewedWorks` / 漏斗 `reviewed` 为作品 `status IN ('accepted','awarded')`;`awardedWorks` 为 `award_level` 非空且不为 `none`。 + ### 5.2 评审分析 ``` -GET /api/analytics/review - 参数: timeRange, contestId? +GET /analytics/review + 参数: + contestId: 可选,同上。 + timeRange: 可传(与前端下拉一致);当前实现中评审分析各模块为「可见活动」全量统计,该参数预留,不参与过滤。 返回: { efficiency: { avgReviewDays, dailyReviewCount, pendingAssignments, avgScoreStddev }, @@ -230,6 +241,8 @@ GET /api/analytics/review } ``` +`efficiency`:`avgReviewDays` 为作品提交至首次评分的平均天数;`dailyReviewCount` 为近 30 天评分条数 / 30;`pendingAssignments` 为分配状态 `assigned` 的条数;`avgScoreStddev` 为「多评委作品」分数标准差再对作品取平均。 + ## 6. 技术方案 - 前端图表库:使用 ECharts 或 Ant Design Charts(@ant-design/charts) diff --git a/frontend/src/api/analytics.ts b/frontend/src/api/analytics.ts index 4560a5b..42e1814 100644 --- a/frontend/src/api/analytics.ts +++ b/frontend/src/api/analytics.ts @@ -57,6 +57,6 @@ export const analyticsApi = { getOverview: (params?: { timeRange?: string; contestId?: number }): Promise => request.get('/analytics/overview', { params }), - getReview: (params?: { contestId?: number }): Promise => + getReview: (params?: { timeRange?: string; contestId?: number }): Promise => request.get('/analytics/review', { params }), } diff --git a/frontend/src/views/analytics/Overview.vue b/frontend/src/views/analytics/Overview.vue index e049a01..a8440c6 100644 --- a/frontend/src/views/analytics/Overview.vue +++ b/frontend/src/views/analytics/Overview.vue @@ -4,7 +4,14 @@