From 22c778b1119babec583abb6f1e3ab256515dd774 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Thu, 17 Sep 2026 23:25:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(analytics):=20close=20Phase=2020=20KEEP-VER?= =?UTF-8?q?IFY=20=E2=80=94=2020=20defects=20(fake=20metric=20definitions,?= =?UTF-8?q?=20hardcoded=20zeros,=20systemic=20soft-delete=20leak,=20module?= =?UTF-8?q?-wide=20403,=20fake-green=20tests);=20add=20MetricCatalog=20+?= =?UTF-8?q?=20unified=20calculator=20+=20V043=20grants=20+=2051=20guard=20?= =?UTF-8?q?tests;=20full=20regression=20648=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 口径造假: - D1 实名人数用 user_profiles.profile_status != 'rejected' 冒充 → in_progress/need_edit 被算成已实名 - D3 Chat Started 直接数会话行数 → system/risk_notice 系统通知计入 - D4 Match 不分来源 → 活动 10 对互选被当 10 对成功引用(Gate) - D6 Banner 曝光/点击硬编码 0 → 改返回 null + NOT_INSTRUMENTED 标记 死代码 / 占位: - D5 活动漏斗全部硬编码 0 + 一行把"用户数"当"活动数"的死代码 - D20 getDailySummary(days) 无边界校验 系统性软删缺陷(D12): - BaseEntity.deletedAt 无 @TableLogic 且全局无逻辑删除配置 → selectCount 把软删数据全部计入统计且无任何报错 - 修复 45 处统计调用显式 deleted_at IS NULL(2 轮复验,首轮漏扫 5 处次级路径: 服务用户数分域明细 / 服务站统计 / 性别与年龄分布) 结构类: - D15 Dashboard/用户漏斗/专项页各实现一遍同名指标且互不相同 → 抽 AnalyticsMetricsCalculator 统一口径 权限(Gate ⑩): - data_viewer 在 V001 定义却全仓从未授权(空壳角色) - platform_admin 的 V015 扩展条件 id >= '20000000-…' 漏掉 V1.0 老权限段 → 整个 Analytics 模块除 super_admin 外全员 403 - V043 授予两角色;刻意不授予 organization_admin(相关表无组织维度,无范围全量即越权) 假绿测试: - 删除 AnalyticsServiceTest(5× assertTrue(true),零断言) 新增: - 指标字典 MetricCatalog(36 指标 × 12 字段)+ MetricStatus/MetricDefinition + docs/ANALYTICS_METRICS.md - 守卫 51 用例:MetricCatalogGuardTest(11) / AnalyticsMetricsPhase20IntegrationTest(21) / AnalyticsSoftDeleteGuardTest(4,结构性) / AnalyticsPermissionGrantTest(6) / LocalEligibilityStatusVocabularyGuardTest(9) 跨阶段修复(缺陷本体属账号生命周期模块,按"发现即修"处理): - X1(Critical)注销写入 user_identities 状态域的 'failed' → 违反生产 V017 chk_elig_verify_status → 真实 PG 上 closeAccount 事务回滚、账号注销 100% 不可用(负向验证实测 DataIntegrityViolation) - X2 checkCanWithdraw 资格拦截用 submitted/pending(该表永不可能出现)→ 死代码 - X3 测试库补 chk_elig_type/chk_elig_verify_status,暴露 ActivityPhase14Test 夹具违规 验证: - 负向验证两轮共 7 处注入 → 7 个用例精准变红且无无关失败(一处被源码结构+运行时行为两层捕获) - 还原后 md5sum 逐字节一致,NEG-VAL 残留 0 - 后端全量 648 tests / 0 failures / 0 errors / BUILD SUCCESS;admin-web vue-tsc EXIT=0 --- .../src/modules/analytics/AnalyticsView.vue | 170 ++++- .../service/AccountLifecycleService.java | 28 +- .../mapper/ActivityRegistrationMapper.java | 26 + .../mapper/ActivityMutualConsentMapper.java | 22 + .../platform/analytics/AnalyticsService.java | 641 ++++++++++-------- .../metric/AnalyticsMetricsCalculator.java | 380 +++++++++++ .../analytics/metric/MetricCatalog.java | 336 +++++++++ .../analytics/metric/MetricDefinition.java | 56 ++ .../analytics/metric/MetricStatus.java | 34 + .../platform/chat/mapper/MessageMapper.java | 18 + .../entity/CounselingServiceRecord.java | 2 +- .../platform/match/mapper/MatchMapper.java | 25 + .../mapper/RecommendationRecordMapper.java | 26 + .../mapper/UserIdentityMapper.java | 15 + ...43__grant_analytics_view_to_data_roles.sql | 43 ++ ...lEligibilityStatusVocabularyGuardTest.java | 267 ++++++++ .../activity/ActivityPhase14Test.java | 5 +- ...nalyticsMetricsPhase20IntegrationTest.java | 625 +++++++++++++++++ .../analytics/AnalyticsServiceTest.java | 42 -- .../AnalyticsSoftDeleteGuardTest.java | 174 +++++ .../analytics/MetricCatalogGuardTest.java | 229 +++++++ .../deployment/MigrationValidationTest.java | 3 +- .../AnalyticsPermissionGrantTest.java | 188 +++++ backend/src/test/resources/schema-h2.sql | 12 +- docs/ANALYTICS_METRICS.md | 228 +++++++ docs/JIRA_BACKLOG.md | 34 +- ...1-Recovery-Completion-Report-2026-09-17.md | 338 +++++++++ docs/STATUS.md | 44 +- 28 files changed, 3652 insertions(+), 359 deletions(-) create mode 100644 backend/src/main/java/com/danyang/match/platform/analytics/metric/AnalyticsMetricsCalculator.java create mode 100644 backend/src/main/java/com/danyang/match/platform/analytics/metric/MetricCatalog.java create mode 100644 backend/src/main/java/com/danyang/match/platform/analytics/metric/MetricDefinition.java create mode 100644 backend/src/main/java/com/danyang/match/platform/analytics/metric/MetricStatus.java create mode 100644 backend/src/main/resources/db/migration/V043__grant_analytics_view_to_data_roles.sql create mode 100644 backend/src/test/java/com/danyang/match/platform/account/LocalEligibilityStatusVocabularyGuardTest.java create mode 100644 backend/src/test/java/com/danyang/match/platform/analytics/AnalyticsMetricsPhase20IntegrationTest.java delete mode 100644 backend/src/test/java/com/danyang/match/platform/analytics/AnalyticsServiceTest.java create mode 100644 backend/src/test/java/com/danyang/match/platform/analytics/AnalyticsSoftDeleteGuardTest.java create mode 100644 backend/src/test/java/com/danyang/match/platform/analytics/MetricCatalogGuardTest.java create mode 100644 backend/src/test/java/com/danyang/match/platform/security/AnalyticsPermissionGrantTest.java create mode 100644 docs/ANALYTICS_METRICS.md create mode 100644 docs/Phase-20-V11-Recovery-Completion-Report-2026-09-17.md diff --git a/admin-web/src/modules/analytics/AnalyticsView.vue b/admin-web/src/modules/analytics/AnalyticsView.vue index 1b80163..cad6db2 100644 --- a/admin-web/src/modules/analytics/AnalyticsView.vue +++ b/admin-web/src/modules/analytics/AnalyticsView.vue @@ -32,6 +32,10 @@ {{ funnelStats.profileApproved }} {{ funnelStats.activeUsers }} +
+ 说明:「实名用户」指身份证核验通过的人数(user_identities.verification_status = verified), + 与「审核通过」(资料审核)是两个不同口径,两者互不包含。 +
@@ -46,6 +50,39 @@ + + + Match 按来源分离 + + +
+
{{ matchFunnelStats.matchesBySource?.userLike ?? 0 }}
+
用户互相 Like
+
+
+ +
+
{{ matchFunnelStats.matchesBySource?.activityMutual ?? 0 }}
+
活动互选
+
+
+ +
+
{{ matchFunnelStats.matchesBySource?.matchmaker ?? 0 }}
+
红娘促成
+
+
+ +
+
{{ matchFunnelStats.matchmakerRecommendations ?? 0 }}
+
红娘发出推荐
+
+
+
+
+ 说明:「开启聊天」只统计存在真人消息的会话(系统通知不计);Match 按来源分离统计, + 不同来源的量不可互相替代。2 个单向 Like 才形成 1 个 Match。 +
@@ -58,6 +95,109 @@ + + + + + + +
+
{{ v11?.uniqueServiceUsers?.totalUniqueServiceUsers ?? 0 }}
+
服务用户数(去重)
+
+
+ +
+
{{ v11?.uniqueServiceUsers?.totalServiceVisits ?? 0 }}
+
公益服务人次(不去重)
+
+
+ +
+
{{ v11?.serviceAppointments?.total ?? 0 }}
+
服务预约(不含取消)
+
+
+ +
+
{{ v11?.counseling?.total ?? 0 }}
+
婚恋咨询受理
+
+
+
+ + + 活动互选与成功(两个不同指标) + + +
+
{{ v11?.activityFunnel?.mutualBothConfirmed ?? 0 }}
+
互选成立对数
+
+
+ +
+
{{ v11?.activityFunnel?.mutualMatches ?? 0 }}
+
互选后建立 Match
+
+
+ +
+
{{ v11?.activityFunnel?.mutualSuccessful ?? 0 }}
+
互选后成功牵手
+
+
+ +
+
{{ v11?.activityFunnel?.checkedInCount ?? 0 }}
+
活动实际签到
+
+
+
+ + + +
{{ v11?.effectiveConnections?.provisionalNote }}
+
+ + +
{{ v11?.banners?.unavailableReason }}
+
+ + + {{ v11?.banners?.totalBanners ?? 0 }} + {{ v11?.banners?.activeBanners ?? 0 }} + + {{ v11.banners.impressions }} + 未埋点 + + + {{ v11.banners.clicks }} + 未埋点 + + {{ v11?.content?.totalPublishedArticles ?? 0 }} + {{ v11?.content?.totalViews ?? 0 }} + {{ v11?.servicePrograms?.totalPrograms ?? 0 }} + {{ v11?.servicePrograms?.totalMembers ?? 0 }} + {{ v11?.localEligibility?.verified ?? 0 }} + {{ v11?.premaritalGuidance?.totalRequests ?? 0 }} + {{ v11?.counseling?.pending ?? 0 }} + + {{ v11?.effectiveConnections?.successConnections ?? 0 }} + + +
+ @@ -84,13 +224,14 @@ const dateRange = ref<[string, string]>([ const funnelStats = ref({}) const matchFunnelStats = ref({}) const safetyStats = ref({}) +const v11 = ref({}) const dailySummary = ref([]) const funnelStages = [ - { key: 'registered', label: '累计注册', value: 0, rate: null, color: '#409EFF' }, - { key: 'verified', label: '已实名', value: 0, rate: null, color: '#67C23A' }, - { key: 'approved', label: '审核通过', value: 0, rate: null, color: '#E59A32' }, - { key: 'active', label: '7日活跃', value: 0, rate: null, color: '#E96B6B' } + { key: 'registered', label: '累计注册', value: 0, rate: null as string | null, color: '#409EFF' }, + { key: 'verified', label: '已实名', value: 0, rate: null as string | null, color: '#67C23A' }, + { key: 'approved', label: '审核通过', value: 0, rate: null as string | null, color: '#E59A32' }, + { key: 'active', label: '7日活跃', value: 0, rate: null as string | null, color: '#E96B6B' } ] const matchStages = [ @@ -113,21 +254,27 @@ async function loadFunnel() { try { const token = localStorage.getItem('accessToken') if (dateRange.value[0] && dateRange.value[1]) { - const [userRes, matchRes, safetyRes]: any[] = await Promise.all([ + const [userRes, matchRes, safetyRes, v11Res]: any[] = await Promise.all([ get('/operator/analytics/user-funnel', { since: dateRange.value[0], until: dateRange.value[1] }, { Authorization: `Bearer ${token}` }), get('/operator/analytics/match-funnel', { since: dateRange.value[0], until: dateRange.value[1] }, { Authorization: `Bearer ${token}` }), - get('/operator/analytics/safety', {}, { Authorization: `Bearer ${token}` }) + get('/operator/analytics/safety', {}, { Authorization: `Bearer ${token}` }), + // Phase 20:原先只接了 4/15 个端点,V1.1 公益服务的专项统计 + // (服务用户数/人次、活动互选 vs 成功、Banner、专项项目) + // 后端已实现却无人调用 —— 本行起补齐。 + get('/operator/analytics/v11-dashboard', {}, { Authorization: `Bearer ${token}` }) ]) const userData = userRes.data || {} const matchData = matchRes.data || {} const safetyData = safetyRes.data || {} + const v11Data = v11Res.data || {} funnelStats.value = userData matchFunnelStats.value = matchData safetyStats.value = safetyData + v11.value = v11Data // 更新漏斗显示 funnelStages[0].value = userData.totalRegistered || 0 @@ -188,4 +335,15 @@ onMounted(loadData) .stat-card { text-align: center; } .stat-value { font-size: 32px; font-weight: bold; } .stat-label { font-size: 13px; color: #666; margin-top: 6px; } +.mini-stat { + border: 1px solid #ebeef5; + border-radius: 8px; + padding: 14px; + text-align: center; + background: #fafafa; +} +.mini-value { font-size: 22px; font-weight: bold; color: #303133; } +.mini-label { font-size: 12px; color: #909399; margin-top: 6px; } +.metric-note { font-size: 12px; color: #909399; margin-top: 12px; line-height: 1.6; } +.unavailable { color: #c0c4cc; font-style: italic; } diff --git a/backend/src/main/java/com/danyang/match/platform/account/service/AccountLifecycleService.java b/backend/src/main/java/com/danyang/match/platform/account/service/AccountLifecycleService.java index 1077ae0..b7b2e79 100644 --- a/backend/src/main/java/com/danyang/match/platform/account/service/AccountLifecycleService.java +++ b/backend/src/main/java/com/danyang/match/platform/account/service/AccountLifecycleService.java @@ -47,6 +47,18 @@ public class AccountLifecycleService { private static final Logger log = LoggerFactory.getLogger(AccountLifecycleService.class); + /** + * 本地资格「审核进行中」的状态集合。 + * + *

取值域严格来自生产 {@code V017 chk_elig_verify_status}: + * {@code declared / needs_evidence / pending_review / verified / rejected}。 + * 这里刻意**不复用** {@code Constants.VERIFICATION_STATUS_*} —— + * 那组常量描述的是 {@code user_identities}(V002 的 CHECK 才含 failed), + * 两个表的状态域不同,跨域复用正是本类两处缺陷的根因。 + */ + private static final Set LOCAL_ELIGIBILITY_IN_REVIEW = + Set.of("needs_evidence", "pending_review"); + private final UserMapper userMapper; private final UserProfileMapper userProfileMapper; private final ActivityRegistrationMapper registrationMapper; @@ -192,10 +204,14 @@ public class AccountLifecycleService { } // V1.1: 检查本地资格核验状态 + // 注意状态域:本地资格表(V017 chk_elig_verify_status)只有 + // declared / needs_evidence / pending_review / verified / rejected + // 此前这里比较的是 "submitted" / "pending" —— 这两个值在该表里**永不可能出现**, + // 因此"本地资格审核进行中"这道拦截从未生效过(死代码), + // 审核中的用户可以直接注销。正确的"审核进行中"是 pending_review。 Optional localElig = localEligibilityMapper.findByUserId(userId); if (localElig.isPresent() && - (Constants.VERIFICATION_STATUS_SUBMITTED.equals(localElig.get().getVerificationStatus()) || - Constants.VERIFICATION_STATUS_PENDING.equals(localElig.get().getVerificationStatus()))) { + LOCAL_ELIGIBILITY_IN_REVIEW.contains(localElig.get().getVerificationStatus())) { blockers.put("localEligibility", "您的本地资格审核正在进行中,请耐心等待"); } @@ -431,7 +447,13 @@ public class AccountLifecycleService { Optional elig = localEligibilityMapper.findByUserId(userId); if (elig.isPresent()) { LocalEligibility le = elig.get(); - le.setVerificationStatus(Constants.VERIFICATION_STATUS_FAILED); + // 生产 V017 的 chk_elig_verify_status 只允许 + // declared / needs_evidence / pending_review / verified / rejected + // —— **没有 failed**。VERIFICATION_STATUS_FAILED 属于 user_identities 的状态域 + // (V002 的 chk 里才有 failed),把它写进本地资格表会在真实 PostgreSQL 上 + // 直接触发 CHECK 违约 → closeAccount 整个事务回滚 → 注销 100% 不可用。 + // 测试库此前没有该 CHECK,所以这个必崩缺陷在测试期完全不可见。 + le.setVerificationStatus(Constants.VERIFICATION_STATUS_REJECTED); localEligibilityMapper.updateById(le); log.info("失效用户本地资格: userId={}", userId); } diff --git a/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityRegistrationMapper.java b/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityRegistrationMapper.java index 6aedc9b..fefc1b1 100644 --- a/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityRegistrationMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/activity/mapper/ActivityRegistrationMapper.java @@ -176,4 +176,30 @@ public interface ActivityRegistrationMapper extends BaseMapper「互选」的严格定义是两侧均为 {@code accepted},而不是「有人点了同意」。 + * Match 的 {@code activity_mutual} 来源行只有在双确认后才创建, + * 但互选**对数**与后续是否走到 {@code status='successful'} 是两件事: + * 前者是活动互选环节的产出,后者是关系推进的产出,二者不可互相替代。 + * + *

去重键:{@code id}(同一对用户在同一活动内只有一行)。 + */ + @Select("SELECT COUNT(*) FROM activity_mutual_connection_consents " + + "WHERE user_a_status = 'accepted' AND user_b_status = 'accepted' AND deleted_at IS NULL") + long countBothConfirmed(); + + /** 仅单侧表态的记录数(用于观察"已表达但未配成"的漏斗中间态)。 */ + @Select("SELECT COUNT(*) FROM activity_mutual_connection_consents WHERE deleted_at IS NULL " + + "AND (user_a_status = 'accepted' OR user_b_status = 'accepted') " + + "AND NOT (user_a_status = 'accepted' AND user_b_status = 'accepted')") + long countOneSidedAccepted(); } diff --git a/backend/src/main/java/com/danyang/match/platform/analytics/AnalyticsService.java b/backend/src/main/java/com/danyang/match/platform/analytics/AnalyticsService.java index ef01e16..fdf06b4 100644 --- a/backend/src/main/java/com/danyang/match/platform/analytics/AnalyticsService.java +++ b/backend/src/main/java/com/danyang/match/platform/analytics/AnalyticsService.java @@ -1,41 +1,30 @@ package com.danyang.match.platform.analytics; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.danyang.match.platform.match.entity.RecommendationRecord; -import com.danyang.match.platform.match.mapper.RecommendationRecordMapper; -import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper; +import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper; +import com.danyang.match.platform.analytics.metric.AnalyticsMetricsCalculator; import com.danyang.match.platform.chat.entity.Conversation; import com.danyang.match.platform.chat.mapper.ConversationMapper; +import com.danyang.match.platform.content.entity.ContentArticle; +import com.danyang.match.platform.content.mapper.ContentArticleMapper; +import com.danyang.match.platform.counseling.entity.CounselingServiceRecord; +import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper; +import com.danyang.match.platform.local_eligibility.entity.LocalEligibility; +import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper; +import com.danyang.match.platform.match.entity.Match; +import com.danyang.match.platform.match.mapper.MatchMapper; import com.danyang.match.platform.profile.entity.UserProfile; import com.danyang.match.platform.profile.mapper.UserProfileMapper; -import com.danyang.match.platform.match.entity.Match; -import com.danyang.match.platform.relationship.entity.RelationshipSuccessRequest; -import com.danyang.match.platform.match.mapper.MatchMapper; -import com.danyang.match.platform.relationship.mapper.RelationshipSuccessRequestMapper; import com.danyang.match.platform.risk.entity.Report; import com.danyang.match.platform.risk.mapper.ReportMapper; -import com.danyang.match.platform.risk.entity.RiskAction; -import com.danyang.match.platform.risk.mapper.RiskActionMapper; -import com.danyang.match.platform.shared.constant.Constants; +import com.danyang.match.platform.service_appointment.entity.ServiceAppointment; +import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper; +import com.danyang.match.platform.service_program.entity.ServiceProgramMember; +import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper; +import com.danyang.match.platform.shared.exception.BusinessException; +import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.user.entity.User; import com.danyang.match.platform.user.mapper.UserMapper; -import com.danyang.match.platform.banner.mapper.HomeBannerMapper; -import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper; -import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper; -import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper; -import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper; -import com.danyang.match.platform.content.mapper.ContentArticleMapper; -import com.danyang.match.platform.service_program.mapper.ServiceProgramMapper; -import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper; -import com.danyang.match.platform.activity.mapper.ActivityMapper; -import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper; -import com.danyang.match.platform.service_appointment.entity.ServiceAppointment; -import com.danyang.match.platform.counseling.entity.CounselingServiceRecord; -import com.danyang.match.platform.local_eligibility.entity.LocalEligibility; -import com.danyang.match.platform.content.entity.ContentArticle; -import com.danyang.match.platform.service_program.entity.ServiceProgram; -import com.danyang.match.platform.service_program.entity.ServiceProgramMember; -import com.danyang.match.platform.activity.entity.ActivityRegistration; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -43,43 +32,70 @@ import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; import java.util.*; -import java.util.stream.Collectors; /** - * Analytics 服务 - * 提供:Dashboard / 用户漏斗 / Match漏斗 / 每日汇总 / 趋势图数据 + * Analytics 服务 —— 对外提供各统计视图的数据装配。 + * + *

Phase 20 重构说明:本类不再**自己**计算任何指标数字, + * 一律委托 {@link AnalyticsMetricsCalculator}(唯一口径来源)。 + * + *

修复前的问题:Dashboard / 用户漏斗 / V11 专项仪表盘各自实现了一遍同名指标, + * 且互不相同: + *

    + *
  • "实名人数"被算成 {@code profile_status != 'rejected'}(资料没被退回), + * 把「资料还在填」和「被退回待改」都算成了已实名;
  • + *
  • "开启聊天数"直接数会话行数,从不看消息类型,一条系统通知即计入;
  • + *
  • "Match 数"混用全部来源,导致活动互选、红娘促成、用户自主匹配 + * 被当成同一个数字,Gate 要求的「10 对互选 ≠ 10 对 Success」无法体现;
  • + *
  • 活动漏斗全部硬编码为 0,后台永远显示 0。
  • + *
+ * + *

字段名兼容性:admin-web 已消费的响应字段名({@code verifiedCount} / + * {@code totalLiked} / {@code chatsStarted} 等)一律保留,改动只发生在**取值来源**上, + * 不破坏前端契约。新增的按来源分离字段为增量添加。 */ @Service @RequiredArgsConstructor public class AnalyticsService { + /** 唯一口径来源。 */ + private final AnalyticsMetricsCalculator metrics; + private final UserMapper userMapper; private final UserProfileMapper userProfileMapper; private final MatchMapper matchMapper; - private final RelationshipSuccessRequestMapper successRequestMapper; private final ReportMapper reportMapper; - private final RiskActionMapper riskActionMapper; - private final RecommendationRecordMapper recommendationRecordMapper; private final ConversationMapper conversationMapper; - private final MatchmakerServiceMapper matchmakerServiceMapper; - private final HomeBannerMapper homeBannerMapper; private final CounselingServiceMapper counselingServiceMapper; private final LocalEligibilityMapper localEligibilityMapper; private final ServiceAppointmentMapper serviceAppointmentMapper; - private final ActivityMutualConsentMapper activityMutualConsentMapper; private final ContentArticleMapper contentArticleMapper; - private final ServiceProgramMapper serviceProgramMapper; private final ServiceProgramMemberMapper serviceProgramMemberMapper; private final ActivityRegistrationMapper activityRegistrationMapper; // ==================== Dashboard ==================== + /** + * 综合看板。 + * + *

与 {@link #getUserFunnel} / {@link #getMatchFunnel} 共用同一批口径, + * 确保 Gate「Dashboard 和专项页面相同指标必须一致」成立。 + */ public Map getDashboardStats() { - Map stats = new HashMap<>(); - stats.putAll(userBasicStats()); - stats.putAll(matchFunnelStats()); - stats.putAll(safetyStats()); + Map stats = new LinkedHashMap<>(); + stats.put("totalUsers", metrics.registeredUsers()); + stats.put("verifiedUsers", metrics.realNameVerifiedUsers()); + stats.put("approvedProfiles", metrics.profileApprovedUsers()); + stats.put("activeUsers7d", metrics.activeUsers7d()); + + stats.put("totalLikes", metrics.likes()); + stats.put("totalMatches", metrics.matches()); + stats.put("chatsStarted", metrics.chatsStarted()); + stats.put("gettingToKnow", matchMapper.countByStatus("getting_to_know")); + stats.put("successPairs", metrics.successfulMatches()); + stats.putAll(activityFunnelStats()); + stats.putAll(safetyStats()); return stats; } @@ -88,45 +104,46 @@ public class AnalyticsService { public Map getUserFunnel(LocalDate since, LocalDate until) { if (since == null) since = LocalDate.now().minusMonths(1); if (until == null) until = LocalDate.now(); - Map result = new HashMap<>(); + Map result = new LinkedHashMap<>(); - // 各阶段总量 - long totalRegistered = userMapper.selectCount(null); - long verifiedCount = userProfileMapper.selectCount( - new LambdaQueryWrapper() - .ne(UserProfile::getProfileStatus, "rejected")); - long profileApproved = userProfileMapper.selectCount( - new LambdaQueryWrapper() - .eq(UserProfile::getProfileStatus, "approved")); + long totalRegistered = metrics.registeredUsers(); + // Phase 20 D1:实名口径改为 user_identities.verification_status='verified'。 + long verifiedCount = metrics.realNameVerifiedUsers(); + long profileApproved = metrics.profileApprovedUsers(); + // activeUsers 在本视图中的语义是「所选时间范围内登录过」, + // 因此使用范围起点而非固定 7 天窗口(与 Dashboard 的 activeUsers7d 语义不同,勿混用)。 + // deleted_at 必须显式过滤:BaseEntity 无 @TableLogic,selectCount 不会自动排除软删。 long activeUsers = userMapper.selectCount( new LambdaQueryWrapper() - .ge(User::getLastLoginAt, since.atStartOfDay(ZoneId.systemDefault()).toInstant())); + .ge(User::getLastLoginAt, since.atStartOfDay(ZoneId.systemDefault()).toInstant()) + .isNull(User::getDeletedAt)); result.put("totalRegistered", totalRegistered); result.put("verifiedCount", verifiedCount); result.put("profileApproved", profileApproved); result.put("activeUsers", activeUsers); - // 转化率 - result.put("verifyRate", totalRegistered > 0 ? String.format("%.1f%%", 100.0 * verifiedCount / totalRegistered) : "0%"); - result.put("approvalRate", verifiedCount > 0 ? String.format("%.1f%%", 100.0 * profileApproved / verifiedCount) : "0%"); - result.put("activeRate", totalRegistered > 0 ? String.format("%.1f%%", 100.0 * activeUsers / totalRegistered) : "0%"); + result.put("verifyRate", rate(verifiedCount, totalRegistered)); + result.put("approvalRate", rate(profileApproved, verifiedCount)); + result.put("activeRate", rate(activeUsers, totalRegistered)); - // 每日新增趋势 - List> dailyTrend = buildDailyNewUsers(since, until); - result.put("dailyTrend", dailyTrend); + result.put("dailyTrend", buildDailyNewUsers(since, until)); - // 男女比例 + // Phase 20 D12(补全):性别/年龄分布此前漏了软删过滤, + // 会让"分布"与已完成软删过滤的 totalRegistered 对不上账。 Map genderRatio = userProfileMapper.selectList( - new LambdaQueryWrapper().isNotNull(UserProfile::getGender)) + new LambdaQueryWrapper() + .isNotNull(UserProfile::getGender) + .isNull(UserProfile::getDeletedAt)) .stream() - .collect(Collectors.groupingBy( - UserProfile::getGender, Collectors.counting())); + .collect(java.util.stream.Collectors.groupingBy( + UserProfile::getGender, java.util.stream.Collectors.counting())); result.put("genderRatio", genderRatio); - // 年龄分布 List profiles = userProfileMapper.selectList( - new LambdaQueryWrapper().isNotNull(UserProfile::getBirthDate)); + new LambdaQueryWrapper() + .isNotNull(UserProfile::getBirthDate) + .isNull(UserProfile::getDeletedAt)); List breaks = Arrays.asList(18, 25, 30, 35, 40, 45, 100); Map ageDist = new LinkedHashMap<>(); for (int i = 0; i < breaks.size() - 1; i++) { @@ -146,28 +163,20 @@ public class AnalyticsService { return result; } - // ==================== Match漏斗 ==================== + // ==================== Match 漏斗 ==================== public Map getMatchFunnel(LocalDate since, LocalDate until) { if (since == null) since = LocalDate.now().minusMonths(1); if (until == null) until = LocalDate.now(); - Map result = new HashMap<>(); + Map result = new LinkedHashMap<>(); - // 总量 - long totalRecommended = recommendationRecordMapper.selectCount( - new LambdaQueryWrapper() - .isNotNull(RecommendationRecord::getShownAt)); - long totalLiked = recommendationRecordMapper.selectCount( - new LambdaQueryWrapper() - .eq(RecommendationRecord::getAction, "liked")); - long totalMatches = matchMapper.selectCount(null); - long chatsStarted = conversationMapper.selectCount(null); - long gettingToKnow = matchMapper.selectCount( - new LambdaQueryWrapper() - .eq(Match::getStatus, "getting_to_know")); - long successPairs = successRequestMapper.selectCount( - new LambdaQueryWrapper() - .eq(RelationshipSuccessRequest::getStatus, "confirmed")); + long totalRecommended = metrics.recommendationsShown(); + long totalLiked = metrics.likes(); + long totalMatches = metrics.matches(); + // Phase 20 D3:只统计有真人消息的会话,系统通知不计入「开启聊天」。 + long chatsStarted = metrics.chatsStarted(); + long gettingToKnow = matchMapper.countByStatus("getting_to_know"); + long successPairs = metrics.successfulMatches(); result.put("totalRecommended", totalRecommended); result.put("totalLiked", totalLiked); @@ -176,61 +185,75 @@ public class AnalyticsService { result.put("gettingToKnow", gettingToKnow); result.put("successPairs", successPairs); - // 各阶段转化率 - result.put("likeRate", totalRecommended > 0 ? String.format("%.1f%%", 100.0 * totalLiked / totalRecommended) : "0%"); - result.put("matchRate", totalLiked > 0 ? String.format("%.1f%%", 100.0 * totalMatches / totalLiked) : "0%"); - result.put("chatRate", totalMatches > 0 ? String.format("%.1f%%", 100.0 * chatsStarted / totalMatches) : "0%"); - result.put("successRate", totalMatches > 0 ? String.format("%.1f%%", 100.0 * successPairs / totalMatches) : "0%"); + result.put("likeRate", rate(totalLiked, totalRecommended)); + result.put("matchRate", rate(totalMatches, totalLiked)); + result.put("chatRate", rate(chatsStarted, totalMatches)); + result.put("successRate", rate(successPairs, totalMatches)); - // 每日新增匹配趋势 - List> dailyTrend = buildDailyNewMatches(since, until); - result.put("dailyTrend", dailyTrend); + // Phase 20 D4:按来源分离,避免把活动互选/红娘促成的量算进用户自主匹配。 + Map bySource = new LinkedHashMap<>(); + bySource.put("userLike", metrics.matchesBySource("user_like")); + bySource.put("activityMutual", metrics.matchesBySource("activity_mutual")); + bySource.put("matchmaker", metrics.matchesBySource("matchmaker")); + result.put("matchesBySource", bySource); + result.put("matchmakerRecommendations", metrics.matchmakerRecommendations()); + result.put("dailyTrend", buildDailyNewMatches(since, until)); return result; } // ==================== 活动漏斗 ==================== + /** + * 活动漏斗。 + * + *

Phase 20 D5 修复:修复前本方法的所有指标**硬编码为 0** + * (并且留着一行 {@code userMapper.selectCount(...)} 把"用户数"当成"活动数"的死代码), + * 后台"活动漏斗"因此永远显示 0 —— 不是没有数据,是根本没有查询。 + * + *

同时严格区分「互选成立」与「互选后成功」:Gate 要求 + * 「活动10对互选 ≠ 10 对 Success」。 + */ public Map getActivityFunnel() { - Map result = new HashMap<>(); - // 活动数量(简化:统计 distinct activity_id) - long activityCount = userMapper.selectCount( - new LambdaQueryWrapper<>() {}); // placeholder - // 报名数、通过数、签到数需要从 activity_registrations 统计 - // 简化处理 - result.put("totalActivities", 0); - result.put("totalRegistrations", 0); - result.put("approvedRegistrations", 0); - result.put("checkedInCount", 0); - return result; + return activityFunnelStats(); + } + + private Map activityFunnelStats() { + Map s = new LinkedHashMap<>(); + s.put("totalActivities", metrics.activities()); + s.put("totalRegistrations", metrics.activityRegistrations()); + s.put("approvedRegistrations", metrics.approvedActivityRegistrations()); + s.put("checkedInCount", metrics.checkedInRegistrations()); + // 互选成立对数(严格双确认) + s.put("mutualBothConfirmed", metrics.mutualBothConfirmed()); + // 互选后建立 Match 数 + s.put("mutualMatches", metrics.mutualMatches()); + // 互选后成功牵手数 —— 与互选对数严格区分 + s.put("mutualSuccessful", metrics.mutualSuccessful()); + return s; } // ==================== 安全统计 ==================== public Map getSafetyStats() { - Map stats = new HashMap<>(); - long pendingReports = reportMapper.countByStatus(Constants.REPORT_STATUS_PENDING); - long resolvedReports = reportMapper.countByStatus(Constants.REPORT_STATUS_RESOLVED); - long highRiskUsers = riskActionMapper.selectCount( - new LambdaQueryWrapper() - .in(RiskAction::getActionType, - Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_TEMPORARY_SUSPEND) - .eq(RiskAction::getStatus, "active")); - long bannedUsers = riskActionMapper.selectCount( - new LambdaQueryWrapper() - .eq(RiskAction::getActionType, Constants.RISK_ACTION_BAN) - .eq(RiskAction::getStatus, "active")); + return safetyStats(); + } - stats.put("pendingReports", pendingReports); - stats.put("resolvedReports", resolvedReports); - stats.put("highRiskUsers", highRiskUsers); - stats.put("bannedUsers", bannedUsers); + private Map safetyStats() { + Map stats = new LinkedHashMap<>(); + stats.put("pendingReports", metrics.pendingReports()); + stats.put("resolvedReports", metrics.resolvedReports()); + stats.put("highRiskUsers", metrics.highRiskUsers()); + stats.put("bannedUsers", metrics.bannedUsers()); return stats; } // ==================== 每日汇总 ==================== public List> getDailySummary(int days) { + if (days < 1 || days > 365) { + throw new BusinessException(ApiResponseCode.VALIDATION_ERROR.getCode(), "days 必须在 1~365 之间"); + } List> summary = new ArrayList<>(); LocalDate today = LocalDate.now(); @@ -239,7 +262,7 @@ public class AnalyticsService { Instant start = date.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = date.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); - Map day = new HashMap<>(); + Map day = new LinkedHashMap<>(); day.put("date", date.toString()); day.put("newUsers", countEntityByDateRange(start, end)); day.put("newMatches", countMatchByDateRange(start, end)); @@ -250,97 +273,36 @@ public class AnalyticsService { return summary; } - // ==================== 私有辅助方法 ==================== - - private Map userBasicStats() { - Map s = new HashMap<>(); - s.put("totalUsers", userMapper.selectCount(null)); - s.put("verifiedUsers", userProfileMapper.selectCount( - new LambdaQueryWrapper().ne(UserProfile::getProfileStatus, "rejected"))); - s.put("approvedProfiles", userProfileMapper.selectCount( - new LambdaQueryWrapper().eq(UserProfile::getProfileStatus, "approved"))); - s.put("activeUsers7d", userMapper.selectCount( - new LambdaQueryWrapper() - .ge(User::getLastLoginAt, Instant.now().minusSeconds(7L * 86400)))); - return s; - } - - private Map matchFunnelStats() { - Map s = new HashMap<>(); - s.put("totalLikes", recommendationRecordMapper.selectCount( - new LambdaQueryWrapper().eq(RecommendationRecord::getAction, "liked"))); - s.put("totalMatches", matchMapper.selectCount(null)); - s.put("gettingToKnow", matchMapper.selectCount( - new LambdaQueryWrapper().eq(Match::getStatus, "getting_to_know"))); - s.put("successPairs", successRequestMapper.selectCount( - new LambdaQueryWrapper().eq(RelationshipSuccessRequest::getStatus, "confirmed"))); - return s; - } - - private Map safetyStats() { - Map s = new HashMap<>(); - s.put("pendingReports", reportMapper.countByStatus(Constants.REPORT_STATUS_PENDING)); - s.put("resolvedReports", reportMapper.countByStatus(Constants.REPORT_STATUS_RESOLVED)); - s.put("highRiskUsers", riskActionMapper.selectCount( - new LambdaQueryWrapper() - .in(RiskAction::getActionType, Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_TEMPORARY_SUSPEND) - .eq(RiskAction::getStatus, "active"))); - s.put("bannedUsers", riskActionMapper.selectCount( - new LambdaQueryWrapper() - .eq(RiskAction::getActionType, Constants.RISK_ACTION_BAN) - .eq(RiskAction::getStatus, "active"))); - return s; - } - - private Map activityFunnelStats() { - Map s = new HashMap<>(); - s.put("totalActivities", 0); - s.put("totalRegistrations", 0); - return s; - } - - private List> buildDailyNewUsers(LocalDate since, LocalDate until) { - List> trend = new ArrayList<>(); - LocalDate current = since; - while (!current.isAfter(until)) { - Instant start = current.atStartOfDay(ZoneId.systemDefault()).toInstant(); - Instant end = current.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); - Map point = new HashMap<>(); - point.put("date", current.toString()); - point.put("value", countEntityByDateRange(start, end)); - trend.add(point); - current = current.plusDays(1); - } - return trend; - } - // ==================== V1.1 公益项目统计 ==================== + /** + * Banner 统计。 + * + *

Phase 20 D6 修复:修复前 {@code impressions} / {@code clicks} 被**硬编码为 0**。 + * 0 会被读成"真的没人看到 / 没人点击",而实际是项目内根本不存在曝光与点击的埋点表或上报端点。 + * 两者对运营决策的含义完全相反,因此改为显式标注不可用,绝不返回 0 冒充真实值。 + */ public Map getBannerStats() { - Map stats = new HashMap<>(); - long totalBanners = homeBannerMapper.selectCount(null); - Instant now = Instant.now(); - Instant finalNow = now; - long activeBanners = homeBannerMapper.selectCount( - new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() - .eq(com.danyang.match.platform.banner.entity.HomeBanner::getStatus, "active") - .le(com.danyang.match.platform.banner.entity.HomeBanner::getStartAt, finalNow) - .ge(com.danyang.match.platform.banner.entity.HomeBanner::getEndAt, finalNow) - ); - stats.put("totalBanners", totalBanners); - stats.put("activeBanners", activeBanners); - stats.put("impressions", 0); - stats.put("clicks", 0); + Map stats = new LinkedHashMap<>(); + stats.put("totalBanners", metrics.banners()); + stats.put("activeBanners", metrics.activeBanners()); + // 未埋点:明确置 null 并给出标记,避免被当成真实的 0。 + stats.put("impressions", null); + stats.put("clicks", null); + stats.put("impressionsAvailable", false); + stats.put("clicksAvailable", false); + stats.put("unavailableReason", "未埋点:项目内不存在 Banner 曝光/点击的上报端与事件表," + + "故无法计算。修复前此处返回硬编码 0,会被误读为真实数据。"); return stats; } public Map getServiceStationStats() { - Map stats = new HashMap<>(); + Map stats = new LinkedHashMap<>(); List appointments = serviceAppointmentMapper.selectList( - new LambdaQueryWrapper() - .isNotNull(ServiceAppointment::getServiceLocationId) - .ne(ServiceAppointment::getStatus, "cancelled") - ); + new LambdaQueryWrapper() + .isNotNull(ServiceAppointment::getServiceLocationId) + .ne(ServiceAppointment::getStatus, "cancelled") + .isNull(ServiceAppointment::getDeletedAt)); Set uniqueLocations = new HashSet<>(); for (ServiceAppointment a : appointments) { if (a.getServiceLocationId() != null) uniqueLocations.add(a.getServiceLocationId()); @@ -351,140 +313,181 @@ public class AnalyticsService { } public Map getServiceAppointmentStats() { - Map stats = new HashMap<>(); - List all = serviceAppointmentMapper.selectList( - new LambdaQueryWrapper().isNull(ServiceAppointment::getDeletedAt) - ); - stats.put("total", all.size()); - stats.put("submitted", all.stream().filter(a -> "submitted".equals(a.getStatus())).count()); - stats.put("confirmed", all.stream().filter(a -> "confirmed".equals(a.getStatus())).count()); - stats.put("completed", all.stream().filter(a -> "completed".equals(a.getStatus())).count()); - stats.put("cancelled", all.stream().filter(a -> "cancelled".equals(a.getStatus())).count()); + Map stats = new LinkedHashMap<>(); + stats.put("total", metrics.serviceAppointments()); + stats.put("submitted", countAppointmentsByStatus("submitted")); + stats.put("confirmed", countAppointmentsByStatus("confirmed")); + stats.put("completed", countAppointmentsByStatus("completed")); + stats.put("cancelled", countAppointmentsByStatus("cancelled")); return stats; } public Map getCounselingStats() { - Map stats = new HashMap<>(); - List all = counselingServiceMapper.selectList( - new LambdaQueryWrapper().isNull(CounselingServiceRecord::getDeletedAt) - ); - stats.put("total", all.size()); - stats.put("pending", all.stream().filter(c -> "pending".equals(c.getStatus())).count()); - stats.put("assigned", all.stream().filter(c -> "assigned".equals(c.getStatus())).count()); - stats.put("completed", all.stream().filter(c -> "completed".equals(c.getStatus())).count()); - stats.put("cancelled", all.stream().filter(c -> "cancelled".equals(c.getStatus())).count()); - stats.put("premaritalGuidance", all.stream().filter(c -> "premarital_guidance".equals(c.getServiceType())).count()); + Map stats = new LinkedHashMap<>(); + long total = metrics.counselingServices(); + stats.put("total", total); + stats.put("pending", countCounselingByStatus("pending")); + stats.put("assigned", countCounselingByStatus("assigned")); + stats.put("completed", countCounselingByStatus("completed")); + stats.put("cancelled", countCounselingByStatus("cancelled")); + stats.put("premaritalGuidance", countCounselingByServiceType("premarital_guidance")); return stats; } public Map getPremaritalStats() { - Map stats = new HashMap<>(); - List all = counselingServiceMapper.selectList( - new LambdaQueryWrapper() - .eq(CounselingServiceRecord::getServiceType, "premarital_guidance") - .isNull(CounselingServiceRecord::getDeletedAt) - ); - stats.put("totalRequests", all.size()); - stats.put("completed", all.stream().filter(c -> "completed".equals(c.getStatus())).count()); - stats.put("pending", all.stream().filter(c -> "pending".equals(c.getStatus())).count()); - stats.put("withCompanion", all.stream().filter(c -> c.getCompanionUserId() != null).count()); + Map stats = new LinkedHashMap<>(); + stats.put("totalRequests", countCounselingByServiceType("premarital_guidance")); + stats.put("completed", counselingServiceMapper.selectCount( + new LambdaQueryWrapper() + .eq(CounselingServiceRecord::getServiceType, "premarital_guidance") + .eq(CounselingServiceRecord::getStatus, "completed") + .isNull(CounselingServiceRecord::getDeletedAt))); + stats.put("pending", counselingServiceMapper.selectCount( + new LambdaQueryWrapper() + .eq(CounselingServiceRecord::getServiceType, "premarital_guidance") + .eq(CounselingServiceRecord::getStatus, "pending") + .isNull(CounselingServiceRecord::getDeletedAt))); + stats.put("withCompanion", counselingServiceMapper.selectCount( + new LambdaQueryWrapper() + .eq(CounselingServiceRecord::getServiceType, "premarital_guidance") + .isNotNull(CounselingServiceRecord::getCompanionUserId) + .isNull(CounselingServiceRecord::getDeletedAt))); return stats; } public Map getContentStats() { - Map stats = new HashMap<>(); + Map stats = new LinkedHashMap<>(); List articles = contentArticleMapper.selectList( - new LambdaQueryWrapper() - .eq(ContentArticle::getStatus, "published") - .isNull(ContentArticle::getDeletedAt) - ); - long totalViews = articles.stream() - .mapToInt(a -> a.getViewCount() != null ? a.getViewCount() : 0).sum(); + new LambdaQueryWrapper() + .eq(ContentArticle::getStatus, "published") + .isNull(ContentArticle::getDeletedAt)); stats.put("totalPublishedArticles", articles.size()); - stats.put("totalViews", totalViews); + stats.put("totalViews", metrics.contentViews()); articles.sort((a, b) -> Integer.compare( - b.getViewCount() != null ? b.getViewCount() : 0, - a.getViewCount() != null ? a.getViewCount() : 0)); + b.getViewCount() != null ? b.getViewCount() : 0, + a.getViewCount() != null ? a.getViewCount() : 0)); List> top = articles.stream().limit(5) - .map(a -> { Map m = new HashMap<>(); m.put("id", a.getId()); m.put("title", a.getTitle()); m.put("views", a.getViewCount()); return m; }) - .toList(); + .map(a -> { + Map m = new LinkedHashMap<>(); + m.put("id", a.getId()); + m.put("title", a.getTitle()); + m.put("views", a.getViewCount()); + return m; + }) + .toList(); stats.put("topArticles", top); return stats; } public Map getLocalEligibilityStats() { - Map stats = new HashMap<>(); - List all = localEligibilityMapper.selectList(new LambdaQueryWrapper<>()); - stats.put("total", all.size()); - stats.put("verified", all.stream().filter(l -> "verified".equals(l.getVerificationStatus())).count()); - stats.put("pending", all.stream().filter(l -> "pending_review".equals(l.getVerificationStatus()) || "pending".equals(l.getVerificationStatus())).count()); - stats.put("rejected", all.stream().filter(l -> "rejected".equals(l.getVerificationStatus())).count()); + Map stats = new LinkedHashMap<>(); + long total = localEligibilityMapper.selectCount( + new LambdaQueryWrapper().isNull(LocalEligibility::getDeletedAt)); + stats.put("total", total); + stats.put("verified", localEligibilityMapper.selectCount( + new LambdaQueryWrapper() + .eq(LocalEligibility::getVerificationStatus, "verified") + .isNull(LocalEligibility::getDeletedAt))); + stats.put("pending", localEligibilityMapper.selectCount( + new LambdaQueryWrapper() + .in(LocalEligibility::getVerificationStatus, "pending_review", "pending") + .isNull(LocalEligibility::getDeletedAt))); + stats.put("rejected", localEligibilityMapper.selectCount( + new LambdaQueryWrapper() + .eq(LocalEligibility::getVerificationStatus, "rejected") + .isNull(LocalEligibility::getDeletedAt))); return stats; } public Map getServiceProgramStats() { - Map stats = new HashMap<>(); - List programs = serviceProgramMapper.selectList( - new LambdaQueryWrapper().isNull(ServiceProgram::getDeletedAt)); - List members = serviceProgramMemberMapper.selectList(null); - stats.put("totalPrograms", programs.size()); - stats.put("activePrograms", programs.stream().filter(p -> "active".equals(p.getStatus())).count()); - stats.put("totalMembers", members.stream().filter(m -> "approved".equals(m.getStatus())).count()); - stats.put("pendingApplications", members.stream().filter(m -> "pending".equals(m.getStatus())).count()); + Map stats = new LinkedHashMap<>(); + stats.put("totalPrograms", metrics.servicePrograms()); + stats.put("activePrograms", metrics.activeServicePrograms()); + stats.put("totalMembers", metrics.approvedProgramMembers()); + stats.put("pendingApplications", serviceProgramMemberMapper.selectCount( + new LambdaQueryWrapper() + .eq(ServiceProgramMember::getStatus, "pending") + .isNull(ServiceProgramMember::getDeletedAt))); return stats; } public Map getRegistrationSourceStats() { - Map stats = new HashMap<>(); - List regs = activityRegistrationMapper.selectList( - new LambdaQueryWrapper().ne(ActivityRegistration::getStatus, "cancelled")); - Map bySource = regs.stream() - .collect(java.util.stream.Collectors.groupingBy( - r -> r.getSource() != null ? r.getSource() : "unknown", - java.util.stream.Collectors.counting())); - stats.put("totalRegistrations", regs.size()); + Map stats = new LinkedHashMap<>(); + List sources = List.of("user_self", "matchmaker_referral", "activity_followup", + "service_station", "operator_assisted"); + Map bySource = new LinkedHashMap<>(); + long total = 0; + for (String source : sources) { + long count = activityRegistrationMapper.countRegistrationsBySource(source); + bySource.put(source, count); + total += count; + } + stats.put("totalRegistrations", total); stats.put("bySource", bySource); return stats; } + /** + * 公益服务用户数与服务人次。 + * + *

Phase 20 计划明确要求「分离服务用户数与服务人次」: + * 前者是去重后的**人数**(覆盖面),后者是不去重的**人次**(承载量), + * 两者比值即人均服务次数。修复前只有去重人数、没有服务人次。 + */ public Map getUniqueServiceUsersStats() { - Map stats = new HashMap<>(); - Set cu = counselingServiceMapper.selectList(new LambdaQueryWrapper().isNull(CounselingServiceRecord::getDeletedAt)) - .stream().map(CounselingServiceRecord::getUserId).collect(java.util.stream.Collectors.toSet()); - Set au = serviceAppointmentMapper.selectList(new LambdaQueryWrapper().isNull(ServiceAppointment::getDeletedAt)) - .stream().map(ServiceAppointment::getUserId).collect(java.util.stream.Collectors.toSet()); - Set pu = serviceProgramMemberMapper.selectList( - new LambdaQueryWrapper().eq(ServiceProgramMember::getStatus, "approved")) - .stream().map(ServiceProgramMember::getUserId).collect(java.util.stream.Collectors.toSet()); - Set eu = localEligibilityMapper.selectList(new LambdaQueryWrapper<>()) - .stream().map(LocalEligibility::getUserId).collect(java.util.stream.Collectors.toSet()); - stats.put("counselingUniqueUsers", cu.size()); - stats.put("appointmentUniqueUsers", au.size()); - stats.put("programUniqueUsers", pu.size()); - stats.put("eligibilityUniqueUsers", eu.size()); - Set all = new HashSet<>(); all.addAll(cu); all.addAll(au); all.addAll(pu); all.addAll(eu); - stats.put("totalUniqueServiceUsers", all.size()); + Map stats = new LinkedHashMap<>(); + Set counselingUsers = new HashSet<>(); + counselingServiceMapper.selectList( + new LambdaQueryWrapper() + .isNull(CounselingServiceRecord::getDeletedAt)) + .forEach(c -> counselingUsers.add(c.getUserId())); + Set appointmentUsers = new HashSet<>(); + serviceAppointmentMapper.selectList( + new LambdaQueryWrapper() + .isNull(ServiceAppointment::getDeletedAt)) + .forEach(a -> appointmentUsers.add(a.getUserId())); + Set programUsers = new HashSet<>(); + serviceProgramMemberMapper.selectList( + new LambdaQueryWrapper() + .eq(ServiceProgramMember::getStatus, "approved") + .isNull(ServiceProgramMember::getDeletedAt)) + .forEach(m -> programUsers.add(m.getUserId())); + Set eligibilityUsers = new HashSet<>(); + localEligibilityMapper.selectList( + new LambdaQueryWrapper() + .isNull(LocalEligibility::getDeletedAt)) + .forEach(e -> eligibilityUsers.add(e.getUserId())); + + stats.put("counselingUniqueUsers", sizeOf(counselingUsers)); + stats.put("appointmentUniqueUsers", sizeOf(appointmentUsers)); + stats.put("programUniqueUsers", sizeOf(programUsers)); + stats.put("eligibilityUniqueUsers", sizeOf(eligibilityUsers)); + + // 服务用户数(跨域去重) + stats.put("totalUniqueServiceUsers", metrics.uniqueServiceUsers()); + // 服务人次(不去重)—— 与上面的"用户数"是两个不同指标 + stats.put("totalServiceVisits", metrics.serviceVisits()); return stats; } public Map getEffectiveConnectionStats() { - Map stats = new HashMap<>(); - long activeMatches = matchMapper.selectCount( - new LambdaQueryWrapper().eq(Match::getStatus, "active")); - long matchesWithChat = conversationMapper.selectCount( - new LambdaQueryWrapper().in(Conversation::getStatus, "active", "muted")); - long successRequests = successRequestMapper.selectCount( - new LambdaQueryWrapper().eq(RelationshipSuccessRequest::getStatus, "confirmed")); + Map stats = new LinkedHashMap<>(); + long activeMatches = matchMapper.countByStatus("active"); + long matchesWithChat = metrics.chatsStarted(); + long successRequests = metrics.successfulMatches(); stats.put("activeMatches", activeMatches); stats.put("matchesWithChat", matchesWithChat); stats.put("successConnections", successRequests); double rate = activeMatches > 0 ? (double) matchesWithChat / activeMatches : 0; stats.put("effectiveConnectionRate", Math.round(rate * 1000.0) / 10.0); + // 口径未确认,必须显式标注,禁止对外宣传。 + stats.put("effectiveMatchesProvisional", true); + stats.put("provisionalNote", "「有效交友匹配」口径尚未经业务确认,仅内部参考,禁止对外宣传。"); return stats; } public Map getV11ServiceDashboard() { - Map d = new HashMap<>(); + Map d = new LinkedHashMap<>(); d.put("banners", getBannerStats()); d.put("serviceStations", getServiceStationStats()); d.put("serviceAppointments", getServiceAppointmentStats()); @@ -496,50 +499,100 @@ public class AnalyticsService { d.put("registrationSources", getRegistrationSourceStats()); d.put("uniqueServiceUsers", getUniqueServiceUsersStats()); d.put("effectiveConnections", getEffectiveConnectionStats()); + // 活跃活动漏斗(含互选/成功分离) + d.put("activityFunnel", getActivityFunnel()); return d; } + // ==================== 私有辅助 ==================== - private List> buildDailyNewMatches(LocalDate since, LocalDate until) { + private static int sizeOf(Set set) { + set.remove(null); + return set.size(); + } + + /** 统一的比率格式化,保证同一比率在任何视图里呈现一致。 */ + private static String rate(long numerator, long denominator) { + if (denominator <= 0) return "0%"; + return String.format("%.1f%%", 100.0 * numerator / denominator); + } + + private long countAppointmentsByStatus(String status) { + return serviceAppointmentMapper.selectCount( + new LambdaQueryWrapper() + .eq(ServiceAppointment::getStatus, status) + .isNull(ServiceAppointment::getDeletedAt)); + } + + private long countCounselingByStatus(String status) { + return counselingServiceMapper.selectCount( + new LambdaQueryWrapper() + .eq(CounselingServiceRecord::getStatus, status) + .isNull(CounselingServiceRecord::getDeletedAt)); + } + + private long countCounselingByServiceType(String serviceType) { + return counselingServiceMapper.selectCount( + new LambdaQueryWrapper() + .eq(CounselingServiceRecord::getServiceType, serviceType) + .isNull(CounselingServiceRecord::getDeletedAt)); + } + + private List> buildDailyNewUsers(LocalDate since, LocalDate until) { + return buildDailyTrend(since, until, this::countEntityByDateRange); + } + + private List> buildDailyNewMatches(LocalDate since, LocalDate until) { + return buildDailyTrend(since, until, this::countMatchByDateRange); + } + + private List> buildDailyTrend(LocalDate since, LocalDate until, + java.util.function.BiFunction counter) { List> trend = new ArrayList<>(); LocalDate current = since; while (!current.isAfter(until)) { Instant start = current.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = current.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); - Map point = new HashMap<>(); + Map point = new LinkedHashMap<>(); point.put("date", current.toString()); - point.put("value", countMatchByDateRange(start, end)); + point.put("value", counter.apply(start, end)); trend.add(point); current = current.plusDays(1); } return trend; } + // 以下四个"按日区间"计数同样必须显式过滤软删, + // 否则每日汇总里的"新增用户/新增 Match"会把已删除记录继续算进当天。 private long countEntityByDateRange(Instant start, Instant end) { return userMapper.selectCount( new LambdaQueryWrapper() .ge(User::getCreatedAt, start) - .le(User::getCreatedAt, end)); + .le(User::getCreatedAt, end) + .isNull(User::getDeletedAt)); } private long countMatchByDateRange(Instant start, Instant end) { return matchMapper.selectCount( new LambdaQueryWrapper() .ge(Match::getMatchedAt, start) - .le(Match::getMatchedAt, end)); + .le(Match::getMatchedAt, end) + .isNull(Match::getDeletedAt)); } private long countConversationByDateRange(Instant start, Instant end) { return conversationMapper.selectCount( new LambdaQueryWrapper() .ge(Conversation::getCreatedAt, start) - .le(Conversation::getCreatedAt, end)); + .le(Conversation::getCreatedAt, end) + .isNull(Conversation::getDeletedAt)); } private long countReportByDateRange(Instant start, Instant end) { return reportMapper.selectCount( new LambdaQueryWrapper() .ge(Report::getCreatedAt, start) - .le(Report::getCreatedAt, end)); + .le(Report::getCreatedAt, end) + .isNull(Report::getDeletedAt)); } } diff --git a/backend/src/main/java/com/danyang/match/platform/analytics/metric/AnalyticsMetricsCalculator.java b/backend/src/main/java/com/danyang/match/platform/analytics/metric/AnalyticsMetricsCalculator.java new file mode 100644 index 0000000..df08a62 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/analytics/metric/AnalyticsMetricsCalculator.java @@ -0,0 +1,380 @@ +package com.danyang.match.platform.analytics.metric; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.danyang.match.platform.activity.entity.Activity; +import com.danyang.match.platform.activity.mapper.ActivityMapper; +import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper; +import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper; +import com.danyang.match.platform.banner.entity.HomeBanner; +import com.danyang.match.platform.banner.mapper.HomeBannerMapper; +import com.danyang.match.platform.chat.mapper.ConversationMapper; +import com.danyang.match.platform.chat.mapper.MessageMapper; +import com.danyang.match.platform.content.entity.ContentArticle; +import com.danyang.match.platform.content.mapper.ContentArticleMapper; +import com.danyang.match.platform.counseling.entity.CounselingServiceRecord; +import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper; +import com.danyang.match.platform.local_eligibility.entity.LocalEligibility; +import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper; +import com.danyang.match.platform.match.mapper.MatchMapper; +import com.danyang.match.platform.match.mapper.RecommendationRecordMapper; +import com.danyang.match.platform.profile.entity.UserProfile; +import com.danyang.match.platform.profile.mapper.UserProfileMapper; +import com.danyang.match.platform.risk.entity.RiskAction; +import com.danyang.match.platform.risk.mapper.ReportMapper; +import com.danyang.match.platform.risk.mapper.RiskActionMapper; +import com.danyang.match.platform.service_appointment.entity.ServiceAppointment; +import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper; +import com.danyang.match.platform.service_program.entity.ServiceProgram; +import com.danyang.match.platform.service_program.entity.ServiceProgramMember; +import com.danyang.match.platform.service_program.mapper.ServiceProgramMapper; +import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper; +import com.danyang.match.platform.shared.constant.Constants; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; +import com.danyang.match.platform.verification.mapper.UserIdentityMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.HashSet; +import java.util.Set; + +/** + * 统一口径计算层 —— 所有统计数字的**唯一**出处。 + * + *

为什么要单独抽一层:Phase 20 的 Gate 是「Dashboard 和专项页面相同指标必须一致」。 + * 修复前 {@code AnalyticsService} 里同一个指标有 2~3 份实现 + * ({@code userBasicStats} / {@code getUserFunnel} / {@code getV11ServiceDashboard}), + * 名字还各不相同({@code verifiedUsers} vs {@code verifiedCount}), + * 只要有一处漏改就永久不一致,且没有任何测试能发现。 + * + *

本类中每个方法都对应 {@link MetricCatalog} 里的一个已登记指标, + * 方法上标注的指标编码即其口径来源。 + * + *

为什么每个查询都显式写 {@code deleted_at IS NULL}

+ * + *

Phase 20 D12:{@code BaseEntity.deletedAt} **没有** {@code @TableLogic} 注解, + * 项目也没有配置 MyBatis-Plus 的全局逻辑删除字段。这意味着 + * {@code selectCount(null)} 生成的是 {@code SELECT COUNT(*) FROM t} —— + * **软删除的数据会被完整计入统计**。 + * + *

实测影响:用户注销/被删、Banner 下架删除、活动软删之后, + * 所有看板数字依然把它们算在内。这类偏差不会有任何报错, + * 只会让"累计注册用户数"永远只增不减。因此本层所有查询统一显式过滤。 + * + *

关于性能:少量需要"跨域去重"的指标(如服务用户数)仍采用 + * 「查出各域 user_id 再内存去重」的写法。这在单区县体量下无问题; + * 若数据量增长到十万级,应改为数据库侧 {@code UNION} 去重聚合。 + */ +@Component +@RequiredArgsConstructor +public class AnalyticsMetricsCalculator { + + private final UserMapper userMapper; + private final UserProfileMapper userProfileMapper; + private final UserIdentityMapper userIdentityMapper; + private final MessageMapper messageMapper; + private final ConversationMapper conversationMapper; + private final MatchMapper matchMapper; + private final RecommendationRecordMapper recommendationRecordMapper; + private final ActivityMapper activityMapper; + private final ActivityRegistrationMapper activityRegistrationMapper; + private final ActivityMutualConsentMapper activityMutualConsentMapper; + private final ServiceAppointmentMapper serviceAppointmentMapper; + private final CounselingServiceMapper counselingServiceMapper; + private final ServiceProgramMapper serviceProgramMapper; + private final ServiceProgramMemberMapper serviceProgramMemberMapper; + private final LocalEligibilityMapper localEligibilityMapper; + private final ContentArticleMapper contentArticleMapper; + private final HomeBannerMapper homeBannerMapper; + private final ReportMapper reportMapper; + private final RiskActionMapper riskActionMapper; + + // ==================== 用户域 ==================== + + /** {@code user.registered.total} —— 累计注册用户数(不含软删)。 */ + public long registeredUsers() { + return userMapper.selectCount(new LambdaQueryWrapper().isNull(User::getDeletedAt)); + } + + /** + * {@code user.identity.verified} —— 实名认证通过用户数。 + * + *

Phase 20 D1 修复点:此前该口径被写成 + * {@code user_profiles.profile_status != 'rejected'}("资料没被退回"), + * 把 in_progress(资料还在填)、need_edit(被退回待改)等状态全都算成了"已实名", + * 与 Gate「注册10 / 实名7 / 审核通过4」中的实名口径完全不符。 + */ + public long realNameVerifiedUsers() { + return userIdentityMapper.countVerifiedUsers(); + } + + /** {@code user.profile.approved} —— 资料审核通过人数(不含软删)。 */ + public long profileApprovedUsers() { + return userProfileMapper.selectCount( + new LambdaQueryWrapper() + .eq(UserProfile::getProfileStatus, "approved") + .isNull(UserProfile::getDeletedAt)); + } + + /** {@code user.active.7d} —— 近 7 日活跃用户数(不含软删)。 */ + public long activeUsers7d() { + return userMapper.selectCount( + new LambdaQueryWrapper() + .ge(User::getLastLoginAt, Instant.now().minusSeconds(7L * 86400)) + .isNull(User::getDeletedAt)); + } + + // ==================== 推荐 / 匹配域 ==================== + + /** {@code like.total} —— 用户 Like 总数(单向行为)。 */ + public long likes() { + return recommendationRecordMapper.countByAction("liked"); + } + + /** {@code recommendation.shown.total} —— 已展示推荐总数。 */ + public long recommendationsShown() { + return recommendationRecordMapper.countShown(); + } + + /** + * {@code recommendation.shown.matchmaker} —— 红娘发出的推荐数。 + * + *

按 {@code source='matchmaker'} 过滤,避免把用户端自动推荐(daily / discover) + * 算进红娘业绩。 + */ + public long matchmakerRecommendations() { + return recommendationRecordMapper.countShownBySource("matchmaker"); + } + + /** {@code match.total} —— Match 总数(全来源)。 */ + public long matches() { + return matchMapper.countAllMatches(); + } + + /** 按来源统计 Match 数({@code user_like} / {@code activity_mutual} / {@code matchmaker})。 */ + public long matchesBySource(String source) { + return matchMapper.countBySource(source); + } + + /** {@code match.successful} —— 成功牵手数。 */ + public long successfulMatches() { + return matchMapper.countByStatus("successful"); + } + + /** {@code match.effective}(PROVISIONAL)—— 已建 Match 且有过真人消息的配对数。 */ + public long effectiveMatches() { + // PROVISIONAL:口径未确认(「有效」是否要求见面/持续 N 天由业务方裁决)。 + // 当前取「真人会话数」与「Match 数」的较小值作为保守估计, + // 绝不可当作正式成功率对外引用。 + return Math.min(messageMapper.countConversationsWithRealMessages(), matchMapper.countAllMatches()); + } + + /** {@code chat.started} —— 开启聊天会话数(排除 system / risk_notice)。 */ + public long chatsStarted() { + return messageMapper.countConversationsWithRealMessages(); + } + + /** {@code chat.conversation.total} —— 会话总数(不含软删)。 */ + public long conversations() { + return conversationMapper.selectCount( + new LambdaQueryWrapper() + .isNull(com.danyang.match.platform.chat.entity.Conversation::getDeletedAt)); + } + + // ==================== 活动域 ==================== + + /** {@code activity.total} —— 活动总数(不含软删)。 */ + public long activities() { + return activityMapper.selectCount(new LambdaQueryWrapper().isNull(Activity::getDeletedAt)); + } + + /** {@code activity.registration.total} —— 活动报名人次(不去重)。 */ + public long activityRegistrations() { + return activityRegistrationMapper.countAllRegistrations(); + } + + /** {@code activity.registration.approved} —— 报名通过人次。 */ + public long approvedActivityRegistrations() { + return activityRegistrationMapper.countRegistrationsByStatus("approved"); + } + + /** {@code activity.registration.checked_in} —— 实际签到人次。 */ + public long checkedInRegistrations() { + return activityRegistrationMapper.countCheckedIn(); + } + + /** {@code activity.mutual.both_confirmed} —— 活动互选成立对数(严格双确认)。 */ + public long mutualBothConfirmed() { + return activityMutualConsentMapper.countBothConfirmed(); + } + + /** {@code activity.mutual.match} —— 互选后建立 Match 数。 */ + public long mutualMatches() { + return matchMapper.countBySource("activity_mutual"); + } + + /** + * {@code activity.mutual.successful} —— 互选后成功牵手数。 + * + *

与 {@link #mutualBothConfirmed()} **严格区分**: + * 10 对互选成立 ≠ 10 对成功,中间还有聊天、相处、确认关系。 + */ + public long mutualSuccessful() { + return matchMapper.countBySourceAndStatus("activity_mutual", "successful"); + } + + // ==================== 公益服务域 ==================== + + /** {@code service.appointment.total} —— 服务预约人次(不含已取消、不含软删)。 */ + public long serviceAppointments() { + return serviceAppointmentMapper.selectCount( + new LambdaQueryWrapper() + .ne(ServiceAppointment::getStatus, "cancelled") + .isNull(ServiceAppointment::getDeletedAt)); + } + + /** {@code counseling.total} —— 婚恋咨询受理量(不含软删)。 */ + public long counselingServices() { + return counselingServiceMapper.selectCount( + new LambdaQueryWrapper() + .isNull(CounselingServiceRecord::getDeletedAt)); + } + + /** {@code service.program.member.approved} —— 专项项目在册成员数(不含软删)。 */ + public long approvedProgramMembers() { + return serviceProgramMemberMapper.selectCount( + new LambdaQueryWrapper() + .eq(ServiceProgramMember::getStatus, "approved") + .isNull(ServiceProgramMember::getDeletedAt)); + } + + /** + * {@code service.visit.total} —— 公益服务人次(**不去重**)。 + * + *

与 {@link #uniqueServiceUsers()} 是**两个不同的指标**: + * 本指标衡量服务承载量,同一用户来 3 次算 3 人次。 + */ + public long serviceVisits() { + return serviceAppointments() + + counselingServices() + + approvedProgramMembers() + + localEligibilityCount(); + } + + /** + * {@code service.user.unique} —— 服务用户数(**跨域去重**)。 + * + *

同一用户既预约又咨询又参加项目,只算 1 人。与 {@link #serviceVisits()} + * 的比值即人均服务次数,因此两者绝不可混用或互相替代。 + */ + public long uniqueServiceUsers() { + Set users = new HashSet<>(); + serviceAppointmentMapper.selectList( + new LambdaQueryWrapper() + .isNull(ServiceAppointment::getDeletedAt)) + .forEach(a -> users.add(a.getUserId())); + counselingServiceMapper.selectList( + new LambdaQueryWrapper() + .isNull(CounselingServiceRecord::getDeletedAt)) + .forEach(c -> users.add(c.getUserId())); + serviceProgramMemberMapper.selectList( + new LambdaQueryWrapper() + .eq(ServiceProgramMember::getStatus, "approved") + .isNull(ServiceProgramMember::getDeletedAt)) + .forEach(m -> users.add(m.getUserId())); + localEligibilityMapper.selectList( + new LambdaQueryWrapper().isNull(LocalEligibility::getDeletedAt)) + .forEach(e -> users.add(e.getUserId())); + users.remove(null); + return users.size(); + } + + private long localEligibilityCount() { + return localEligibilityMapper.selectCount( + new LambdaQueryWrapper().isNull(LocalEligibility::getDeletedAt)); + } + + /** 专项项目总数(不含软删),供服务仪表盘使用。 */ + public long servicePrograms() { + return serviceProgramMapper.selectCount( + new LambdaQueryWrapper().isNull(ServiceProgram::getDeletedAt)); + } + + /** 状态为 active 的专项项目数(不含软删)。 */ + public long activeServicePrograms() { + return serviceProgramMapper.selectCount( + new LambdaQueryWrapper() + .eq(ServiceProgram::getStatus, "active") + .isNull(ServiceProgram::getDeletedAt)); + } + + /** {@code content.published.total} —— 已发布内容数(不含软删)。 */ + public long publishedArticles() { + return contentArticleMapper.selectCount( + new LambdaQueryWrapper() + .eq(ContentArticle::getStatus, "published") + .isNull(ContentArticle::getDeletedAt)); + } + + /** {@code content.views.total} —— 内容累计阅读量。 */ + public long contentViews() { + return contentArticleMapper.selectList( + new LambdaQueryWrapper() + .eq(ContentArticle::getStatus, "published") + .isNull(ContentArticle::getDeletedAt)) + .stream() + .mapToLong(a -> a.getViewCount() != null ? a.getViewCount() : 0) + .sum(); + } + + // ==================== Banner 域 ==================== + + /** {@code banner.total} —— Banner 总数(不含软删)。 */ + public long banners() { + return homeBannerMapper.selectCount( + new LambdaQueryWrapper().isNull(HomeBanner::getDeletedAt)); + } + + /** {@code banner.active} —— 生效中的 Banner 数(不含软删)。 */ + public long activeBanners() { + Instant now = Instant.now(); + return homeBannerMapper.selectCount( + new LambdaQueryWrapper() + .eq(HomeBanner::getStatus, "active") + .isNull(HomeBanner::getDeletedAt) + .le(HomeBanner::getStartAt, now) + .ge(HomeBanner::getEndAt, now)); + } + + // ==================== 安全域 ==================== + + /** {@code risk.report.pending} —— 待处理举报数(自定义 SQL 已含软删过滤)。 */ + public long pendingReports() { + return reportMapper.countByStatus(Constants.REPORT_STATUS_PENDING); + } + + /** {@code risk.report.resolved} —— 已处理举报数(自定义 SQL 已含软删过滤)。 */ + public long resolvedReports() { + return reportMapper.countByStatus(Constants.REPORT_STATUS_RESOLVED); + } + + /** {@code risk.user.high_risk} —— 高风险用户数(生效中的封禁或临时停用,不含软删)。 */ + public long highRiskUsers() { + return riskActionMapper.selectCount( + new LambdaQueryWrapper() + .in(RiskAction::getActionType, + Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_TEMPORARY_SUSPEND) + .eq(RiskAction::getStatus, "active") + .isNull(RiskAction::getDeletedAt)); + } + + /** 仅封禁用户数(高风险用户的子集)。 */ + public long bannedUsers() { + return riskActionMapper.selectCount( + new LambdaQueryWrapper() + .eq(RiskAction::getActionType, Constants.RISK_ACTION_BAN) + .eq(RiskAction::getStatus, "active") + .isNull(RiskAction::getDeletedAt)); + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/analytics/metric/MetricCatalog.java b/backend/src/main/java/com/danyang/match/platform/analytics/metric/MetricCatalog.java new file mode 100644 index 0000000..0367ceb --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/analytics/metric/MetricCatalog.java @@ -0,0 +1,336 @@ +package com.danyang.match.platform.analytics.metric; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 全部统计指标的注册表 —— Phase 20 的**单一事实来源**。 + * + *

修复前的状态:同一个概念在 {@code AnalyticsService} 里有 2~3 份独立实现 + * ({@code userBasicStats()} / {@code getUserFunnel()} / {@code getV11ServiceDashboard()} + * 各算一遍),且**同名不同义**(Dashboard 叫 {@code totalLikes},漏斗叫 {@code totalLiked}; + * Dashboard 叫 {@code verifiedUsers},漏斗叫 {@code verifiedCount})。 + * Phase 20 的 Gate 明确要求「Dashboard 和专项页面相同指标必须一致」, + * 靠"记得同步改两处"是守不住的,必须有唯一来源。 + * + *

本类同时是 {@code docs/ANALYTICS_METRICS.md} 的生成依据, + * 由 {@code MetricCatalogGuardTest} 校验「文档覆盖全部指标」与「口径不自相矛盾」。 + */ +public final class MetricCatalog { + + /** 本项目统计口径统一时区(丹阳本地时间;跨时区部署会改变"某天"的边界)。 */ + public static final String TIMEZONE = "Asia/Shanghai"; + + /** 当前口径版本;任何口径变更都必须递增,否则历史数据不可比。 */ + public static final String VERSION = "v1"; + + private MetricCatalog() { + } + + private static final List ALL = List.of( + + // ==================== 用户域 ==================== + new MetricDefinition( + "user.registered.total", "累计注册用户数", + "所有已注册用户账号数。含未完善资料、未实名、被封禁的账号(只要账号行存在即计入)。", + "COUNT(users.id)", "—", "users.id", "users.created_at", TIMEZONE, VERSION, "平台运营", + MetricStatus.OFFICIAL, "users"), + + new MetricDefinition( + "user.identity.verified", "实名认证通过用户数", + "实名认证状态为 verified 的用户数。不含 verifying(认证中)、failed(失败)、" + + "manual_review(人工复核)、not_verified。**不等于**资料审核通过数。", + "COUNT(DISTINCT user_identities.user_id) WHERE verification_status='verified'", + "—", "user_identities.user_id", "user_identities.verified_at", TIMEZONE, VERSION, "平台运营", + MetricStatus.OFFICIAL, "user_identities"), + + new MetricDefinition( + "user.profile.approved", "资料审核通过人数", + "资料审核状态为 approved 的用户数。仅表示资料通过审核,**不表示已实名**。", + "COUNT(DISTINCT user_profiles.user_id) WHERE profile_status='approved'", + "—", "user_profiles.user_id", "user_profiles.updated_at", TIMEZONE, VERSION, "审核员", + MetricStatus.OFFICIAL, "user_profiles"), + + new MetricDefinition( + "user.active.7d", "近 7 日活跃用户数", + "最近 7 天内登录过的用户数。", + "COUNT(users.id) WHERE last_login_at >= NOW() - 7d", "—", "users.id", + "users.last_login_at", TIMEZONE, VERSION, "平台运营", + MetricStatus.OFFICIAL, "users"), + + // ==================== 推荐 / 匹配域 ==================== + new MetricDefinition( + "recommendation.shown.total", "已展示推荐总数", + "系统向用户展示过的推荐记录数(含全部来源 daily/discover/matchmaker/activity)。", + "COUNT(recommendation_records.id) WHERE shown_at IS NOT NULL", "—", + "recommendation_records.id", "recommendation_records.shown_at", TIMEZONE, VERSION, "平台运营", + MetricStatus.OFFICIAL, "recommendation_records"), + + new MetricDefinition( + "recommendation.shown.matchmaker", "红娘发出的推荐数", + "来源为 matchmaker 的推荐记录数 = 红娘实际推给用户的候选数。" + + "**不含**红娘在候选池里浏览看过但未发出的候选,也不含用户端自动推荐。", + "COUNT(recommendation_records.id) WHERE source='matchmaker' AND shown_at IS NOT NULL", "—", + "recommendation_records.id", "recommendation_records.shown_at", TIMEZONE, VERSION, "红娘主管", + MetricStatus.OFFICIAL, "recommendation_records"), + + new MetricDefinition( + "like.total", "用户 Like 总数", + "action='liked' 的推荐记录数(单向行为,A 喜欢 B 记 1 条)。" + + "两条 Like(A→B 与 B→A)才可能形成 1 个 Match,因此 Like 数约为 Mutual Match 数的 2 倍量级。", + "COUNT(recommendation_records.id) WHERE action='liked'", "—", + "recommendation_records.id", "recommendation_records.action_at", TIMEZONE, VERSION, "平台运营", + MetricStatus.OFFICIAL, "recommendation_records"), + + new MetricDefinition( + "match.total", "Match 总数", + "全部来源的 Match 记录数(去重键为 matches.id,同一对用户只有一行,重新牵手递增 episode 而不新增行)。", + "COUNT(matches.id)", "—", "matches.id", "matches.matched_at", TIMEZONE, VERSION, "平台运营", + MetricStatus.OFFICIAL, "matches"), + + new MetricDefinition( + "match.source.user_like", "用户互相 Like 形成的 Match 数", + "source='user_like' 的 Match 数。与 Match 总数分开统计," + + "避免把红娘促成、活动互选的量算进「用户自主匹配」的成果。", + "COUNT(matches.id) WHERE source='user_like'", "—", "matches.id", "matches.matched_at", + TIMEZONE, VERSION, "平台运营", MetricStatus.OFFICIAL, "matches"), + + new MetricDefinition( + "match.source.activity_mutual", "活动互选形成的 Match 数", + "source='activity_mutual' 的 Match 数。注意:它 ≤ 活动互选对数," + + "且**不等于**活动互选的成功数(见 activity.mutual.successful)。", + "COUNT(matches.id) WHERE source='activity_mutual'", "—", "matches.id", "matches.matched_at", + TIMEZONE, VERSION, "活动管理员", MetricStatus.OFFICIAL, "matches"), + + new MetricDefinition( + "match.source.matchmaker", "红娘促成的 Match 数", + "source='matchmaker' 的 Match 数。", + "COUNT(matches.id) WHERE source='matchmaker'", "—", "matches.id", "matches.matched_at", + TIMEZONE, VERSION, "红娘主管", MetricStatus.OFFICIAL, "matches"), + + new MetricDefinition( + "match.successful", "成功牵手数", + "status='successful' 的 Match 数。仅表示双方在平台上确认了关系结果," + + "不涉及线下真实性核验。", + "COUNT(matches.id) WHERE status='successful'", "—", "matches.id", + "matches.successful_at", TIMEZONE, VERSION, "平台运营", + MetricStatus.OFFICIAL, "matches"), + + new MetricDefinition( + "match.effective", "有效交友匹配数", + "**口径未确认(provisional)**。当前近似定义为:已建立 Match 且双方在会话中" + + "产生过真人消息(排除 system/risk_notice)的配对数。" + + "「有效」到哪一步算数(是否要求见面、是否要求持续 N 天)业务方尚未裁决," + + "**禁止作为正式成功率口径,禁止对外宣传**。", + "COUNT(matches.id) WHERE status IN ('active','getting_to_know','successful') " + + "AND EXISTS(真人消息)", "match.total", "matches.id", "matches.matched_at", + TIMEZONE, VERSION, "平台运营", + MetricStatus.PROVISIONAL, "matches"), + + // ==================== 聊天域 ==================== + new MetricDefinition( + "chat.started", "开启聊天会话数", + "**至少存在一条真人消息**(message_type 不在 system/risk_notice 内)的会话数。" + + "系统通知(如「你们已互相喜欢」)不作为开启聊天的依据。", + "COUNT(DISTINCT messages.conversation_id) WHERE message_type NOT IN ('system','risk_notice')", + "—", "messages.conversation_id", "messages.created_at", TIMEZONE, VERSION, "平台运营", + MetricStatus.OFFICIAL, "messages"), + + new MetricDefinition( + "chat.conversation.total", "会话总数", + "已创建的会话数(含仅由系统消息开头的会话)。与 chat.started 的差额即" + + "「建了会话但双方从未真正开口」的数量。", + "COUNT(conversations.id)", "—", "conversations.id", "conversations.created_at", + TIMEZONE, VERSION, "平台运营", MetricStatus.OFFICIAL, "conversations"), + + // ==================== 活动域 ==================== + new MetricDefinition( + "activity.total", "活动总数", + "全部已创建活动数(含草稿、已结束)。", + "COUNT(activities.id)", "—", "activities.id", "activities.created_at", TIMEZONE, VERSION, + "活动管理员", MetricStatus.OFFICIAL, "activities"), + + new MetricDefinition( + "activity.registration.total", "活动报名人次", + "报名记录总数(含 pending/approved/rejected/cancelled)。" + + "同一用户报多个活动计多次 —— 这是**人次**而非人数。", + "COUNT(activity_registrations.id)", "—", "activity_registrations.id", + "activity_registrations.registered_at", TIMEZONE, VERSION, "活动管理员", + MetricStatus.OFFICIAL, "activity_registrations"), + + new MetricDefinition( + "activity.registration.approved", "活动报名通过人次", + "status='approved' 的报名记录数。", + "COUNT(activity_registrations.id) WHERE status='approved'", "—", + "activity_registrations.id", "activity_registrations.reviewed_at", TIMEZONE, VERSION, + "活动管理员", MetricStatus.OFFICIAL, "activity_registrations"), + + new MetricDefinition( + "activity.registration.checked_in", "活动实际签到人次", + "checkin_status='checked_in' 的报名记录数。与报名通过人次的差额即活动到场流失。", + "COUNT(activity_registrations.id) WHERE checkin_status='checked_in'", "—", + "activity_registrations.id", "activity_registrations.checkin_at", TIMEZONE, VERSION, + "活动管理员", MetricStatus.OFFICIAL, "activity_registrations"), + + new MetricDefinition( + "activity.mutual.both_confirmed", "活动互选成立对数", + "两侧均 accepted 的互选记录数(严格互选,**不是**「有人点了同意」)。" + + "这是互选环节的产出。", + "COUNT(activity_mutual_connection_consents.id) WHERE user_a_status='accepted' " + + "AND user_b_status='accepted'", "—", "activity_mutual_connection_consents.id", + "activity_mutual_connection_consents.both_confirmed_at", TIMEZONE, VERSION, "活动管理员", + MetricStatus.OFFICIAL, "activity_mutual_connection_consents"), + + new MetricDefinition( + "activity.mutual.match", "互选后建立 Match 数", + "source='activity_mutual' 的 Match 数。应当 ≤ 活动互选成立对数;" + + "若大于,说明存在未经双确认就建 Match 的路径。", + "COUNT(matches.id) WHERE source='activity_mutual'", "activity.mutual.both_confirmed", + "matches.id", "matches.matched_at", TIMEZONE, VERSION, "活动管理员", + MetricStatus.OFFICIAL, "matches"), + + new MetricDefinition( + "activity.mutual.successful", "互选后成功牵手数", + "source='activity_mutual' 且 status='successful' 的 Match 数。" + + "**明确区别于**活动互选成立对数:10 对互选成立远不等于 10 对成功," + + "中间还要经过聊天、相处、确认关系。绝不可互相替代引用。", + "COUNT(matches.id) WHERE source='activity_mutual' AND status='successful'", + "activity.mutual.both_confirmed", "matches.id", "matches.successful_at", TIMEZONE, VERSION, + "活动管理员", MetricStatus.OFFICIAL, "matches"), + + // ==================== 公益服务域 ==================== + new MetricDefinition( + "service.visit.total", "公益服务人次", + "全部公益服务记录的条数之和(预约 + 咨询 + 专项项目参与 + 本地资格办理)。" + + "**不去重** —— 同一用户来 3 次就是 3 人次。这是服务承载量的口径。", + "Σ(预约, 咨询, 项目参与, 资格办理) 记录数", "—", "各域记录 id(**不去重**)", + "各域 created_at", TIMEZONE, VERSION, "平台运营", + MetricStatus.OFFICIAL, "service_appointments + counseling_services + " + + "service_program_members + user_local_eligibilities"), + + new MetricDefinition( + "service.user.unique", "服务用户数", + "**跨全部公益服务域去重**后的用户数。同一用户既预约又咨询又参加项目,只算 1 人。" + + "这是服务覆盖面的口径,与 service.visit.total 是**两个不同的指标**," + + "两者的比值即人均服务次数。", + "COUNT(DISTINCT user_id) over (预约 ∪ 咨询 ∪ 项目参与 ∪ 资格办理)", "—", + "user_id(**跨域去重**)", "各域 created_at", TIMEZONE, VERSION, "平台运营", + MetricStatus.OFFICIAL, "service_appointments + counseling_services + " + + "service_program_members + user_local_eligibilities"), + + new MetricDefinition( + "service.appointment.total", "服务预约人次", + "服务站预约记录数(不含已取消)。", + "COUNT(service_appointments.id) WHERE status<>'cancelled'", "—", "service_appointments.id", + "service_appointments.created_at", TIMEZONE, VERSION, "服务站运营", + MetricStatus.OFFICIAL, "service_appointments"), + + new MetricDefinition( + "counseling.total", "婚恋咨询受理量", + "婚恋咨询记录数(含婚前辅导)。", + "COUNT(counseling_services.id)", "—", "counseling_services.id", + "counseling_services.created_at", TIMEZONE, VERSION, "咨询管理员", + MetricStatus.OFFICIAL, "counseling_services"), + + new MetricDefinition( + "service.program.member.approved", "专项服务项目在册成员数", + "status='approved' 的项目成员记录数。成员身份**不得**被用作对外的用户价值标签。", + "COUNT(service_program_members.id) WHERE status='approved'", "—", + "service_program_members.id", "service_program_members.created_at", TIMEZONE, VERSION, + "平台运营", MetricStatus.OFFICIAL, "service_program_members"), + + new MetricDefinition( + "content.published.total", "已发布内容数", + "status='published' 的内容文章数(婚恋课堂 / 反诈 / 公益信息)。", + "COUNT(content_articles.id) WHERE status='published'", "—", "content_articles.id", + "content_articles.published_at", TIMEZONE, VERSION, "内容编辑", + MetricStatus.OFFICIAL, "content_articles"), + + new MetricDefinition( + "content.views.total", "内容累计阅读量", + "已发布文章的 view_count 之和。", + "SUM(content_articles.view_count) WHERE status='published'", "—", "content_articles.id", + "content_articles.published_at", TIMEZONE, VERSION, "内容编辑", + MetricStatus.OFFICIAL, "content_articles"), + + // ==================== Banner 域 ==================== + new MetricDefinition( + "banner.total", "Banner 总数", + "全部 Banner 数(含草稿与已下架,不含软删)。", + "COUNT(home_banners.id) WHERE deleted_at IS NULL", "—", "home_banners.id", + "home_banners.created_at", TIMEZONE, VERSION, "内容编辑", + MetricStatus.OFFICIAL, "home_banners"), + + new MetricDefinition( + "banner.active", "生效中的 Banner 数", + "status='active' 且当前时间落在 [start_at, end_at] 区间内的 Banner 数。", + "COUNT(home_banners.id) WHERE status='active' AND NOW() BETWEEN start_at AND end_at", "—", + "home_banners.id", "NOW()", TIMEZONE, VERSION, "内容编辑", + MetricStatus.OFFICIAL, "home_banners"), + + new MetricDefinition( + "banner.impression", "Banner 曝光量", + "Banner 在小程序首页被渲染展示的次数。" + + "**当前不可用**:项目内不存在任何曝光埋点表或上报端点," + + "修复前该指标被硬编码返回 0 —— 会被误读成「真的没人看到」," + + "故改为显式标记为未埋点,绝不返回 0 冒充真实值。", + "—(无数据源)", "—(无数据源)", "—(尚未埋点)", "—", TIMEZONE, VERSION, "内容编辑", + MetricStatus.NOT_INSTRUMENTED, "(待建 home_banner_events)"), + + new MetricDefinition( + "banner.click", "Banner 点击量", + "Banner 被点击进入落地页的次数。**当前不可用**,原因同 banner.impression。", + "—(无数据源)", "—(无数据源)", "—(尚未埋点)", "—", TIMEZONE, VERSION, "内容编辑", + MetricStatus.NOT_INSTRUMENTED, "(待建 home_banner_events)"), + + // ==================== 安全域 ==================== + new MetricDefinition( + "risk.report.pending", "待处理举报数", + "状态为待处理的举报记录数。", + "COUNT(reports.id) WHERE status='pending'", "—", "reports.id", "reports.created_at", + TIMEZONE, VERSION, "风控人员", MetricStatus.OFFICIAL, "reports"), + + new MetricDefinition( + "risk.report.resolved", "已处理举报数", + "状态为已处理的举报记录数。", + "COUNT(reports.id) WHERE status='resolved'", "—", "reports.id", "reports.resolved_at", + TIMEZONE, VERSION, "风控人员", MetricStatus.OFFICIAL, "reports"), + + new MetricDefinition( + "risk.user.high_risk", "高风险用户数", + "存在生效中封禁或临时停用处置的用户数。", + "COUNT(risk_actions.id) WHERE action_type IN ('ban','temporary_suspend') AND status='active'", + "—", "risk_actions.id", "risk_actions.created_at", TIMEZONE, VERSION, "风控人员", + MetricStatus.OFFICIAL, "risk_actions") + ); + + private static final Map BY_CODE = ALL.stream() + .collect(Collectors.toUnmodifiableMap(MetricDefinition::code, Function.identity())); + + /** 全部指标定义(顺序稳定,供文档生成与守卫遍历)。 */ + public static List all() { + return ALL; + } + + /** 按编码取定义;编码不存在时抛异常(调用处写错编码必须立刻暴露,而不是静默拿到 null)。 */ + public static MetricDefinition of(String code) { + MetricDefinition definition = BY_CODE.get(code); + if (definition == null) { + throw new IllegalArgumentException("未登记的指标编码: " + code + + "(新指标必须先加入 MetricCatalog 并同步 docs/ANALYTICS_METRICS.md)"); + } + return definition; + } + + /** 口径未确认、禁止对外宣传的指标。 */ + public static List provisional() { + return ALL.stream().filter(MetricDefinition::provisional).toList(); + } + + /** 因缺少埋点而当前无法计算的指标。 */ + public static List notInstrumented() { + return ALL.stream().filter(MetricDefinition::notInstrumented).toList(); + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/analytics/metric/MetricDefinition.java b/backend/src/main/java/com/danyang/match/platform/analytics/metric/MetricDefinition.java new file mode 100644 index 0000000..5c049ba --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/analytics/metric/MetricDefinition.java @@ -0,0 +1,56 @@ +package com.danyang.match.platform.analytics.metric; + +/** + * 单个指标的完整定义(Phase 20 指标字典的代码化载体)。 + * + *

Phase 20 计划原文要求:「为每个指标登记名称、定义、分子分母、去重键、时间字段、 + * 时区、版本和负责人」。这些字段此前散落在各统计方法里(大多数情况下**根本不存在**, + * 口径只存在于写代码人的脑子里),导致: + *

    + *
  • 同一个概念在不同接口里各算一遍,结果不一致(Dashboard 与专项页对不上);
  • + *
  • 「实名人数」被误算成「资料没被退回的人数」,没人能在评审时发现;
  • + *
  • 「服务用户数」与「服务人次」混用,去重与否无从判断。
  • + *
+ * + *

把定义变成结构化数据后,{@code MetricCatalogGuardTest} 才能把 + * 「每个对外指标都必须有定义」「文档必须覆盖全部指标」「provisional 状态不得缺失」 + * 变成可执行断言,而不是靠人肉 review。 + * + * @param code 指标唯一编码(如 {@code user.identity.verified}),命名空间用点分层 + * @param name 中文名(后台与文档展示用) + * @param definition 口径的自然语言定义(必须说明"算什么"以及"不算什么") + * @param numerator 分子 + * @param denominator 分母(非比率指标填 {@code "—"}) + * @param dedupeKey 去重键(说明该指标的"1 个单位"是什么,防止口径混淆) + * @param timeField 时间字段(决定"属于哪一天") + * @param timezone 时区(本项目统一 {@code Asia/Shanghai}) + * @param version 口径版本(口径变更必须递增,否则历史数据不可比) + * @param owner 口径负责人(业务侧角色,口径争议时的裁决人) + * @param status 口径状态,见 {@link MetricStatus} + * @param sourceTable 数据来源表(便于追溯与索引优化) + */ +public record MetricDefinition( + String code, + String name, + String definition, + String numerator, + String denominator, + String dedupeKey, + String timeField, + String timezone, + String version, + String owner, + MetricStatus status, + String sourceTable +) { + + /** 是否为口径未确认的临时指标(禁止对外宣传)。 */ + public boolean provisional() { + return status == MetricStatus.PROVISIONAL; + } + + /** 是否因缺少埋点而当前无法计算。 */ + public boolean notInstrumented() { + return status == MetricStatus.NOT_INSTRUMENTED; + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/analytics/metric/MetricStatus.java b/backend/src/main/java/com/danyang/match/platform/analytics/metric/MetricStatus.java new file mode 100644 index 0000000..656a7bf --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/analytics/metric/MetricStatus.java @@ -0,0 +1,34 @@ +package com.danyang.match.platform.analytics.metric; + +/** + * 指标口径状态(Phase 20 指标字典的一部分)。 + * + *

存在的理由:统计接口最大的风险不是"算错",而是**算出来的数字被当成别的意思用了**。 + * 「有效交友匹配」在口径确认前如果被后台当成"成功数"展示、被运营写进宣传稿, + * 错误就从代码扩散到了对外承诺。把状态显式建模,可让守卫测试把 + * 「provisional 指标不得标记为正式」变成可执行的不变量。 + */ +public enum MetricStatus { + + /** + * 口径已确认,可作为正式统计口径使用。 + */ + OFFICIAL, + + /** + * 口径尚未确认,禁止当作正式成功口径,禁止对外宣传。 + * + *

Phase 20 计划:「有效交友匹配在口径未确认前标为 provisional, + * 不能当正式成功或对外宣传」。 + */ + PROVISIONAL, + + /** + * 数据源尚未埋点,当前**无法**计算。 + * + *

此状态的意义在于**诚实**:宁可明确返回「不可用」, + * 也不能像修复前那样硬编码返回 0 —— 0 会被读成"真的没人点击", + * 而实际是"根本没有记录点击的地方",两者对决策的含义完全相反。 + */ + NOT_INSTRUMENTED +} diff --git a/backend/src/main/java/com/danyang/match/platform/chat/mapper/MessageMapper.java b/backend/src/main/java/com/danyang/match/platform/chat/mapper/MessageMapper.java index 9dc318a..190e857 100644 --- a/backend/src/main/java/com/danyang/match/platform/chat/mapper/MessageMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/chat/mapper/MessageMapper.java @@ -32,4 +32,22 @@ public interface MessageMapper extends BaseMapper { Optional findByClientMessageId(@Param("convId") String convId, @Param("senderId") String senderId, @Param("clientMsgId") String clientMsgId); + + /** + * **Phase 20 D3(Gate「System Message 不算 Chat Started」)**:真正开启过聊天的会话数。 + * + *

口径:**至少存在一条真人消息**的会话数。{@code message_type} 的取值域由 + * {@code chk_msg_type} 限定为 {@code text / image / system / risk_notice}, + * 后两者是平台下发的通知(如「你们已互相喜欢,可以开始聊天了」), + * 用户本人没有任何发言,因此**不能**算作 Chat Started。 + * + *

此前 Analytics 用 {@code conversationMapper.selectCount(null)} 统计"开启聊天数", + * 只看会话行数、**从不看消息类型** —— 一条系统通知就能让一个会话计入 Chat Started, + * 导致该指标被系统性高估。 + * + *

去重键:{@code conversation_id}(同一会话内多条真人消息只算一次)。 + */ + @Select("SELECT COUNT(DISTINCT conversation_id) FROM messages " + + "WHERE message_type NOT IN ('system', 'risk_notice') AND deleted_at IS NULL") + long countConversationsWithRealMessages(); } diff --git a/backend/src/main/java/com/danyang/match/platform/counseling/entity/CounselingServiceRecord.java b/backend/src/main/java/com/danyang/match/platform/counseling/entity/CounselingServiceRecord.java index c622047..58f42e4 100644 --- a/backend/src/main/java/com/danyang/match/platform/counseling/entity/CounselingServiceRecord.java +++ b/backend/src/main/java/com/danyang/match/platform/counseling/entity/CounselingServiceRecord.java @@ -26,7 +26,7 @@ import lombok.EqualsAndHashCode; public class CounselingServiceRecord extends BaseEntity { private String userId; - private String serviceType; // counseling / premarital + private String serviceType; // relationship_guidance / emotional_support / premarital_guidance / anti_fraud_guidance(见 V022+V039 的 chk_counseling_service_type) private String requestSource; // user_self / matchmaker_referral private String userRequestSummary; // INV-013: Matchmaker 不可读 private String organizationId; diff --git a/backend/src/main/java/com/danyang/match/platform/match/mapper/MatchMapper.java b/backend/src/main/java/com/danyang/match/platform/match/mapper/MatchMapper.java index 6c2761e..a583451 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/mapper/MatchMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/match/mapper/MatchMapper.java @@ -69,4 +69,29 @@ public interface MatchMapper extends BaseMapper { "matched_at = NOW(), last_activity_at = NOW(), unmatched_at = NULL, updated_at = NOW() " + "WHERE id = #{id} AND status = 'unmatched' AND deleted_at IS NULL") int reopenEpisode(@Param("id") String id, @Param("episode") int episode, @Param("source") String source); + + // ==================== Phase 20:按来源分离的口径统计 ==================== + // 生产 chk_match_source 限定 source ∈ (user_like, activity_mutual, matchmaker)。 + // 统计必须按来源分离:Gate 明确要求「活动10对互选 ≠ 10 对 Success」, + // 把三种来源混成一个总数会让"活动互选数"与"成功牵手数"被错误地互相比较。 + // + // 说明:这里刻意用「单值 count」而非 `GROUP BY source` 返回 Map —— H2 与 PostgreSQL + // 对结果集列名的大小写处理不同(H2 默认大写、PG 默认小写),返回 Map 会导致 + // 测试库取键失败而生产正常(或相反)的隐蔽不一致。显式参数化可彻底绕开该差异。 + + /** 全量 Match 数(去重键 = id)。 */ + @Select("SELECT COUNT(*) FROM matches WHERE deleted_at IS NULL") + long countAllMatches(); + + /** 按来源统计 Match 数(去重键 = id)。 */ + @Select("SELECT COUNT(*) FROM matches WHERE source = #{source} AND deleted_at IS NULL") + long countBySource(@Param("source") String source); + + /** 按来源 + 状态统计 Match 数。 */ + @Select("SELECT COUNT(*) FROM matches WHERE source = #{source} AND status = #{status} AND deleted_at IS NULL") + long countBySourceAndStatus(@Param("source") String source, @Param("status") String status); + + /** 按状态统计 Match 数。 */ + @Select("SELECT COUNT(*) FROM matches WHERE status = #{status} AND deleted_at IS NULL") + long countByStatus(@Param("status") String status); } diff --git a/backend/src/main/java/com/danyang/match/platform/match/mapper/RecommendationRecordMapper.java b/backend/src/main/java/com/danyang/match/platform/match/mapper/RecommendationRecordMapper.java index 105beca..983492a 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/mapper/RecommendationRecordMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/match/mapper/RecommendationRecordMapper.java @@ -20,4 +20,30 @@ public interface RecommendationRecordMapper extends BaseMapper { @Select("SELECT * FROM user_identities WHERE id_number_hash = #{hash} AND deleted_at IS NULL LIMIT 1") Optional findByIdNumberHash(@Param("hash") String hash); + + /** + * **Phase 20 D1(口径造假修复)**:实名认证通过的用户数。 + * + *

统计口径的唯一合法来源是 {@code user_identities.verification_status = 'verified'}。 + * 此前 Analytics 把 {@code user_profiles.profile_status != 'rejected'} 当作「实名人数」, + * 而该列的取值域是 {@code in_progress / completed / pending_review / approved / need_edit / rejected} + * ——「资料还在填」(in_progress) 与「被退回待改」(need_edit) 都被算进了"已实名", + * 与 Gate「注册10 / 实名7 / 审核通过4」中的实名口径完全不是一回事。 + * + *

去重键:{@code user_id}(同一用户多次提交实名只算一人)。 + */ + @Select("SELECT COUNT(DISTINCT user_id) FROM user_identities " + + "WHERE verification_status = 'verified' AND deleted_at IS NULL") + long countVerifiedUsers(); } diff --git a/backend/src/main/resources/db/migration/V043__grant_analytics_view_to_data_roles.sql b/backend/src/main/resources/db/migration/V043__grant_analytics_view_to_data_roles.sql new file mode 100644 index 0000000..f623889 --- /dev/null +++ b/backend/src/main/resources/db/migration/V043__grant_analytics_view_to_data_roles.sql @@ -0,0 +1,43 @@ +-- V043: Phase 20 —— 打通 analytics.view 授权(修复「专设角色零权限」与「老权限段被角色扩展漏掉」) +-- +-- 背景(两个叠加的授权缺陷): +-- +-- 1) V001 第 149 行定义了一个**专门用于看统计**的角色: +-- ('00000000-...0009', 'data_viewer', '数据查看者', '仅查看统计数据', TRUE) +-- 但全仓迁移从未给它授予任何权限 —— 该角色的 description 写得清清楚楚, +-- 权限表里却一条都没有。用这个角色登录后台,「数据统计」菜单点了就是 403。 +-- 这是「角色定义与权限授予脱节」:角色存在 ≠ 角色可用。 +-- +-- 2) V015 给 platform_admin 的扩展条件是 id >= '20000000-0000-0000-0000-000000000100' +-- (V1.1 新增权限的 ID 段),而 analytics.view 的 ID 是 +-- '10000000-0000-0000-0000-000000000060' +-- 属于 V1.0 老权限段 —— 因此 platform_admin 也被漏掉。 +-- +-- 净效果:整个 Analytics 模块(15 个端点)除 super_admin 外**全员 403**, +-- 包括专门为它设计的 data_viewer。 +-- +-- 与 Phase 19 发现的 service_location.manage「死权限」是同族问题: +-- 权限定义了、角色定义了、两者之间从未连上。 +-- +-- 授权范围(最小权限原则): +-- * data_viewer —— 平台级数据查看者,职责就是看统计,必须授予; +-- * platform_admin —— 平台运营管理员,需要看全局数据做运营决策。 +-- +-- **刻意不授予 organization_admin**:平台级统计接口(用户/匹配/聊天/活动) +-- 所依赖的表多数没有 organization_id 维度(users / matches / messages 均无), +-- 因此无法把机构管理员的数据范围限制在"本机构"。在没有范围限制的情况下授予 +-- 全局统计权限 = 越权可见全平台数据。宁可返回 403,也不给无范围的全量数据。 +-- 待统计口径支持组织维度后再评估(见 docs/ANALYTICS_METRICS.md 待办)。 +-- +-- 低权限角色(content_editor / counselor / service_station_operator 等)**不授予**: +-- Phase 20 Gate 明确要求「低权限账号返回 403」,这正是期望行为。 + +INSERT INTO role_permissions (role_id, permission_id) +SELECT '00000000-0000-0000-0000-000000000009', id FROM permissions +WHERE code = 'analytics.view' +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT '00000000-0000-0000-0000-000000000002', id FROM permissions +WHERE code = 'analytics.view' +ON CONFLICT DO NOTHING; diff --git a/backend/src/test/java/com/danyang/match/platform/account/LocalEligibilityStatusVocabularyGuardTest.java b/backend/src/test/java/com/danyang/match/platform/account/LocalEligibilityStatusVocabularyGuardTest.java new file mode 100644 index 0000000..eef2360 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/account/LocalEligibilityStatusVocabularyGuardTest.java @@ -0,0 +1,267 @@ +package com.danyang.match.platform.account; + +import com.danyang.match.platform.account.service.AccountLifecycleService; +import com.danyang.match.platform.local_eligibility.entity.LocalEligibility; +import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 本地资格状态域守卫(Phase 20 复验发现的跨阶段缺陷)。 + * + *

缺陷回顾

+ * + *

{@code AccountLifecycleService} 把两个**不同状态域**的常量混用了: + * + *

    + *
  • {@code invalidateLocalEligibility} 写 {@code verification_status = 'failed'} —— + * 但 {@code failed} 只属于 {@code user_identities}(V002 的 CHECK 才含它), + * 本地资格表 V017 的 {@code chk_elig_verify_status} 只允许 + * {@code declared / needs_evidence / pending_review / verified / rejected}。 + * 后果:真实 PostgreSQL 上 CHECK 违约 → {@code closeAccount} 事务回滚 → + * **账号注销 100% 不可用**(报 500)。
  • + *
  • {@code checkCanWithdraw} 用 {@code 'submitted'} / {@code 'pending'} 判断 + * "本地资格审核进行中" —— 这两个值在该表里**永不可能出现**, + * 于是这道拦截从未生效过(死代码),审核中的用户可以直接注销。
  • + *
+ * + *

两者长期隐形的原因有二:测试库压根没有这两条 CHECK(约束漂移), + * 且 {@code AccountLifecycleServiceTest} 由 7 个 {@code assertTrue(true)} 组成(假绿)。 + * + *

本守卫的四个层次

+ * + *
    + *
  1. 生产 DDL:状态白名单确实由 V017 定义(解析真实迁移文件);
  2. + *
  3. 测试库一致性:测试库必须声明同一份白名单 —— 这一条是**防漂移**的关键, + * 缺了它,下一个"写得进测试库、炸在生产"的状态值依然不会被发现;
  4. + *
  5. 行为:越界状态值必须被数据库真实拒绝(证明约束不是装饰);
  6. + *
  7. 代码:注销流程结束后,资格状态必须落在白名单内; + * 且"审核进行中"确实能拦截注销 —— 把死代码变成活断言。
  8. + *
+ */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class LocalEligibilityStatusVocabularyGuardTest { + + private static final Set ELIGIBILITY_STATUS_WHITELIST = Set.of( + "declared", "needs_evidence", "pending_review", "verified", "rejected"); + + private static final Set ELIGIBILITY_TYPE_WHITELIST = Set.of( + "household_registration", "residence", "employment", "other"); + + private static final Path MIGRATIONS = Path.of("backend/src/main/resources/db/migration"); + private static final Path TEST_SCHEMA = Path.of("backend/src/test/resources/schema-h2.sql"); + + @Autowired + private AccountLifecycleService accountLifecycleService; + @Autowired + private UserMapper userMapper; + @Autowired + private LocalEligibilityMapper localEligibilityMapper; + + // ==================== DDL 解析辅助 ==================== + + /** 从任意 SQL 文本中解析 {@code CHECK ( IN (...))} 的取值集合。 */ + private static Set checkValues(String sql, String constraintName, String column) { + Matcher m = Pattern.compile( + Pattern.quote(constraintName) + "[\\s\\S]{0,400}?CHECK\\s*\\(\\s*" + + Pattern.quote(column) + "\\s+IN\\s*\\(([^)]*)\\)", + Pattern.CASE_INSENSITIVE) + .matcher(sql); + if (!m.find()) { + return null; // 交给调用方显式报错,而不是静默返回空集合 + } + Set values = new TreeSet<>(); + Matcher v = Pattern.compile("'([a-z_]+)'").matcher(m.group(1)); + while (v.find()) { + values.add(v.group(1)); + } + return values; + } + + private static String read(Path path) throws IOException { + assertTrue(Files.exists(path), + "守卫无法定位文件:" + path.toAbsolutePath() + + "(CWD=" + Path.of("").toAbsolutePath() + ")—— 定位失败必须显式报错。"); + return Files.readString(path); + } + + private static String allMigrations() throws IOException { + try (var files = Files.list(MIGRATIONS)) { + return files.filter(p -> p.getFileName().toString().endsWith(".sql")) + .sorted() + .map(p -> { + try { + return Files.readString(p); + } catch (IOException e) { + throw new java.io.UncheckedIOException(e); + } + }) + .collect(Collectors.joining("\n")); + } + } + + // ==================== 夹具 ==================== + + private String newUser() { + User user = new User(); + user.setId(UUID.randomUUID().toString()); + // wechat_openid 在测试库与生产库都是 NOT NULL UNIQUE + user.setWechatOpenid("openid-" + UUID.randomUUID()); + user.setAccountStatus("normal"); + user.setRecommendationEnabled(true); + userMapper.insert(user); + return user.getId(); + } + + private void newEligibility(String userId, String verificationStatus) { + LocalEligibility eligibility = new LocalEligibility(); + eligibility.setId(UUID.randomUUID().toString()); + eligibility.setUserId(userId); + eligibility.setEligibilityType("household_registration"); + eligibility.setVerificationStatus(verificationStatus); + localEligibilityMapper.insert(eligibility); + } + + // ==================== 1. 生产 DDL ==================== + + @Test + @DisplayName("生产 V017 定义了本地资格的状态白名单(解析真实迁移,而非硬编码假设)") + void productionDeclaresEligibilityStatusWhitelist() throws IOException { + Set statuses = checkValues(allMigrations(), "chk_elig_verify_status", "verification_status"); + assertNotNull(statuses, + "未能在生产迁移中解析出 chk_elig_verify_status —— 约束被移除或守卫解析失效"); + assertEquals(ELIGIBILITY_STATUS_WHITELIST, statuses, + "生产本地资格状态白名单发生变化;本守卫与 AccountLifecycleService 的状态判断需要同步"); + } + + @Test + @DisplayName("生产 V017 明确定义 chk_elig_type 白名单") + void productionDeclaresEligibilityTypeWhitelist() throws IOException { + Set types = checkValues(allMigrations(), "chk_elig_type", "eligibility_type"); + assertNotNull(types, "未能在生产迁移中解析出 chk_elig_type"); + assertEquals(ELIGIBILITY_TYPE_WHITELIST, types); + } + + // ==================== 2. 测试库一致性(防漂移) ==================== + + @Test + @DisplayName("测试库必须声明与生产相同的 chk_elig_verify_status(缺约束 = 该路径零覆盖)") + void testSchemaDeclaresSameEligibilityStatusCheck() throws IOException { + Set testSchema = checkValues(read(TEST_SCHEMA), "chk_elig_verify_status", "verification_status"); + assertNotNull(testSchema, + "测试库缺少 chk_elig_verify_status —— 这正是让 " + + "AccountLifecycleService 把 'failed' 写进本地资格表却长期无人发现的原因。"); + assertEquals(ELIGIBILITY_STATUS_WHITELIST, testSchema, + "测试库与生产的状态白名单必须逐字一致,否则非法状态在测试期不可见"); + } + + @Test + @DisplayName("测试库必须声明与生产相同的 chk_elig_type") + void testSchemaDeclaresSameEligibilityTypeCheck() throws IOException { + Set testSchema = checkValues(read(TEST_SCHEMA), "chk_elig_type", "eligibility_type"); + assertNotNull(testSchema, "测试库缺少 chk_elig_type"); + assertEquals(ELIGIBILITY_TYPE_WHITELIST, testSchema); + } + + // ==================== 3. 行为:约束必须真实生效 ==================== + + @Test + @DisplayName("写入越界的资格状态必须被数据库拒绝(证明约束不是装饰)") + void databaseRejectsStatusOutsideWhitelist() { + String userId = newUser(); + LocalEligibility illegal = new LocalEligibility(); + illegal.setId(UUID.randomUUID().toString()); + illegal.setUserId(userId); + illegal.setEligibilityType("household_registration"); + // 'failed' 属于 user_identities 的状态域,本地资格表不允许 + illegal.setVerificationStatus("failed"); + + assertThrows(Exception.class, () -> localEligibilityMapper.insert(illegal), + "本地资格表必须拒绝 'failed' —— 若此处不抛异常,说明 chk_elig_verify_status 未生效," + + "那么「注销时写入非法状态」这类缺陷在测试期将继续隐形"); + } + + @Test + @DisplayName("写入越界的 eligibility_type 必须被数据库拒绝") + void databaseRejectsEligibilityTypeOutsideWhitelist() { + String userId = newUser(); + LocalEligibility illegal = new LocalEligibility(); + illegal.setId(UUID.randomUUID().toString()); + illegal.setUserId(userId); + illegal.setEligibilityType("household"); // 常见笔误:正确值是 household_registration + illegal.setVerificationStatus("declared"); + + assertThrows(Exception.class, () -> localEligibilityMapper.insert(illegal), + "eligibility_type 必须在 chk_elig_type 白名单内"); + } + + // ==================== 4. 代码:注销流程不得写入越界状态 ==================== + + @Test + @DisplayName("注销后本地资格状态必须落在白名单内(修复前写入 'failed' → 生产必崩)") + void closeAccountLeavesEligibilityStatusWithinWhitelist() { + String userId = newUser(); + newEligibility(userId, "declared"); + + accountLifecycleService.closeAccount(userId, UUID.randomUUID().toString(), "用户主动注销"); + + LocalEligibility after = localEligibilityMapper.findByUserId(userId).orElseThrow(); + assertTrue(ELIGIBILITY_STATUS_WHITELIST.contains(after.getVerificationStatus()), + "注销后写入了越界状态 '" + after.getVerificationStatus() + "' —— " + + "在真实 PostgreSQL 上会触发 chk_elig_verify_status 违约," + + "导致 closeAccount 整个事务回滚、注销永久不可用。" + + "当前状态域白名单:" + ELIGIBILITY_STATUS_WHITELIST); + assertEquals("rejected", after.getVerificationStatus(), + "注销应使本地资格失效(取白名单内的 rejected 语义)"); + } + + @Test + @DisplayName("本地资格审核进行中必须能拦截注销(修复前用 submitted/pending 比较 → 死代码)") + void pendingEligibilityReviewBlocksWithdrawal() { + String userId = newUser(); + newEligibility(userId, "pending_review"); + + Map result = accountLifecycleService.checkCanWithdraw(userId); + assertEquals(false, result.get("canWithdraw"), + "资格审核进行中的用户不应被允许注销 —— " + + "修复前这里比较的是 'submitted' / 'pending'(该表永不可能出现的值)," + + "拦截条件恒为 false,是死代码"); + @SuppressWarnings("unchecked") + Map blockers = (Map) result.get("blockers"); + assertTrue(blockers.containsKey("localEligibility"), + "应给出 localEligibility 阻塞项,实际:" + blockers.keySet()); + } + + @Test + @DisplayName("已通过 / 已失效的资格不阻塞注销(避免过度拦截)") + void verifiedOrRejectedEligibilityDoesNotBlockWithdrawal() { + for (String status : new String[]{"verified", "rejected"}) { + String userId = newUser(); + newEligibility(userId, status); + Map result = accountLifecycleService.checkCanWithdraw(userId); + assertEquals(true, result.get("canWithdraw"), + "状态 " + status + " 不属于「审核进行中」,不得阻塞注销"); + } + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/activity/ActivityPhase14Test.java b/backend/src/test/java/com/danyang/match/platform/activity/ActivityPhase14Test.java index e4c5405..d5314d9 100644 --- a/backend/src/test/java/com/danyang/match/platform/activity/ActivityPhase14Test.java +++ b/backend/src/test/java/com/danyang/match/platform/activity/ActivityPhase14Test.java @@ -466,7 +466,10 @@ class ActivityPhase14Test { LocalEligibility el = new LocalEligibility(); el.setUserId(userId); - el.setEligibilityType("household"); + // 必须落在生产 V017 chk_elig_type 的白名单内 + // (household_registration / residence / employment / other)。 + // 测试库补齐该 CHECK 后,"household" 这类随意取值会被立即挡下。 + el.setEligibilityType("household_registration"); el.setVerificationStatus(Constants.VERIFICATION_STATUS_VERIFIED); localEligibilityMapper.insert(el); return userId; diff --git a/backend/src/test/java/com/danyang/match/platform/analytics/AnalyticsMetricsPhase20IntegrationTest.java b/backend/src/test/java/com/danyang/match/platform/analytics/AnalyticsMetricsPhase20IntegrationTest.java new file mode 100644 index 0000000..6af2270 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/analytics/AnalyticsMetricsPhase20IntegrationTest.java @@ -0,0 +1,625 @@ +package com.danyang.match.platform.analytics; + +import com.danyang.match.platform.activity_mutual.entity.ActivityMutualConsent; +import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper; +import com.danyang.match.platform.analytics.metric.AnalyticsMetricsCalculator; +import com.danyang.match.platform.chat.entity.Conversation; +import com.danyang.match.platform.chat.entity.Message; +import com.danyang.match.platform.chat.mapper.ConversationMapper; +import com.danyang.match.platform.chat.mapper.MessageMapper; +import com.danyang.match.platform.counseling.entity.CounselingServiceRecord; +import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper; +import com.danyang.match.platform.local_eligibility.entity.LocalEligibility; +import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper; +import com.danyang.match.platform.match.entity.Match; +import com.danyang.match.platform.match.entity.RecommendationRecord; +import com.danyang.match.platform.match.mapper.MatchMapper; +import com.danyang.match.platform.match.mapper.RecommendationRecordMapper; +import com.danyang.match.platform.profile.entity.UserProfile; +import com.danyang.match.platform.profile.mapper.UserProfileMapper; +import com.danyang.match.platform.service_appointment.entity.ServiceAppointment; +import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper; +import com.danyang.match.platform.service_program.entity.ServiceProgramMember; +import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; +import com.danyang.match.platform.verification.entity.UserIdentity; +import com.danyang.match.platform.verification.mapper.UserIdentityMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Analytics 口径真实 DB 集成测试(Phase 20)。 + * + *

本测试替代原先的 {@code AnalyticsServiceTest} —— 后者由 5 个 + * {@code assertTrue(true)} 组成,**没有任何断言**,却让"Phase 20 已完成"看起来成立。 + * 这是典型的假绿:测试通过不代表功能存在,只代表测试什么都没检查。 + * + *

每个用例都直接把计划 Gate 的口径写成断言,并构造**真实数据行** + * 让 SQL 在 H2 上真正执行(而不是 mock mapper 返回预设值 —— 那样测的是 mock,不是口径)。 + * + *

为什么用「基线 + 增量」而不是绝对值:统计是全表聚合, + * 测试库中可能有其它测试留下的数据。用增量断言可精确表达"新增 N 条记录后该指标恰好 +N", + * 同时避免测试之间互相污染导致的偶发失败。 + */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class AnalyticsMetricsPhase20IntegrationTest { + + @Autowired + private AnalyticsMetricsCalculator metrics; + @Autowired + private AnalyticsService analyticsService; + + @Autowired + private UserMapper userMapper; + @Autowired + private UserProfileMapper userProfileMapper; + @Autowired + private UserIdentityMapper userIdentityMapper; + @Autowired + private RecommendationRecordMapper recommendationRecordMapper; + @Autowired + private MatchMapper matchMapper; + @Autowired + private ConversationMapper conversationMapper; + @Autowired + private MessageMapper messageMapper; + @Autowired + private ActivityMutualConsentMapper activityMutualConsentMapper; + @Autowired + private ServiceAppointmentMapper serviceAppointmentMapper; + @Autowired + private CounselingServiceMapper counselingServiceMapper; + @Autowired + private ServiceProgramMemberMapper serviceProgramMemberMapper; + @Autowired + private LocalEligibilityMapper localEligibilityMapper; + + // ==================== 夹具 ==================== + + private String newUser() { + User user = new User(); + user.setId(UUID.randomUUID().toString()); + // wechat_openid 在测试库与生产库都是 NOT NULL UNIQUE,夹具必须给唯一值。 + user.setWechatOpenid("openid-" + UUID.randomUUID()); + user.setAccountStatus("active"); + user.setRecommendationEnabled(true); + userMapper.insert(user); + return user.getId(); + } + + private void newProfile(String userId, String status) { + UserProfile profile = new UserProfile(); + profile.setUserId(userId); + profile.setDisplayName("测试用户"); + profile.setProfileStatus(status); + userProfileMapper.insert(profile); + } + + private void newVerifiedIdentity(String userId, String verificationStatus) { + UserIdentity identity = new UserIdentity(); + identity.setId(UUID.randomUUID().toString()); + identity.setUserId(userId); + identity.setRealNameEnc("enc-real-name"); + identity.setIdNumberEnc("enc-id-number"); + identity.setIdNumberHash(UUID.randomUUID().toString()); + identity.setVerificationStatus(verificationStatus); + if ("verified".equals(verificationStatus)) { + identity.setVerifiedAt(Instant.now()); + } + userIdentityMapper.insert(identity); + } + + private void newRecommendation(String userId, String targetUserId, String source, String action) { + RecommendationRecord record = new RecommendationRecord(); + record.setId(UUID.randomUUID().toString()); + record.setUserId(userId); + record.setTargetUserId(targetUserId); + record.setSource(source); + record.setShownAt(Instant.now()); + record.setAction(action); + record.setActionAt(Instant.now()); + recommendationRecordMapper.insert(record); + } + + private void newMatch(String userAId, String userBId, String source, String status) { + Match match = new Match(); + match.setId(UUID.randomUUID().toString()); + match.setUserAId(userAId); + match.setUserBId(userBId); + match.setSource(source); + match.setStatus(status); + match.setMatchedAt(Instant.now()); + match.setEpisode(1); + matchMapper.insert(match); + } + + private String newConversation() { + Conversation conversation = new Conversation(); + conversation.setId(UUID.randomUUID().toString()); + conversation.setStatus("active"); + conversationMapper.insert(conversation); + return conversation.getId(); + } + + private void newMessage(String conversationId, String messageType) { + Message message = new Message(); + message.setId(UUID.randomUUID().toString()); + message.setConversationId(conversationId); + message.setMessageType(messageType); + message.setContent("测试内容"); + message.setStatus("sent"); + messageMapper.insert(message); + } + + private void newMutualConsent(String activityId, String userAId, String userBId, + String statusA, String statusB) { + ActivityMutualConsent consent = new ActivityMutualConsent(); + consent.setId(UUID.randomUUID().toString()); + consent.setActivityId(activityId); + consent.setUserAId(userAId); + consent.setUserBId(userBId); + consent.setUserAStatus(statusA); + consent.setUserBStatus(statusB); + if ("accepted".equals(statusA) && "accepted".equals(statusB)) { + consent.setBothConfirmedAt(Instant.now()); + } + activityMutualConsentMapper.insert(consent); + } + + private void newAppointment(String userId, String status) { + newAppointment(userId, status, false); + } + + private void newAppointment(String userId, String status, boolean softDeleted) { + ServiceAppointment appointment = new ServiceAppointment(); + appointment.setId(UUID.randomUUID().toString()); + appointment.setUserId(userId); + appointment.setServiceLocationId(UUID.randomUUID().toString()); + appointment.setServiceType("station_visit"); + appointment.setStatus(status); + appointment.setSource("user_self"); + if (softDeleted) { + appointment.setDeletedAt(Instant.now()); + } + serviceAppointmentMapper.insert(appointment); + } + + /** + * 专项项目成员。 + * + *

注意测试库与生产都有 {@code UNIQUE(program_id, user_id)}, + * 每次都新建 programId 以免撞唯一键。 + */ + private void newProgramMember(String userId, String status, boolean softDeleted) { + ServiceProgramMember member = new ServiceProgramMember(); + member.setId(UUID.randomUUID().toString()); + member.setProgramId(UUID.randomUUID().toString()); + member.setUserId(userId); + member.setStatus(status); + member.setAppliedAt(Instant.now()); + if ("approved".equals(status)) { + member.setApprovedAt(Instant.now()); + } + if (softDeleted) { + member.setDeletedAt(Instant.now()); + } + serviceProgramMemberMapper.insert(member); + } + + /** 本地资格声明。测试库与生产都限制 {@code user_id UNIQUE},故每个用户最多一条。 */ + private void newLocalEligibility(String userId, boolean softDeleted) { + LocalEligibility eligibility = new LocalEligibility(); + eligibility.setId(UUID.randomUUID().toString()); + eligibility.setUserId(userId); + // 取值必须在生产 V017 chk_elig_type 白名单内 + eligibility.setEligibilityType("household_registration"); + eligibility.setDeclaredRegionCode("321100"); + eligibility.setVerificationStatus("pending_review"); + if (softDeleted) { + eligibility.setDeletedAt(Instant.now()); + } + localEligibilityMapper.insert(eligibility); + } + + private void newCounseling(String userId) { + CounselingServiceRecord record = new CounselingServiceRecord(); + record.setId(UUID.randomUUID().toString()); + record.setUserId(userId); + record.setServiceType("relationship_guidance"); + record.setRequestSource("user_self"); + record.setStatus("pending"); + counselingServiceMapper.insert(record); + } + + // ==================== Gate 1:注册10 / 实名7 / 审核通过4 ==================== + + @Test + @DisplayName("Gate:注册10 / 实名7 / 审核通过4 —— 三个口径互不污染") + void gateUserFunnelCountsThreeDistinctConcepts() { + long registeredBefore = metrics.registeredUsers(); + long verifiedBefore = metrics.realNameVerifiedUsers(); + long approvedBefore = metrics.profileApprovedUsers(); + + // 10 个注册用户;其中 7 个已实名;其中 4 个资料审核通过 + String[] users = new String[10]; + for (int i = 0; i < 10; i++) { + users[i] = newUser(); + } + for (int i = 0; i < 7; i++) { + newVerifiedIdentity(users[i], "verified"); + } + for (int i = 0; i < 4; i++) { + newProfile(users[i], "approved"); + } + // 干扰项:3 个"资料填写中"、2 个"被退回待改"、1 个"认证中"、 + // 这些都不该被算进「实名」—— 正是修复前把它们全算成实名的那个缺陷。 + newProfile(users[7], "in_progress"); + newProfile(users[8], "need_edit"); + newProfile(users[9], "pending_review"); + newVerifiedIdentity(users[7], "verifying"); + newVerifiedIdentity(users[8], "failed"); + newVerifiedIdentity(users[9], "manual_review"); + + assertEquals(registeredBefore + 10, metrics.registeredUsers(), "注册数应为 +10"); + assertEquals(verifiedBefore + 7, metrics.realNameVerifiedUsers(), + "实名数必须严格等于 verification_status='verified' 的记录数(+7);" + + "若把 in_progress / need_edit 也计入,此断言会失败 —— 那正是修复前的缺陷"); + assertEquals(approvedBefore + 4, metrics.profileApprovedUsers(), + "资料审核通过数应为 +4(仅 profile_status='approved')"); + } + + @Test + @DisplayName("实名口径不得回退为「资料没被退回」(修复前缺陷的回归守卫)") + void realNameIsNotProfileStatusProxy() { + long verifiedBefore = metrics.realNameVerifiedUsers(); + + // 造 5 个「资料已填完但从未做实名认证」的用户: + // 修复前的口径(profile_status != 'rejected')会把它们全部算成已实名。 + for (int i = 0; i < 5; i++) { + String id = newUser(); + newProfile(id, "completed"); + } + + assertEquals(verifiedBefore, metrics.realNameVerifiedUsers(), + "资料状态为 completed 但无 user_identities 记录的用户**不是**已实名用户 —— " + + "「资料没被退回」与「已实名」是两个不同的概念"); + } + + // ==================== Gate 2:2 Like = 1 Mutual Match ==================== + + @Test + @DisplayName("Gate:2 个 Like 形成 1 个 Mutual Match") + void gateTwoLikesProduceOneMutualMatch() { + long likesBefore = metrics.likes(); + long matchesBefore = metrics.matches(); + + String userA = newUser(); + String userB = newUser(); + newRecommendation(userA, userB, "daily", "liked"); + newRecommendation(userB, userA, "daily", "liked"); + newMatch(userA, userB, "user_like", "active"); + + assertEquals(likesBefore + 2, metrics.likes(), "两条单向 Like 应计为 2"); + assertEquals(matchesBefore + 1, metrics.matches(), "双向 Like 只形成 1 个 Match"); + } + + @Test + @DisplayName("Match 必须按来源分离,不能混成一个数字") + void matchesAreSeparatedBySource() { + long userLikeBefore = metrics.matchesBySource("user_like"); + long mutualBefore = metrics.matchesBySource("activity_mutual"); + long matchmakerBefore = metrics.matchesBySource("matchmaker"); + + newMatch(newUser(), newUser(), "user_like", "active"); + newMatch(newUser(), newUser(), "activity_mutual", "active"); + newMatch(newUser(), newUser(), "matchmaker", "active"); + + assertEquals(userLikeBefore + 1, metrics.matchesBySource("user_like")); + assertEquals(mutualBefore + 1, metrics.matchesBySource("activity_mutual")); + assertEquals(matchmakerBefore + 1, metrics.matchesBySource("matchmaker")); + } + + // ==================== Gate 3:System Message 不算 Chat Started ==================== + + @Test + @DisplayName("Gate:只有系统消息的会话不计入 Chat Started") + void gateSystemMessageDoesNotCountAsChatStarted() { + long startedBefore = metrics.chatsStarted(); + + // 会话 1:只有 system 消息(平台下发的「你们已互相喜欢」) + String systemOnly = newConversation(); + newMessage(systemOnly, "system"); + + assertEquals(startedBefore, metrics.chatsStarted(), + "仅有 system 消息的会话不算开启聊天 —— 修复前直接数会话行数," + + "系统通知会把该指标系统性高估"); + + // 会话 2:双方真正开口 + String realChat = newConversation(); + newMessage(realChat, "text"); + assertEquals(startedBefore + 1, metrics.chatsStarted(), "有真人消息的会话才 +1"); + } + + @Test + @DisplayName("risk_notice 同样不计入 Chat Started") + void riskNoticeDoesNotCountAsChatStarted() { + long startedBefore = metrics.chatsStarted(); + String conversationId = newConversation(); + newMessage(conversationId, "risk_notice"); + assertEquals(startedBefore, metrics.chatsStarted(), "风险提示是平台下发的,不是用户发言"); + } + + @Test + @DisplayName("同一会话内多条真人消息只算 1 个 Chat Started(去重键 = conversation_id)") + void chatStartedDeduplicatesByConversation() { + long startedBefore = metrics.chatsStarted(); + String conversationId = newConversation(); + newMessage(conversationId, "text"); + newMessage(conversationId, "image"); + newMessage(conversationId, "text"); + assertEquals(startedBefore + 1, metrics.chatsStarted(), + "同一会话的多条消息只算一次 —— 去重键是 conversation_id 而非消息 id"); + } + + // ==================== Gate 4:活动 10 对互选 ≠ 10 对 Success ==================== + + @Test + @DisplayName("Gate:10 对互选成立 ≠ 10 对成功牵手") + void gateMutualConfirmationDoesNotEqualSuccess() { + long bothConfirmedBefore = metrics.mutualBothConfirmed(); + long mutualSuccessfulBefore = metrics.mutualSuccessful(); + + String activityId = UUID.randomUUID().toString(); + // 10 对双确认 + for (int i = 0; i < 10; i++) { + newMutualConsent(activityId, newUser(), newUser(), "accepted", "accepted"); + } + // 其中只有 3 对最终成功牵手 + for (int i = 0; i < 3; i++) { + newMatch(newUser(), newUser(), "activity_mutual", "successful"); + } + + assertEquals(bothConfirmedBefore + 10, metrics.mutualBothConfirmed(), + "互选成立对数应为 10(严格双确认)"); + assertEquals(mutualSuccessfulBefore + 3, metrics.mutualSuccessful(), + "互选后成功牵手数应为 3"); + assertNotEquals(metrics.mutualBothConfirmed(), metrics.mutualSuccessful(), + "互选成立对数与成功牵手数**必须可以不同** —— " + + "把两者当成同一个数字会让「10 对互选」被引述为「10 对成功」"); + } + + @Test + @DisplayName("单侧同意的互选不算成立(防止把「有人点了同意」当成互选)") + void oneSidedConsentIsNotMutual() { + long bothConfirmedBefore = metrics.mutualBothConfirmed(); + String activityId = UUID.randomUUID().toString(); + newMutualConsent(activityId, newUser(), newUser(), "accepted", "pending"); + newMutualConsent(activityId, newUser(), newUser(), "pending", "accepted"); + newMutualConsent(activityId, newUser(), newUser(), "accepted", "declined"); + assertEquals(bothConfirmedBefore, metrics.mutualBothConfirmed(), + "只有两侧都是 accepted 才算互选成立"); + } + + // ==================== Gate 5:红娘看 20 候选、发 3 个 → Recommendation=3 ==================== + + @Test + @DisplayName("Gate:红娘发出 3 条推荐 → Recommendation=3(不是浏览过的 20 个候选)") + void gateMatchmakerRecommendationCountsOnlySent() { + long before = metrics.matchmakerRecommendations(); + + // 红娘浏览了 20 个候选(不产生推荐记录),实际只发出 3 条 + for (int i = 0; i < 3; i++) { + newRecommendation(newUser(), newUser(), "matchmaker", null); + } + // 干扰项:用户端自动推荐 17 条(source 不是 matchmaker),不该计入红娘业绩 + for (int i = 0; i < 17; i++) { + newRecommendation(newUser(), newUser(), "daily", null); + } + + assertEquals(before + 3, metrics.matchmakerRecommendations(), + "红娘推荐数必须按 source='matchmaker' 过滤;" + + "把 daily / discover 的自动推荐算进来会虚增红娘业绩"); + } + + // ==================== Gate 6:服务用户数 vs 服务人次 ==================== + + @Test + @DisplayName("Gate:同一用户 3 次预约 + 1 次咨询 → 用户数计 1、人次计 4") + void gateServiceUsersDeduplicatedButVisitsAreNot() { + long visitsBefore = metrics.serviceVisits(); + long uniqueBefore = metrics.uniqueServiceUsers(); + + String sameUser = newUser(); + newAppointment(sameUser, "submitted"); + newAppointment(sameUser, "confirmed"); + newAppointment(sameUser, "completed"); + newCounseling(sameUser); + + assertEquals(visitsBefore + 4, metrics.serviceVisits(), + "服务人次不去重:3 次预约 + 1 次咨询 = 4 人次"); + assertEquals(uniqueBefore + 1, metrics.uniqueServiceUsers(), + "服务用户数跨域去重:同一个人只算 1 人 —— " + + "两者比值即人均服务次数,混用会同时高估覆盖面与低估承载量"); + } + + @Test + @DisplayName("已取消的预约不计入服务人次") + void cancelledAppointmentIsNotAServiceVisit() { + long before = metrics.serviceVisits(); + newAppointment(newUser(), "cancelled"); + assertEquals(before, metrics.serviceVisits(), "取消的预约没有实际提供服务"); + } + + // ==================== Gate 7:Dashboard 与专项页必须一致 ==================== + + @Test + @DisplayName("Gate:Dashboard 与专项页的同一指标必须完全相等") + void gateDashboardAgreesWithDetailViews() { + // 造一批数据,使各视图都有非零值可比较 + for (int i = 0; i < 6; i++) { + String id = newUser(); + newVerifiedIdentity(id, "verified"); + newProfile(id, "approved"); + } + String a = newUser(); + String b = newUser(); + newRecommendation(a, b, "daily", "liked"); + newRecommendation(b, a, "daily", "liked"); + newMatch(a, b, "user_like", "successful"); + String realChat = newConversation(); + newMessage(realChat, "text"); + + Map dashboard = analyticsService.getDashboardStats(); + Map userFunnel = analyticsService.getUserFunnel(null, null); + Map matchFunnel = analyticsService.getMatchFunnel(null, null); + + assertEquals(userFunnel.get("verifiedCount"), dashboard.get("verifiedUsers"), + "实名人数在 Dashboard 与用户漏斗必须相同(修复前是两套独立实现," + + "字段名还一个叫 verifiedUsers 一个叫 verifiedCount)"); + assertEquals(userFunnel.get("profileApproved"), dashboard.get("approvedProfiles"), + "审核通过数在两个视图必须相同"); + assertEquals(matchFunnel.get("totalMatches"), dashboard.get("totalMatches"), + "Match 总数在两个视图必须相同"); + assertEquals(matchFunnel.get("chatsStarted"), dashboard.get("chatsStarted"), + "开启聊天数在两个视图必须相同"); + assertEquals(matchFunnel.get("successPairs"), dashboard.get("successPairs"), + "成功牵手数在两个视图必须相同"); + } + + @Test + @DisplayName("Dashboard 的活动指标不再是硬编码 0(修复前活动漏斗永远显示 0)") + void dashboardActivityMetricsAreNotHardcodedZero() { + Map dashboard = analyticsService.getDashboardStats(); + long before = ((Number) dashboard.get("totalRegistrations")).longValue(); + + String activityId = UUID.randomUUID().toString(); + newMutualConsent(activityId, newUser(), newUser(), "accepted", "accepted"); + + Map after = analyticsService.getDashboardStats(); + assertEquals(before, ((Number) after.get("totalRegistrations")).longValue(), + "报名数不应受互选影响(口径隔离)"); + assertEquals(1L, ((Number) after.get("mutualBothConfirmed")).longValue() + - ((Number) dashboard.get("mutualBothConfirmed")).longValue(), + "新增 1 对互选后 mutualBothConfirmed 必须 +1 —— " + + "修复前该字段根本不存在(活动漏斗全为硬编码 0)"); + } + + // ==================== Gate 8:软删数据不得计入(D12 守卫) ==================== + + @Test + @DisplayName("软删除的用户不计入注册数(BaseEntity 无 @TableLogic,必须显式过滤)") + void softDeletedUserIsExcludedFromRegisteredCount() { + long before = metrics.registeredUsers(); + + User deleted = new User(); + deleted.setId(UUID.randomUUID().toString()); + deleted.setWechatOpenid("openid-" + UUID.randomUUID()); + deleted.setAccountStatus("active"); + deleted.setDeletedAt(Instant.now()); + userMapper.insert(deleted); + + assertEquals(before, metrics.registeredUsers(), + "已软删的用户不能计入累计注册数 —— BaseEntity.deletedAt 没有 @TableLogic," + + "selectCount 不会自动排除软删,必须显式加 deleted_at IS NULL"); + } + + @Test + @DisplayName("软删除的实名记录不计入实名数") + void softDeletedIdentityIsExcluded() { + long before = metrics.realNameVerifiedUsers(); + UserIdentity identity = new UserIdentity(); + identity.setId(UUID.randomUUID().toString()); + identity.setUserId(UUID.randomUUID().toString()); + identity.setVerificationStatus("verified"); + identity.setVerifiedAt(Instant.now()); + identity.setDeletedAt(Instant.now()); + userIdentityMapper.insert(identity); + + assertEquals(before, metrics.realNameVerifiedUsers(), "已软删的实名记录不应计入"); + } + + @Test + @DisplayName("软删的专项成员 / 本地资格不计入服务用户数、分域明细与服务人次(D12 补全)") + void softDeletedServiceRowsAreExcludedFromServiceMetrics() { + Map before = analyticsService.getUniqueServiceUsersStats(); + long totalUsersBefore = ((Number) before.get("totalUniqueServiceUsers")).longValue(); + long programBefore = ((Number) before.get("programUniqueUsers")).longValue(); + long eligibilityBefore = ((Number) before.get("eligibilityUniqueUsers")).longValue(); + long visitsBefore = metrics.serviceVisits(); + + // 2 条已软删:不得计入任何服务指标 + newProgramMember(newUser(), "approved", true); + newLocalEligibility(newUser(), true); + // 2 条存活:作为对照,证明查询本身是活的(否则"恒为 0"也会误通过) + newProgramMember(newUser(), "approved", false); + newLocalEligibility(newUser(), false); + + Map after = analyticsService.getUniqueServiceUsersStats(); + assertEquals(programBefore + 1, ((Number) after.get("programUniqueUsers")).longValue(), + "已软删的专项成员不得计入分域明细 —— 该明细与已过滤的总数同屏展示," + + "漏过滤会让各部分之和对不上总数"); + assertEquals(eligibilityBefore + 1, ((Number) after.get("eligibilityUniqueUsers")).longValue(), + "已软删的本地资格不得计入分域明细"); + assertEquals(totalUsersBefore + 2, ((Number) after.get("totalUniqueServiceUsers")).longValue(), + "服务用户数只计未软删记录:本用例新增 2 个存活用户(专项 1 + 资格 1)"); + assertEquals(visitsBefore + 2, metrics.serviceVisits(), + "服务人次同样只计未软删记录(专项成员 + 本地资格各 1 条存活)"); + } + + @Test + @DisplayName("软删的预约不计入服务站统计(D12 补全)") + void softDeletedAppointmentIsExcludedFromStationStats() { + Map before = analyticsService.getServiceStationStats(); + long totalBefore = ((Number) before.get("totalAppointments")).longValue(); + + newAppointment(newUser(), "confirmed", false); + newAppointment(newUser(), "confirmed", true); + + Map after = analyticsService.getServiceStationStats(); + assertEquals(totalBefore + 1, ((Number) after.get("totalAppointments")).longValue(), + "已软删的预约不得计入服务站预约数 —— 只应有 1 条存活预约被计入"); + } + + // ==================== 附加:入参校验与可用性诚实性 ==================== + + @Test + @DisplayName("每日汇总的 days 参数越界应报校验错误,而不是静默返回空") + void dailySummaryRejectsInvalidDays() { + assertThrows(RuntimeException.class, () -> analyticsService.getDailySummary(0)); + assertThrows(RuntimeException.class, () -> analyticsService.getDailySummary(9999)); + } + + @Test + @DisplayName("Banner 曝光/点击不可计算时必须返回不可用标记,绝不能返回 0") + void bannerMetricsDoNotFakeZero() { + Map stats = analyticsService.getBannerStats(); + assertNull(stats.get("impressions"), + "曝光量无数据源时必须为 null,返回 0 会被误读为「真的没人看到」"); + assertNull(stats.get("clicks")); + assertEquals(false, stats.get("impressionsAvailable")); + assertEquals(false, stats.get("clicksAvailable")); + assertNotNull(stats.get("unavailableReason"), "必须说明不可用的原因"); + } + + @Test + @DisplayName("有效交友匹配必须带 provisional 标记(禁止对外宣传)") + void effectiveConnectionCarriesProvisionalFlag() { + Map stats = analyticsService.getEffectiveConnectionStats(); + assertEquals(true, stats.get("effectiveMatchesProvisional"), + "口径未确认的指标必须在响应里显式标注,避免被当成正式成功率引用"); + assertNotNull(stats.get("provisionalNote")); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/analytics/AnalyticsServiceTest.java b/backend/src/test/java/com/danyang/match/platform/analytics/AnalyticsServiceTest.java deleted file mode 100644 index 22e7732..0000000 --- a/backend/src/test/java/com/danyang/match/platform/analytics/AnalyticsServiceTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.danyang.match.platform.analytics; - -import com.danyang.match.platform.shared.constant.Constants; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Phase 20: Analytics 测试 - */ -class AnalyticsServiceTest { - - @Test - void testFunnelStagesExist() { - // 核心不变量:漏斗各阶段数据可计算 - assertTrue(true); - } - - @Test - void testDailySummaryIncludesAllMetrics() { - // 每日汇总必须包含:新增用户、新增Match、新增聊天、新增举报 - assertTrue(true); - } - - @Test - void testConversionRatesNotNull() { - // 转化率不能为null,应为有效百分比字符串或"0%" - assertTrue(true); - } - - @Test - void testGenderRatioValid() { - // 男女比例字段有效(male/female) - assertTrue(true); - } - - @Test - void testAgeDistributionValidRanges() { - // 年龄区间有效(18-24, 25-29, 30-34, ...) - assertTrue(true); - } -} diff --git a/backend/src/test/java/com/danyang/match/platform/analytics/AnalyticsSoftDeleteGuardTest.java b/backend/src/test/java/com/danyang/match/platform/analytics/AnalyticsSoftDeleteGuardTest.java new file mode 100644 index 0000000..1d23c59 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/analytics/AnalyticsSoftDeleteGuardTest.java @@ -0,0 +1,174 @@ +package com.danyang.match.platform.analytics; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 统计口径「软删过滤」结构性守卫(Phase 20 D12)。 + * + *

D12 的根因是**系统性**的,而不是某个方法写错了: + * {@code BaseEntity.deletedAt} 没有 {@code @TableLogic} 注解,项目也没有配置 + * MyBatis-Plus 的全局逻辑删除字段。于是 {@code selectCount(wrapper)} 在 wrapper 里 + * 不放软删条件时,生成的 SQL 就是 {@code SELECT COUNT(*) FROM t} —— + * **软删除的数据会被完整计入统计,且没有任何报错**: + * 用户被删、Banner 下架删除、活动软删之后,看板数字依然把它们算在内, + * "累计注册用户数"只会增不会减。 + * + *

因为根因是"少写一个条件",靠逐处人工补齐必然遗漏 —— + * 本守卫的作用就是把「必须显式过滤」变成一条**可执行的结构约束**: + * 统计包内每一处直接的 {@code *Mapper.selectCount(..)} / {@code *Mapper.selectList(..)} + * 调用,都必须在其参数表达式中出现 {@code isNull(..)}。 + * + *

为什么是"解析源码"而不是"断言某几个数字"

+ * + *

数字断言只能覆盖被构造出来的固定数据集,对**尚未被想到的新统计查询**毫无约束力 —— + * 而那正是 D12 复发的地方。源码级约束对新写的代码同样生效。 + * + *

实现上有两个刻意的处理: + *

    + *
  • 先**剥离注释**再匹配 —— 否则形如 + * {@code // 修复前这里有一行 userMapper.selectCount(...)} 的说明文字 + * 会被误判为真实调用(本守卫开发时确实踩到过这个假阳性);
  • + *
  • 按**括号配平**截取完整实参,而不是匹配到行尾 —— + * 查询条件普遍写成多行链式调用,只看单行会漏掉写在后面的 {@code isNull(..)}。
  • + *
+ */ +class AnalyticsSoftDeleteGuardTest { + + /** 统计口径代码所在包(Service + 统一计算层)。 */ + private static final Path ANALYTICS_SRC = + Path.of("backend/src/main/java/com/danyang/match/platform/analytics"); + + /** 直接返回统计值的 Mapper 方法名 —— 这些是唯一会产生"计数/清单"的地方。 */ + private static final Pattern STATS_CALL = + Pattern.compile("\\w+Mapper\\.(selectCount|selectList)\\s*\\("); + + /** + * 兜底下界:防止"改坏定位逻辑 → 一个调用都没扫到 → 守卫静默变绿"。 + * + *

当前实测 45 处。取 40 留出正常重构的余量,但足以在 + * 「目录搬走 / 正则写错 / 文件读取失败」时立刻变红。 + */ + private static final int MIN_EXPECTED_CALL_SITES = 40; + + private static List analyticsSources() throws IOException { + Path dir = ANALYTICS_SRC; + assertTrue(Files.isDirectory(dir), + "守卫无法定位统计源码目录:" + dir.toAbsolutePath().normalize() + + "(CWD=" + Path.of("").toAbsolutePath() + ")。" + + "定位失败必须显式报错,绝不允许静默通过。"); + try (Stream files = Files.walk(dir)) { + return files.filter(p -> p.toString().endsWith(".java")).sorted().toList(); + } + } + + /** 剥离块注释与行注释 —— 注释里的示例代码不是真实调用。 */ + private static String stripComments(String source) { + return source.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("//[^\\n]*", " "); + } + + /** 从 {@code openIdx} 处的 '(' 起做括号配平,返回匹配的 ')' 下标(-1 表示未配平)。 */ + private static int matchingParen(String s, int openIdx) { + int depth = 0; + for (int i = openIdx; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + if (depth == 0) { + return i; + } + } + } + return -1; + } + + /** 一处未加软删过滤的统计调用。 */ + private record Violation(String file, int line, String call) { + @Override + public String toString() { + return file + ":" + line + " → " + call; + } + } + + private static List findUnfilteredStatsCalls() throws IOException { + List violations = new ArrayList<>(); + for (Path path : analyticsSources()) { + String raw = Files.readString(path); + String src = stripComments(raw); + Matcher m = STATS_CALL.matcher(src); + while (m.find()) { + int openIdx = src.indexOf('(', m.end() - 1); + int closeIdx = matchingParen(src, openIdx); + if (closeIdx < 0) { + // 配平失败说明正则切到了非代码区域 —— 显式失败,不静默跳过。 + throw new IllegalStateException("无法配平括号,可能扫到了非代码内容:" + path); + } + String args = src.substring(openIdx, closeIdx + 1); + if (!args.contains("isNull(")) { + int line = raw.substring(0, Math.min(m.start(), raw.length())).split("\n", -1).length; + violations.add(new Violation( + path.getFileName().toString(), line, + m.group(0) + args.replaceAll("\\s+", " "))); + } + } + } + return violations; + } + + @Test + @DisplayName("统计包内每处 Mapper 计数/查询都必须显式过滤软删(D12:无 @TableLogic,漏写即静默计入)") + void everyAnalyticsMapperQueryFiltersSoftDeletes() throws IOException { + List violations = findUnfilteredStatsCalls(); + assertTrue(violations.isEmpty(), + "以下统计查询缺少 isNull(..DeletedAt) 过滤 —— " + + "BaseEntity.deletedAt 没有 @TableLogic 注解,漏写会把软删数据静默计入统计:\n " + + String.join("\n ", violations.stream().map(Object::toString).toList())); + } + + @Test + @DisplayName("守卫本身必须扫到真实调用点(防止定位失效后静默变绿)") + void guardIsNotVacuouslyGreen() throws IOException { + int sites = 0; + for (Path path : analyticsSources()) { + Matcher m = STATS_CALL.matcher(stripComments(Files.readString(path))); + while (m.find()) { + sites++; + } + } + assertTrue(sites >= MIN_EXPECTED_CALL_SITES, + "只扫到 " + sites + " 处统计调用,低于下界 " + MIN_EXPECTED_CALL_SITES + + " —— 说明目录定位或正则已失效,本守卫当前是假绿。"); + } + + @Test + @DisplayName("统计包内不得出现无 wrapper 的 selectCount(null) 形式(等价于全表计数)") + void noUnfilteredBareCount() throws IOException { + for (Path path : analyticsSources()) { + String src = stripComments(Files.readString(path)); + assertFalse(src.contains("selectCount(null)"), + path.getFileName() + " 出现 selectCount(null) —— 该项目未配置全局逻辑删除字段," + + "这等价于 SELECT COUNT(*) 全表计数,软删数据会全部计入。"); + } + } + + @Test + @DisplayName("守卫可定位源码目录(路径失效必须显式报错而非跳过)") + void sourceDirectoryIsLocatable() throws IOException { + assertFalse(analyticsSources().isEmpty(), + "统计源码目录存在但一个 .java 都没扫到,守卫无法生效。"); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/analytics/MetricCatalogGuardTest.java b/backend/src/test/java/com/danyang/match/platform/analytics/MetricCatalogGuardTest.java new file mode 100644 index 0000000..d4634da --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/analytics/MetricCatalogGuardTest.java @@ -0,0 +1,229 @@ +package com.danyang.match.platform.analytics; + +import com.danyang.match.platform.analytics.metric.MetricCatalog; +import com.danyang.match.platform.analytics.metric.MetricDefinition; +import com.danyang.match.platform.analytics.metric.MetricStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 指标字典一致性守卫(Phase 20)。 + * + *

把「每个对外统计指标都必须在字典里有完整定义」从"评审时靠人记得检查" + * 变成**可执行的不变量**。 + * + *

背景:Phase 20 之前项目里有 15 个统计端点、40 多个返回字段, + * 但**没有任何一处说明这些数字是怎么算出来的**。后果是「实名人数」被写成 + * "资料没被退回的人数"这类口径错误能长期存活 —— 因为没有任何机制要求 + * 写代码的人回答"你的分子分母是什么、去重键是什么"。 + * + *

本守卫同时校验代码侧({@link MetricCatalog})与文档侧 + * ({@code docs/ANALYTICS_METRICS.md})**双向一致**: + * 代码登记了文档没写 → 失败;文档写了代码没登记 → 也失败。 + */ +class MetricCatalogGuardTest { + + private static final Path METRICS_DOC = Paths.get("docs/ANALYTICS_METRICS.md"); + + /** 指标编码格式:小写命名空间 + 点分层,如 user.identity.verified。 */ + private static final Pattern CODE_PATTERN = Pattern.compile("^[a-z][a-z0-9]*(\\.[a-z0-9_]+)+$"); + + /** 只从 Markdown 表格的**首列**提取编码,避免误抓正文里提到的表名/字段名。 */ + private static final Pattern DOC_TABLE_CODE = Pattern.compile("^\\|\\s*`([^`]+)`\\s*\\|", Pattern.MULTILINE); + + private static Set documentedCodes() throws IOException { + assertTrue(Files.exists(METRICS_DOC), + "指标字典文档缺失: " + METRICS_DOC.toAbsolutePath() + + "(Phase 20 要求为每个指标登记口径,文档是交付物之一)"); + String markdown = Files.readString(METRICS_DOC); + Set codes = new TreeSet<>(); + Matcher matcher = DOC_TABLE_CODE.matcher(markdown); + while (matcher.find()) { + String candidate = matcher.group(1).trim(); + if (CODE_PATTERN.matcher(candidate).matches()) { + codes.add(candidate); + } + } + return codes; + } + + @Test + @DisplayName("字典非空且覆盖 Phase 20 Gate 涉及的全部关键指标") + void catalogCoversGateCriticalMetrics() { + Set codes = MetricCatalog.all().stream() + .map(MetricDefinition::code) + .collect(Collectors.toSet()); + + // Gate 直接点名的口径,任何一个缺失都意味着 Gate 无法被验证 + List gateCritical = List.of( + "user.registered.total", // 注册10 + "user.identity.verified", // 实名7 ← 修复前口径造假的那个 + "user.profile.approved", // 审核通过4 + "like.total", // 2 Like + "match.total", // = 1 Mutual Match + "chat.started", // System Message 不算 + "activity.mutual.both_confirmed", // 活动10对互选 + "activity.mutual.successful", // ≠ 10 对 Success + "recommendation.shown.matchmaker", // 红娘发3个 + "service.user.unique", // 服务用户数 + "service.visit.total" // 服务人次(与上一个必须分离) + ); + List missing = gateCritical.stream().filter(c -> !codes.contains(c)).toList(); + assertTrue(missing.isEmpty(), "以下 Gate 关键指标未登记: " + missing); + } + + @Test + @DisplayName("每个已登记指标都必须在 ANALYTICS_METRICS.md 中有条目") + void everyRegisteredMetricIsDocumented() throws IOException { + Set documented = documentedCodes(); + List undocumented = MetricCatalog.all().stream() + .map(MetricDefinition::code) + .filter(code -> !documented.contains(code)) + .toList(); + assertTrue(undocumented.isEmpty(), + "以下指标已在 MetricCatalog 登记但文档未记录: " + undocumented + + "(口径必须有文档,否则等于没有口径)"); + } + + @Test + @DisplayName("文档不得出现未登记的指标编码(防止文档凭空描述不存在的口径)") + void documentationHasNoUnregisteredCodes() throws IOException { + Set registered = MetricCatalog.all().stream() + .map(MetricDefinition::code) + .collect(Collectors.toSet()); + List unregistered = documentedCodes().stream() + .filter(code -> !registered.contains(code)) + .toList(); + assertTrue(unregistered.isEmpty(), + "以下编码出现在文档中但未在 MetricCatalog 登记: " + unregistered + + "(新指标必须先登记再写文档)"); + } + + @Test + @DisplayName("指标编码不得重复") + void metricCodesAreUnique() { + Set seen = new TreeSet<>(); + List duplicates = new ArrayList<>(); + for (MetricDefinition definition : MetricCatalog.all()) { + if (!seen.add(definition.code())) { + duplicates.add(definition.code()); + } + } + assertTrue(duplicates.isEmpty(), "指标编码重复: " + duplicates); + } + + @Test + @DisplayName("Phase 20 要求登记 12 项元数据,缺一不可") + void everyMetricDeclaresFullMetadata() { + List incomplete = new ArrayList<>(); + for (MetricDefinition d : MetricCatalog.all()) { + List blanks = new ArrayList<>(); + if (isBlank(d.code())) blanks.add("code"); + if (isBlank(d.name())) blanks.add("name"); + if (isBlank(d.definition())) blanks.add("definition"); + // NOT_INSTRUMENTED 的指标允许分子分母为「无数据源」说明,但不能为空 + if (isBlank(d.numerator())) blanks.add("numerator"); + if (isBlank(d.denominator())) blanks.add("denominator"); + if (isBlank(d.dedupeKey())) blanks.add("dedupeKey"); + if (isBlank(d.timeField())) blanks.add("timeField"); + if (isBlank(d.timezone())) blanks.add("timezone"); + if (isBlank(d.version())) blanks.add("version"); + if (isBlank(d.owner())) blanks.add("owner"); + if (d.status() == null) blanks.add("status"); + if (isBlank(d.sourceTable())) blanks.add("sourceTable"); + if (!blanks.isEmpty()) { + incomplete.add(d.code() + " 缺少: " + blanks); + } + } + assertTrue(incomplete.isEmpty(), + "以下指标元数据不完整(Phase 20 要求名称/定义/分子分母/去重键/时间字段/时区/版本/负责人): " + + incomplete); + } + + @Test + @DisplayName("时区必须统一,否则「某天」的边界会随部署环境漂移") + void timezoneIsUnified() { + List drifted = MetricCatalog.all().stream() + .filter(d -> !MetricCatalog.TIMEZONE.equals(d.timezone())) + .map(d -> d.code() + " -> " + d.timezone()) + .toList(); + assertTrue(drifted.isEmpty(), + "以下指标时区与统一时区 " + MetricCatalog.TIMEZONE + " 不一致: " + drifted); + } + + @Test + @DisplayName("有效交友匹配必须标记为 PROVISIONAL,禁止当作正式成功口径") + void effectiveMatchIsProvisional() { + MetricDefinition effective = MetricCatalog.of("match.effective"); + assertEquals(MetricStatus.PROVISIONAL, effective.status(), + "「有效交友匹配」口径未确认前必须是 PROVISIONAL —— " + + "否则会被当成正式成功率对外引用(Phase 20 计划明确禁止)"); + assertTrue(effective.provisional()); + // 定义本身必须说明"禁止对外宣传",避免读者误用 + assertTrue(effective.definition().contains("禁止"), + "PROVISIONAL 指标的定义必须写明使用禁令"); + } + + @Test + @DisplayName("Banner 曝光/点击不可计算时必须标为 NOT_INSTRUMENTED,绝不能返回 0 冒充真实值") + void bannerMetricsAreMarkedNotInstrumented() { + for (String code : List.of("banner.impression", "banner.click")) { + MetricDefinition d = MetricCatalog.of(code); + assertEquals(MetricStatus.NOT_INSTRUMENTED, d.status(), + code + " 无埋点数据源,必须标为 NOT_INSTRUMENTED —— " + + "返回硬编码 0 会被读成「真的没人点击」,与「没有记录点击的地方」含义完全相反"); + assertTrue(d.notInstrumented()); + } + } + + @Test + @DisplayName("服务用户数与服务人次必须是两个已登记且去重键不同的指标") + void serviceUserCountIsSeparatedFromServiceVisitCount() { + MetricDefinition users = MetricCatalog.of("service.user.unique"); + MetricDefinition visits = MetricCatalog.of("service.visit.total"); + assertNotEquals(users.dedupeKey(), visits.dedupeKey(), + "服务用户数(跨域去重)与服务人次(不去重)的去重键必须不同," + + "否则两个指标会被混用 —— Phase 20 计划明确要求「分离服务用户数与服务人次」"); + assertTrue(users.dedupeKey().contains("去重")); + assertTrue(visits.dedupeKey().contains("不去重")); + } + + @Test + @DisplayName("取未登记编码必须抛异常,而不是静默返回 null") + void unknownCodeIsRejected() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> MetricCatalog.of("not.a.registered.metric")); + assertTrue(ex.getMessage().contains("未登记")); + } + + @Test + @DisplayName("provisional 与 not-instrumented 指标可被检索(供接口层附加使用警示)") + void provisionalAndUnavailableMetricsAreDiscoverable() { + assertFalse(MetricCatalog.provisional().isEmpty(), + "必须至少登记一个 PROVISIONAL 指标(有效交友匹配),否则说明口径状态机制未被使用"); + assertFalse(MetricCatalog.notInstrumented().isEmpty(), + "必须显式登记当前不可计算的指标,否则「不可用」会被伪装成 0"); + assertTrue(MetricCatalog.provisional().stream() + .anyMatch(d -> d.code().equals("match.effective"))); + assertTrue(MetricCatalog.notInstrumented().stream() + .anyMatch(d -> d.code().equals("banner.impression"))); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java index baa73b9..8f75c95 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java @@ -17,7 +17,8 @@ class MigrationValidationTest { // 基线守卫:新增 Flyway 迁移时必须同步递增此值 // Phase 19:V042 善缘服务站「临时公告」表(service_location_announcements) - private static final int LATEST_MIGRATION = 42; + // Phase 20:V043 打通 analytics.view 授权(data_viewer 专设却零权限 + platform_admin 被老权限段漏掉) + private static final int LATEST_MIGRATION = 43; private static final String MIGRATION_DIR = "backend/src/main/resources/db/migration/"; diff --git a/backend/src/test/java/com/danyang/match/platform/security/AnalyticsPermissionGrantTest.java b/backend/src/test/java/com/danyang/match/platform/security/AnalyticsPermissionGrantTest.java new file mode 100644 index 0000000..afe6436 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/security/AnalyticsPermissionGrantTest.java @@ -0,0 +1,188 @@ +package com.danyang.match.platform.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Analytics 权限授予守卫(Phase 20)。 + * + *

背景:{@code AnalyticsController} 上一直挂着 {@code @PreAuthorize("hasAuthority('analytics.view')")}, + * {@code OperatorEndpointPermissionCoverageTest} 也一直是绿的 —— + * 因为它只检查「端点有没有声明权限」,**不检查「这个权限有没有授予任何角色」**。 + * + *

于是出现了这样的组合: + *

    + *
  • V001 定义了一个专看统计的角色 {@code data_viewer}(描述:仅查看统计数据), + * 但全仓迁移从未给它授予任何权限 —— 它是个空壳角色;
  • + *
  • {@code platform_admin} 的权限扩展在 V015 里以 + * {@code id >= '20000000-...'} 为条件,而 {@code analytics.view} 的 id 是 + * {@code 10000000-...060}(V1.0 老权限段),因此被漏掉;
  • + *
  • 结果:整个 Analytics 模块除 {@code super_admin} 外全员 403, + * 前端"数据统计"菜单对谁都是点了报错。
  • + *
+ * + *

两个守卫都绿,功能却完全不可达 —— 这是"守卫覆盖不到的组合缺陷"。 + * 本测试补上「权限 → 角色」这一环。 + */ +class AnalyticsPermissionGrantTest { + + private static final String SUPER_ADMIN = "00000000-0000-0000-0000-000000000001"; + private static final String PLATFORM_ADMIN = "00000000-0000-0000-0000-000000000002"; + private static final String ORG_ADMIN = "00000000-0000-0000-0000-000000000003"; + private static final String REVIEWER = "00000000-0000-0000-0000-000000000004"; + private static final String MATCHMAKER = "00000000-0000-0000-0000-000000000005"; + private static final String ACTIVITY_MANAGER = "00000000-0000-0000-0000-000000000006"; + private static final String RISK_OPERATOR = "00000000-0000-0000-0000-000000000007"; + private static final String VOLUNTEER = "00000000-0000-0000-0000-000000000008"; + private static final String DATA_VIEWER = "00000000-0000-0000-0000-000000000009"; + private static final String STATION_OPERATOR = "00000000-0000-0000-0000-000000000010"; + private static final String COUNSELOR = "00000000-0000-0000-0000-000000000011"; + private static final String COUNSELING_MANAGER = "00000000-0000-0000-0000-000000000012"; + private static final String CONTENT_EDITOR = "00000000-0000-0000-0000-000000000013"; + + private static final String ANALYTICS_VIEW = "analytics.view"; + + private static String allMigrations() throws IOException { + Path dir = Path.of("backend/src/main/resources/db/migration"); + try (var files = Files.list(dir)) { + return files.filter(path -> path.getFileName().toString().endsWith(".sql")) + .sorted() + .map(path -> { + try { + return Files.readString(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }) + .collect(Collectors.joining("\n")); + } + } + + /** + * 解析某个角色被授予的权限集合。 + * + *

实现刻意**不做精细的 SQL 语法匹配**,而是"按语句切分 + 命中角色 ID 即提取权限码": + * 项目里的授权语句有两种写法({@code WHERE code IN (...)} 与 {@code WHERE code = '...'}), + * 用精确正则去匹配 {@code SELECT 'id', id FROM permissions WHERE code ...} 既脆弱又难维护 + * (换个换行或加个 ON CONFLICT 就漏解析,而漏解析会表现为"权限缺失"的误报)。 + * + *

判定条件:同一条 SQL 语句中同时出现 {@code role_permissions}、{@code INSERT} 与该角色 ID, + * 则该语句里的所有 {@code 命名空间.权限名} 形式字符串即该角色的授权。 + */ + private static Set grantsFor(String sql, String roleId) { + Set permissions = new HashSet<>(); + for (String statement : sql.split(";")) { + if (!statement.contains("role_permissions")) continue; + if (!statement.toUpperCase().contains("INSERT")) continue; + if (!statement.contains(roleId)) continue; + Matcher codes = Pattern.compile("'([a-z][a-z0-9_]*\\.[a-z0-9_.]+)'").matcher(statement); + while (codes.find()) { + permissions.add(codes.group(1)); + } + } + return permissions; + } + + @Test + @DisplayName("data_viewer 角色必须真的拥有 analytics.view(专设角色不得是空壳)") + void dataViewerRoleIsActuallyUsable() throws IOException { + Set granted = grantsFor(allMigrations(), DATA_VIEWER); + assertTrue(granted.contains(ANALYTICS_VIEW), + "data_viewer 在 V001 中被定义为「仅查看统计数据」,但从未被授予 analytics.view —— " + + "角色存在不等于角色可用,这个角色的用户访问统计接口会直接 403。" + + "当前实际授予: " + granted); + assertFalse(granted.isEmpty(), + "data_viewer 的权限集合不能为空(空壳角色)"); + } + + @Test + @DisplayName("platform_admin 必须拥有 analytics.view(V1.0 老权限段不得被角色扩展漏掉)") + void platformAdminCanViewAnalytics() throws IOException { + Set granted = grantsFor(allMigrations(), PLATFORM_ADMIN); + assertTrue(granted.contains(ANALYTICS_VIEW), + "platform_admin 的权限扩展在 V015 中以 id >= '20000000-...' 为条件," + + "而 analytics.view 属于 V1.0 老权限段(10000000-...060)→ 被漏掉。" + + "平台运营管理员看不到任何数据统计。当前实际授予: " + granted); + } + + @Test + @DisplayName("低权限角色一律不得拥有 analytics.view(Gate:低权限账号返回 403)") + void lowPrivilegeRolesCannotViewAnalytics() throws IOException { + String migrations = allMigrations(); + List forbidden = List.of( + REVIEWER, MATCHMAKER, ACTIVITY_MANAGER, RISK_OPERATOR, VOLUNTEER, + STATION_OPERATOR, COUNSELOR, COUNSELING_MANAGER, CONTENT_EDITOR); + for (String roleId : forbidden) { + assertFalse(grantsFor(migrations, roleId).contains(ANALYTICS_VIEW), + "角色 " + roleId + " 不应拥有 analytics.view —— " + + "Phase 20 Gate 要求「低权限账号返回 403」," + + "给它们全局统计权限等于开放全平台数据"); + } + } + + @Test + @DisplayName("organization_admin 刻意不授予全局统计权限(无范围限制的全局数据 = 越权)") + void organizationAdminIsDeliberatelyExcluded() throws IOException { + Set granted = grantsFor(allMigrations(), ORG_ADMIN); + assertFalse(granted.contains(ANALYTICS_VIEW), + "平台级统计接口所依赖的表(users / matches / messages)没有 organization_id 维度," + + "无法把机构管理员的数据范围限制在「本机构」。在没有范围限制的情况下授予全局统计权限" + + "= 越权可见全平台数据。应保持 403,待统计支持组织维度后再评估。"); + } + + @Test + @DisplayName("所有被授予的权限码必须真实存在于 permissions 定义中(防止授予不存在的权限)") + void everyGrantedPermissionCodeExists() throws IOException { + String migrations = allMigrations(); + Set defined = new HashSet<>(); + Matcher definitions = Pattern.compile( + "INSERT\\s+INTO\\s+permissions\\s*\\([^)]*\\)\\s*VALUES(.*?);", + Pattern.CASE_INSENSITIVE | Pattern.DOTALL).matcher(migrations); + // 权限行形如 ('', 'analytics.view', '查看统计', 'analytics', '说明')。 + // 取「UUID 之后的第一个字符串」为权限码 —— 不能要求它后面紧跟小写字母, + // 因为权限名是中文(修复前用 '[a-z] 作尾锚,导致一条都解析不出来)。 + Pattern codeColumn = Pattern.compile( + "\\(\\s*'[0-9a-fA-F-]{36}'\\s*,\\s*'([a-z][a-z0-9_.]*)'"); + while (definitions.find()) { + Matcher codes = codeColumn.matcher(definitions.group(1)); + while (codes.find()) { + defined.add(codes.group(1)); + } + } + assertTrue(defined.contains(ANALYTICS_VIEW), "analytics.view 必须在 permissions 表中有定义"); + + for (String roleId : List.of(DATA_VIEWER, PLATFORM_ADMIN)) { + for (String code : grantsFor(migrations, roleId)) { + assertTrue(defined.contains(code), + "角色 " + roleId + " 被授予了未定义的权限码: " + code); + } + } + } + + @Test + @DisplayName("super_admin 通过全量授予持有 analytics.view(V001 的兜底路径)") + void superAdminHoldsAllPermissions() throws IOException { + String migrations = allMigrations(); + // V001 的写法是 SELECT '', id FROM permissions;(无 WHERE) + boolean blanketGrant = Pattern.compile( + "SELECT\\s+'" + Pattern.quote(SUPER_ADMIN) + "'\\s*,\\s*id\\s+FROM\\s+permissions\\s*;", + Pattern.CASE_INSENSITIVE) + .matcher(migrations).find(); + assertTrue(blanketGrant, + "super_admin 依赖 V001 的全量授予兜底;若该语句被删除," + + "而 analytics.view 又没有别的授予路径,则整个统计模块将无人可访问"); + } +} diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index c2ffa28..0aff780 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -208,7 +208,17 @@ CREATE TABLE user_local_eligibilities ( id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) UNIQUE, eligibility_type VARCHAR(32), declared_region_code VARCHAR(32), verification_status VARCHAR(32), review_task_id VARCHAR(36), verified_at TIMESTAMP WITH TIME ZONE, verified_by VARCHAR(36), created_at TIMESTAMP WITH TIME ZONE, - updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE + updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE, + -- 对齐生产 V017:两条 CHECK 此前在测试库完全缺失(与 Phase 19 D9/D23 同族约束漂移)。 + -- 后果实证:AccountLifecycleService.invalidateLocalEligibility 曾把 user_identities 域的 + -- 'failed' 写进本表的 verification_status —— 在测试库"合法"、在真实 PostgreSQL 上 + -- 必然 CHECK 违约并使 closeAccount 整个事务回滚(注销 100% 不可用)。 + CONSTRAINT chk_elig_type CHECK (eligibility_type IN ( + 'household_registration', 'residence', 'employment', 'other' + )), + CONSTRAINT chk_elig_verify_status CHECK (verification_status IN ( + 'declared', 'needs_evidence', 'pending_review', 'verified', 'rejected' + )) ); -- ============================================================================ diff --git a/docs/ANALYTICS_METRICS.md b/docs/ANALYTICS_METRICS.md new file mode 100644 index 0000000..2a10e37 --- /dev/null +++ b/docs/ANALYTICS_METRICS.md @@ -0,0 +1,228 @@ +# 善缘丹阳 · 统计指标字典(ANALYTICS_METRICS) + +> 版本:v1 | 时区:`Asia/Shanghai` | 生效日期:2026-09-17 +> 代码载体:`backend/src/main/java/com/danyang/match/platform/analytics/metric/MetricCatalog.java` +> 一致性守卫:`MetricCatalogGuardTest`(文档缺任何已登记指标即失败) + +--- + +## 一、这份文档解决什么问题 + +Phase 20 之前,平台的统计数字**没有口径**。具体表现为: + +| 现象 | 后果 | +|---|---| +| 「实名人数」实际算的是 `user_profiles.profile_status != 'rejected'` | 把「资料还在填」(in_progress)、「被退回待改」(need_edit) 都算成已实名,对外汇报失真 | +| 「开启聊天数」直接数会话行数,从不看消息类型 | 一条系统通知(「你们已互相喜欢」)就让一个会话计入,指标被系统性高估 | +| 「Match 数」不分来源 | 用户自主匹配、活动互选、红娘促成的量混成一个数字,「活动 10 对互选」被当成「10 对成功」引用 | +| 「服务用户数」与「服务人次」只有一个概念 | 无法回答"服务了多少人"与"承载了多少次服务"这两个不同问题 | +| Dashboard / 漏斗 / 专项页各算一遍同名指标 | 同一指标在不同页面显示不同的值,且没有任何测试能发现 | +| 活动漏斗硬编码返回 `0` | 后台永远显示 0,不是没数据而是没查询 | +| Banner 曝光/点击硬编码返回 `0` | 被读成"真的没人看到",而实际是"根本没有记录的地方" | + +**本文档 + `MetricCatalog` 的作用**:让每个指标有唯一的、可被机器校验的定义, +消除"同名不同义"和"同义不同值"。 + +--- + +## 二、口径状态(`MetricStatus`) + +| 状态 | 含义 | 使用约束 | +|---|---|---| +| `OFFICIAL` | 口径已确认 | 可作为正式统计口径使用与对外引用 | +| `PROVISIONAL` | 口径**未**确认 | **禁止**当作正式成功口径,**禁止**对外宣传;接口响应中必须带 provisional 标记 | +| `NOT_INSTRUMENTED` | 数据源尚未埋点 | **禁止**返回 `0` 冒充真实值;必须显式返回不可用并说明原因 | + +> **为什么状态本身要建模**:`0` 与 `不可用` 对决策的含义完全相反。 +> 「Banner 点击 0」读起来是"投了没人点",而真相是"没有任何地方记录点击"。 +> 把这两种情况混为一谈,比算错一个数字更危险。 + +--- + +## 三、指标总表 + +> 分子 / 分母为口径的可执行定义;`—` 表示该指标不是比率、无分母。 +> 去重键说明该指标的「1 个单位」是什么,**是防止口径混淆的关键字段**。 + +### 3.1 用户域 + +| 指标编码 | 名称 | 分子 | 分母 | 去重键 | 时间字段 | 时区 | 版本 | 负责人 | 状态 | +|---|---|---|---|---|---|---|---|---|---| +| `user.registered.total` | 累计注册用户数 | `COUNT(users.id)` | — | `users.id` | `users.created_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL | +| `user.identity.verified` | 实名认证通过用户数 | `COUNT(DISTINCT user_identities.user_id) WHERE verification_status='verified'` | — | `user_identities.user_id` | `user_identities.verified_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL | +| `user.profile.approved` | 资料审核通过人数 | `COUNT(DISTINCT user_profiles.user_id) WHERE profile_status='approved'` | — | `user_profiles.user_id` | `user_profiles.updated_at` | Asia/Shanghai | v1 | 审核员 | OFFICIAL | +| `user.active.7d` | 近 7 日活跃用户数 | `COUNT(users.id) WHERE last_login_at >= NOW() - 7d` | — | `users.id` | `users.last_login_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL | + +> ⚠️ **`user.identity.verified` 与 `user.profile.approved` 是两件事**: +> 前者是**实名认证**(身份证核验,来源 `user_identities`), +> 后者是**资料审核**(来源 `user_profiles`)。 +> 一个人可以已实名但资料被打回,也可以资料通过但尚未实名。二者不可互相替代。 +> Gate「注册10 / 实名7 / 审核通过4」正是要求这三者分别正确且可同时成立。 + +### 3.2 推荐 / 匹配域 + +| 指标编码 | 名称 | 分子 | 分母 | 去重键 | 时间字段 | 时区 | 版本 | 负责人 | 状态 | +|---|---|---|---|---|---|---|---|---|---| +| `recommendation.shown.total` | 已展示推荐总数 | `COUNT(recommendation_records.id) WHERE shown_at IS NOT NULL` | — | `recommendation_records.id` | `recommendation_records.shown_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL | +| `recommendation.shown.matchmaker` | 红娘发出的推荐数 | `COUNT(...) WHERE source='matchmaker' AND shown_at IS NOT NULL` | — | `recommendation_records.id` | `recommendation_records.shown_at` | Asia/Shanghai | v1 | 红娘主管 | OFFICIAL | +| `like.total` | 用户 Like 总数 | `COUNT(...) WHERE action='liked'` | — | `recommendation_records.id` | `recommendation_records.action_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL | +| `match.total` | Match 总数 | `COUNT(matches.id)` | — | `matches.id` | `matches.matched_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL | +| `match.source.user_like` | 用户互相 Like 形成的 Match 数 | `COUNT(matches.id) WHERE source='user_like'` | — | `matches.id` | `matches.matched_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL | +| `match.source.activity_mutual` | 活动互选形成的 Match 数 | `COUNT(matches.id) WHERE source='activity_mutual'` | — | `matches.id` | `matches.matched_at` | Asia/Shanghai | v1 | 活动管理员 | OFFICIAL | +| `match.source.matchmaker` | 红娘促成的 Match 数 | `COUNT(matches.id) WHERE source='matchmaker'` | — | `matches.id` | `matches.matched_at` | Asia/Shanghai | v1 | 红娘主管 | OFFICIAL | +| `match.successful` | 成功牵手数 | `COUNT(matches.id) WHERE status='successful'` | — | `matches.id` | `matches.successful_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL | +| `match.effective` | 有效交友匹配数 | `COUNT(matches.id) WHERE status IN ('active','getting_to_know','successful') AND EXISTS(真人消息)` | `match.total` | `matches.id` | `matches.matched_at` | Asia/Shanghai | v1 | 平台运营 | **PROVISIONAL** | + +> ⚠️ **Gate:「2 个 Like = 1 个 Mutual Match」**。 +> `like.total` 是**单向**行为(A 喜欢 B 记 1 条),而 `match.total` 是**双向**结果。 +> 因此 Like 数天然约为 Match 数的 2 倍量级,两者**不可直接相除当作转化率的分母分子组合**去解读"效率"。 +> 若某天 Like 数与 Match 数接近 1:1,说明存在单向 Like 直接建 Match 的路径(缺陷信号)。 + +> ⚠️ **`match.effective` 处于 PROVISIONAL**:「有效」是否要求线下见面、是否要求关系持续 N 天, +> 业务方尚未裁决。当前实现是保守近似(真人会话数与 Match 数取较小值)。 +> **禁止**将其作为正式成功率口径,**禁止**写入对外宣传材料。 + +### 3.3 聊天域 + +| 指标编码 | 名称 | 分子 | 分母 | 去重键 | 时间字段 | 时区 | 版本 | 负责人 | 状态 | +|---|---|---|---|---|---|---|---|---|---| +| `chat.started` | 开启聊天会话数 | `COUNT(DISTINCT messages.conversation_id) WHERE message_type NOT IN ('system','risk_notice')` | — | `messages.conversation_id` | `messages.created_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL | +| `chat.conversation.total` | 会话总数 | `COUNT(conversations.id)` | — | `conversations.id` | `conversations.created_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL | + +> ⚠️ **Gate:「System Message 不算 Chat Started」**。 +> `message_type` 的值域由生产 `chk_msg_type` 限定为 `text / image / system / risk_notice`。 +> 后两者是平台下发的通知(含风险提示),用户本人没有任何发言,**不计入**开启聊天。 +> `chat.conversation.total` 与 `chat.started` 的差额,即「建了会话但双方从未真正开口」的数量。 + +### 3.4 活动域 + +| 指标编码 | 名称 | 分子 | 分母 | 去重键 | 时间字段 | 时区 | 版本 | 负责人 | 状态 | +|---|---|---|---|---|---|---|---|---|---| +| `activity.total` | 活动总数 | `COUNT(activities.id)` | — | `activities.id` | `activities.created_at` | Asia/Shanghai | v1 | 活动管理员 | OFFICIAL | +| `activity.registration.total` | 活动报名人次 | `COUNT(activity_registrations.id)` | — | `activity_registrations.id` | `activity_registrations.registered_at` | Asia/Shanghai | v1 | 活动管理员 | OFFICIAL | +| `activity.registration.approved` | 活动报名通过人次 | `COUNT(...) WHERE status='approved'` | — | `activity_registrations.id` | `activity_registrations.reviewed_at` | Asia/Shanghai | v1 | 活动管理员 | OFFICIAL | +| `activity.registration.checked_in` | 活动实际签到人次 | `COUNT(...) WHERE checkin_status='checked_in'` | — | `activity_registrations.id` | `activity_registrations.checkin_at` | Asia/Shanghai | v1 | 活动管理员 | OFFICIAL | +| `activity.mutual.both_confirmed` | 活动互选成立对数 | `COUNT(...) WHERE user_a_status='accepted' AND user_b_status='accepted'` | — | `activity_mutual_connection_consents.id` | `both_confirmed_at` | Asia/Shanghai | v1 | 活动管理员 | OFFICIAL | +| `activity.mutual.match` | 互选后建立 Match 数 | `COUNT(matches.id) WHERE source='activity_mutual'` | `activity.mutual.both_confirmed` | `matches.id` | `matches.matched_at` | Asia/Shanghai | v1 | 活动管理员 | OFFICIAL | +| `activity.mutual.successful` | 互选后成功牵手数 | `COUNT(matches.id) WHERE source='activity_mutual' AND status='successful'` | `activity.mutual.both_confirmed` | `matches.id` | `matches.successful_at` | Asia/Shanghai | v1 | 活动管理员 | OFFICIAL | + +> ⚠️ **Gate:「活动 10 对互选 ≠ 10 对 Success」**。 +> `activity.mutual.both_confirmed` 是**互选环节**的产出(严格双确认,不是"有人点了同意"); +> `activity.mutual.successful` 是**关系推进**的产出。 +> 中间还要经过聊天、相处、确认关系。两者绝不可互相替代引用。 + +### 3.5 公益服务域 + +| 指标编码 | 名称 | 分子 | 分母 | 去重键 | 时间字段 | 时区 | 版本 | 负责人 | 状态 | +|---|---|---|---|---|---|---|---|---|---| +| `service.visit.total` | 公益服务人次 | `Σ(预约 + 咨询 + 项目参与 + 资格办理) 记录数` | — | 各域记录 id(**不去重**) | 各域 `created_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL | +| `service.user.unique` | 服务用户数 | `COUNT(DISTINCT user_id)` 跨全部服务域 | — | `user_id`(**跨域去重**) | 各域 `created_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL | +| `service.appointment.total` | 服务预约人次 | `COUNT(...) WHERE status<>'cancelled'` | — | `service_appointments.id` | `created_at` | Asia/Shanghai | v1 | 服务站运营 | OFFICIAL | +| `counseling.total` | 婚恋咨询受理量 | `COUNT(counseling_services.id)` | — | `counseling_services.id` | `created_at` | Asia/Shanghai | v1 | 咨询管理员 | OFFICIAL | +| `service.program.member.approved` | 专项服务项目在册成员数 | `COUNT(...) WHERE status='approved'` | — | `service_program_members.id` | `created_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL | +| `content.published.total` | 已发布内容数 | `COUNT(...) WHERE status='published'` | — | `content_articles.id` | `published_at` | Asia/Shanghai | v1 | 内容编辑 | OFFICIAL | +| `content.views.total` | 内容累计阅读量 | `SUM(view_count) WHERE status='published'` | — | `content_articles.id` | `published_at` | Asia/Shanghai | v1 | 内容编辑 | OFFICIAL | + +> ⚠️ **`service.visit.total` 与 `service.user.unique` 必须分别汇报**(Phase 20 计划明确要求「分离服务用户数与服务人次」): +> 前者衡量**服务承载量**(同一用户来 3 次算 3 人次),后者衡量**服务覆盖面**(同一用户只算 1 人)。 +> 两者比值即人均服务次数。把两者混用会同时高估覆盖面和低估承载量。 + +### 3.6 Banner 域 + +| 指标编码 | 名称 | 分子 | 分母 | 去重键 | 时间字段 | 时区 | 版本 | 负责人 | 状态 | +|---|---|---|---|---|---|---|---|---|---| +| `banner.total` | Banner 总数 | `COUNT(home_banners.id) WHERE deleted_at IS NULL` | — | `home_banners.id` | `created_at` | Asia/Shanghai | v1 | 内容编辑 | OFFICIAL | +| `banner.active` | 生效中的 Banner 数 | `COUNT(...) WHERE status='active' AND NOW() BETWEEN start_at AND end_at` | — | `home_banners.id` | `NOW()` | Asia/Shanghai | v1 | 内容编辑 | OFFICIAL | +| `banner.impression` | Banner 曝光量 | —(无数据源) | — | —(尚未埋点) | — | Asia/Shanghai | v1 | 内容编辑 | **NOT_INSTRUMENTED** | +| `banner.click` | Banner 点击量 | —(无数据源) | — | —(尚未埋点) | — | Asia/Shanghai | v1 | 内容编辑 | **NOT_INSTRUMENTED** | + +> ⚠️ `banner.impression` / `banner.click` **当前不可计算**: +> 项目内不存在曝光/点击的上报端点,也不存在事件表。 +> 接口返回 `impressions: null` + `impressionsAvailable: false` + 原因说明, +> **不返回 `0`**。要启用这两个指标,需要先建事件表与上报端点(见"待办")。 + +### 3.7 安全域 + +| 指标编码 | 名称 | 分子 | 分母 | 去重键 | 时间字段 | 时区 | 版本 | 负责人 | 状态 | +|---|---|---|---|---|---|---|---|---|---| +| `risk.report.pending` | 待处理举报数 | `COUNT(reports.id) WHERE status='pending'` | — | `reports.id` | `reports.created_at` | Asia/Shanghai | v1 | 风控人员 | OFFICIAL | +| `risk.report.resolved` | 已处理举报数 | `COUNT(reports.id) WHERE status='resolved'` | — | `reports.id` | `reports.resolved_at` | Asia/Shanghai | v1 | 风控人员 | OFFICIAL | +| `risk.user.high_risk` | 高风险用户数 | `COUNT(risk_actions.id) WHERE action_type IN ('ban','temporary_suspend') AND status='active'` | — | `risk_actions.id` | `risk_actions.created_at` | Asia/Shanghai | v1 | 风控人员 | OFFICIAL | + +--- + +## 四、验收用的固定数据集 + +Phase 20 Gate 要求「用固定测试数据人工复算一致」。对应守卫测试 `AnalyticsMetricsPhase20IntegrationTest`: + +| 场景 | 构造数据 | 期望 | +|---|---|---| +| 用户漏斗 | 注册 10、实名 7、资料审核通过 4 | 三个数字分别显示 10 / 7 / 4,且**互不干扰** | +| Like 与 Match | A→B、B→A 各一条 Like | `like.total` = 2,`match.total` = 1 | +| Chat Started | 会话内仅有 `system` 消息 | 不计入 `chat.started` | +| 活动互选 | 10 对双确认,其中 3 对 `successful` | `activity.mutual.both_confirmed` = 10,`activity.mutual.successful` = 3,**不相等** | +| 红娘推荐 | 候选池 20 人,实际发出 3 条 | `recommendation.shown.matchmaker` = 3(不是 20) | +| 服务去重 | 同一用户 3 次预约、1 次咨询 | `service.user.unique` 计 1 人,`service.visit.total` 计 4 人次 | +| 跨视图一致 | 同一数据集 | Dashboard、用户漏斗、专项页的同一指标值完全相等 | + +--- + +## 五、软删口径(所有指标的强制前置条件) + +**每一个指标都隐含一个 WHERE 条件:`deleted_at IS NULL`。** 这不是可选项。 + +### 为什么必须显式写 + +`BaseEntity.deletedAt` **没有** `@TableLogic` 注解,项目也没有配置 MyBatis-Plus 的全局逻辑删除字段。 +因此 `selectCount(new LambdaQueryWrapper())` 生成的 SQL 是 `SELECT COUNT(*) FROM t` —— +**软删除的数据会被完整计入统计,且不会产生任何报错或警告**。 + +后果是隐蔽而持续的: + +| 场景 | 不写软删过滤的结果 | +|---|---| +| 用户被删除 | 「累计注册用户数」永久只增不减 | +| Banner 下架删除 | 「Banner 总数」把已删的算在内 | +| 活动软删 | 活动数、报名数偏高 | +| 服务记录软删 | 服务用户数与分域明细对不上(总数已过滤、明细未过滤) | + +这类偏差**不会有任何异常**,只会让运营看到的数字系统性偏高。 + +### 如何被强制 + +| 守卫 | 作用 | +|---|---| +| `AnalyticsSoftDeleteGuardTest` | 结构性约束:统计包内每一处 `*Mapper.selectCount(..)` / `selectList(..)` 的实参中**必须**出现 `isNull(..)`。覆盖尚未被想到的新查询,而不只是当前这几个。 | +| `AnalyticsMetricsPhase20IntegrationTest` | 行为断言:软删的用户 / 实名记录 / 专项成员 / 本地资格 / 预约,均不得改变对应指标。 | + +> 结构性守卫比逐条数字断言更有效:数字断言只能覆盖"被构造出来的固定数据集", +> 而漏写软删过滤的高发区恰恰是**新写的、还没被想到的**统计查询。 + +### 唯一口径来源 + +所有指标计算集中在 `AnalyticsMetricsCalculator`(以及 `AnalyticsService` 中少量按状态拆分的查询), +**不允许**在别处再实现一遍同名指标 —— 修复前 Dashboard / 用户漏斗 / 专项页各有一份实现且互不相同, +"改了其中一处忘了另两处"是必然发生的缺陷。 + +--- + +## 六、变更流程(强制) + +1. **新增指标**:先在 `MetricCatalog` 登记(含全部 12 个字段),再在本文件补表行,最后实现计算。 + `MetricCatalogGuardTest` 会拦截"只写代码不登记"或"登记了但文档没写"。 +2. **修改口径**:必须递增 `version`(如 v1 → v2),并在本文件记录变更原因。 + 未递增版本会让历史数据与新数据被误当成同一口径比较。 +3. **降级为 PROVISIONAL**:接口响应必须带 provisional 标记, + 且**禁止**出现在任何对外的成功率/宣传口径中。 +4. **不得返回硬编码 `0` 掩盖缺失数据源**:无数据源一律用 `NOT_INSTRUMENTED`。 + +--- + +## 七、已知待办 + +| 项 | 说明 | +|---|---| +| Banner 曝光/点击埋点 | 需新建 `home_banner_events` 事件表 + 上报端点 + 小程序端埋点,闭环后才可把两个指标从 `NOT_INSTRUMENTED` 提升为 `OFFICIAL` | +| `match.effective` 口径裁决 | 需业务方确认「有效」的判定标准(是否要求见面 / 持续 N 天),确认后提升为 `OFFICIAL` | +| 组织 / 站点维度拆分 | 当前统计为平台级;按组织或服务站拆分需叠加 `DataScope`(见 Phase 20 权限部分) | +| 大数据量优化 | `service.user.unique` 等跨域去重指标目前为内存去重;数据量达十万级时应改为数据库侧 `UNION` 聚合 | diff --git a/docs/JIRA_BACKLOG.md b/docs/JIRA_BACKLOG.md index 8d066e6..63e819d 100644 --- a/docs/JIRA_BACKLOG.md +++ b/docs/JIRA_BACKLOG.md @@ -24,7 +24,36 @@ > 证据:`docs/Phase-18-V11-Recovery-Completion-Report-2026-09-17.md`。 > > **2026-09-17 Phase 19 复验关闭:** EPIC-P19 已完成。复验不采信任何「历史 ✅」,逐条核对运行时路径与生产约束,查出并修复 **25 处真实缺陷**。高价值项:①**服务站「临时公告」整条不可达** —— `ServiceLocationAnnouncementService` 与 V042 表都已存在,但**全仓零端点**,后台与小程序都拿不到(D20);②**服务站 `create/update` 越权写** —— `@PreAuthorize("service_location.view")` 挂在**类**上,V015 下发的 `service_location.manage` 是**从未被使用的死权限**,只读角色可直接改站点(D21);③该控制器**未纳入** `OperatorEndpointPermissionCoverageTest` 扫描清单,导致 D21 长期隐形(D22);④Banner 校验失败抛裸 `RuntimeException` → **500**(应 400)+ 软删 Banner 仍上首页(D2/D3);⑤Content「发布」死路(后台复用只返回 published 的公开接口)+ 分类停用不可恢复(D5/D6);⑥ServiceProgram 审批死路 / 草稿泄露 / 成员数恒 0 / 退出未实现 / 重复申请撞唯一键(D10~D14);⑦测试库缺 `content_categories` 与 `service_location_announcements` → 对应路径**零覆盖**(D9/D23);⑧预约 IDOR(D18);⑨`MigrationValidationTest` 基线未跟进 V042(D24)。新增守卫:`ServiceLocationAnnouncementPhase19IntegrationTest`(24) / `ServiceLocationControllerPermissionContractTest`(5) / `ServiceLocationAnnouncementConstraintParityTest`(5,生产 DDL↔测试库↔后端白名单↔后台标签四方一致);`V11PermissionMatrixContractTest` 由硬编码 V015+V029 改为扫描全部迁移。负向验证:注入 3 处缺陷(移除软删过滤 / 移除测试库 CHECK / 写端点降级为 `view`)→ **4 个用例精准变红且无无关失败**,还原后 `NEG-VAL` 残留 0。后端 **602 tests 全绿**;admin-web `vue-tsc` EXIT=0 + `vite build` EXIT=0;小程序 `tsc --noEmit` EXIT=0;ui-tests 14/14 PASS。 -> 证据:`docs/Phase-19-V11-Recovery-Completion-Report-2026-09-17.md`。**Phase 20 尚未获准。** +> 证据:`docs/Phase-19-V11-Recovery-Completion-Report-2026-09-17.md`。 +> +> **2026-09-17 Phase 20 复验关闭:** EPIC-P20 已完成。`STATUS.md` 早已把 Phase 20 标为「✅ 已完成」, +> 复验起点是 `AnalyticsController` 15 端点 + `AnalyticsService` 546 行**确实存在**, +> 但其中同时含有**硬编码 0**、**占位死代码**、**字母级口径造假**、**全员 403**、**假绿测试**。 +> 逐条核对运行时路径与生产约束后修复:①**口径造假** —— 实名人数用 `user_profiles.profile_status != 'rejected'` +> 冒充(把"资料还在填"算成已实名);Chat Started 直接数会话行数(系统通知计入); +> Match 不分来源(10 对互选被当 10 对成功引用);**Banner 曝光/点击硬编码 0**;②**死代码** —— +> 活动漏斗**全部硬编码 0**(不是没数据,是根本没有查询)+ 一行把"用户数"当"活动数"的死代码; +> ③**系统性软删缺陷(D12)** —— `BaseEntity.deletedAt` 无 `@TableLogic`、全局无逻辑删除配置, +> `selectCount` 把**软删数据全部计入统计且无任何报错**,修复 45 处统计调用(**2 轮复验,首轮漏扫 5 处次级路径**); +> ④**权限授予脱节(Gate ⑩)** —— `data_viewer` 角色被定义却**从未授权**(空壳角色), +> `platform_admin` 因 `id >= '20000000-…'` 漏掉 V1.0 老权限段 → +> **整个模块除 super_admin 外全员 403**(含专为它设计的 `data_viewer`);⑤**三套独立实现** —— +> Dashboard/用户漏斗/专项页各实现一遍同名指标且互不相同、字段名还不一致(`verifiedUsers` vs `verifiedCount`); +> ⑥**假绿测试** —— `AnalyticsServiceTest` = 5×`assertTrue(true)`(零断言)。 +> 建立指标字典(`MetricCatalog` 36 指标 × 12 字段 + `docs/ANALYTICS_METRICS.md`)与统一口径层 +> `AnalyticsMetricsCalculator`;新增 V043 打通 `analytics.view` 授权(刻意不授予 `organization_admin` —— +> 相关表无组织维度,无范围的全量数据即越权);新增守卫 `MetricCatalogGuardTest`(11) / +> `AnalyticsMetricsPhase20IntegrationTest`(21) / `AnalyticsSoftDeleteGuardTest`(4,**结构性**约束:统计包内 +> 每处 Mapper 统计调用必须显式含 `isNull(..)`,含防假绿下界) / `AnalyticsPermissionGrantTest`(6)。 +> **跨阶段发现(本轮已修 X1~X3)**:`AccountLifecycleService` 注销时把 `user_identities` 状态域的 `'failed'` +> 写入本地资格表 → 违反生产 `V017 chk_elig_verify_status` → **真实 PG 上 `closeAccount` 事务回滚、 +> 账号注销 100% 不可用**(负向验证实测报 `DataIntegrityViolation`);`checkCanWithdraw` 的资格拦截用 +> `submitted`/`pending`(该表永不可能出现的值)→ **死代码**;测试库补 `chk_elig_type`/`chk_elig_verify_status` +> 后立即暴露 `ActivityPhase14Test` 夹具违规(`household` → `household_registration`)。新增 +> `LocalEligibilityStatusVocabularyGuardTest`(9)。负向验证两轮共 7 处注入 → **7 个用例精准变红且无无关失败** +> (其中一处被"源码结构 + 运行时行为"两层守卫同时捕获),还原后 `md5sum` 逐字节一致、`NEG-VAL` 残留 0。 +> 后端 **648 tests 全绿**;admin-web `vue-tsc --noEmit` EXIT=0;**Phase 21 尚未获准。** +> 证据:`docs/Phase-20-V11-Recovery-Completion-Report-2026-09-17.md`。 > **2026-09-16 状态校正:** Phase 07 UI 已按 2026-09-13 规格重新实现,**首批 11 页于 2026-09-16 通过用户验收(结论:基本达标)**,本文件此前"视觉否决/REOPENED"口径随之关闭。 > @@ -148,6 +177,7 @@ | Phase 17 举报与风控复验 | 已完成(2026-09-17) | 见 `Phase-17-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **6 处真实缺陷**(**D1(Critical,必崩级盲区)测试库缺 `reports`/`risk_events`/`risk_actions` 三表 → 风控/举报真实 DB 路径零覆盖,`ReportServiceTest` 全 `assertTrue(true)` 假绿**;**D3(High)敏感访问(查看他人风险画像/举报)无审计(违反 AGENTS.md #8)**;D4 审计缺 `ip`/`userAgent`;D5 `findByUserId` 未过滤软删;D2 人工标记无审计;**D6 `javax`→`jakarta` 隐藏编译缺陷 + `risk_events` 漏 `updated_at` 列**);补齐三表并移出白名单入 `REQUIRED_IN_TEST_SCHEMA`;新增 `AuditLogService.recordSensitiveAccess` 并接线三端点;新增 `ReportAndRiskPhase17IntegrationTest`(7 用例);负向验证(注释 getReportDetail 审计 → D3 守卫精准变红并还原);后端 **488 tests 全绿**;**Phase 18 尚未获准** | | Phase 18 申诉复验 | 已完成(2026-09-17) | 见 `Phase-18-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **8 处真实缺陷**(**D1(Critical,必崩级盲区)测试库缺 `appeals` 表 + `AppealServiceTest` 9 例中 6 例 `assertTrue(true)` 假绿 → 申诉真实 DB 路径零覆盖**;**D2(High,只在 PG 上炸)`escalateAppeal` 向 `operatorId` 发用户通知撞 `notifications.user_id → users(id)` FK → 升级事务整体回滚**;**D3(High,只在 PG 上炸)`appealType` 未校验生产 `chk_appeal_type` 白名单**;D4 escalated 死胡同(前后端均无法再决策);D5 `processAppeal` 先读后写并发竞态;D6 三处查询漏 `deleted_at` 过滤;D7 硬编码 `resolved`;D8 证据字段契约 `evidenceFileIds`);补 `appeals` 表并移出白名单入 `REQUIRED_IN_TEST_SCHEMA`;新增原子认领 `markReviewingIfProcessable`;新增 `AppealPhase18IntegrationTest`(15 用例),删除假绿 `AppealServiceTest`(9 用例);负向验证(误伤其他处置 → Gate 用例精准变红并还原);后端 **494 tests 全绿**;admin-web `vue-tsc` EXITCODE=0;**Phase 19 尚未获准** | | Phase 19 统一运营后台 + 用户端公益服务页面复验 | 已完成(2026-09-17) | 见 `Phase-19-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **25 处真实缺陷**(**D20 服务站公告能力整条不可达:Service + V042 表都在、全仓零端点**;**D21 服务站 `create/update` 越权写:类级 `service_location.view` 兜底,`service_location.manage` 是从未使用的死权限**;D22 该控制器未纳入权限覆盖守卫;**D2/D3 Banner 校验 500 + 软删 Banner 仍上首页**;**D5/D6 Content 发布死路 + 分类停用不可恢复**;D10~D14 ServiceProgram 审批死路/草稿泄露/成员数恒 0/退出未实现/撞唯一键;D9/D23 测试库缺 `content_categories` 与 `service_location_announcements` → 零覆盖;D18 预约 IDOR;D24 迁移基线未跟进 V042);补 V042 公告表 + 运营端五端点 + 用户端只读端点 + 后台公告管理 UI + 小程序 U50 公告区块;新增 3 组守卫(24+5+5 用例)并把服务站运营端收入权限覆盖扫描;负向验证 3 注入 → 4 用例精准变红;后端 **602 tests 全绿**;admin-web `vue-tsc`/`vite build` 双绿;小程序 `tsc` EXIT=0;ui-tests 14/14;**Phase 20 尚未获准** | +| Phase 20 Analytics 数据统计与管理驾驶舱复验 | 已完成(2026-09-17) | 见 `Phase-20-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **20 处真实缺陷**(**D1 实名人数用 `profile_status != 'rejected'` 冒充 → 把"资料还在填"算成已实名**;**D3 Chat Started 直接数会话行数 → 系统通知计入**;**D4 Match 不分来源 → 10 对互选被当 10 对成功引用**;**D5 活动漏斗全部硬编码 0 + 一行把"用户数"当"活动数"的死代码**;**D6 Banner 曝光/点击硬编码 0**;**D12 系统性软删缺陷:`BaseEntity.deletedAt` 无 `@TableLogic` + 全局无逻辑删除配置 → `selectCount` 把软删数据全部计入且无任何报错**,修复 45 处统计调用(含 2 轮复验补扫出的 5 处次级路径);**D15 三套独立实现** → 抽 `AnalyticsMetricsCalculator` 统一口径;**D13/权限 授予脱节:`data_viewer` 空壳角色 + `platform_admin` 被 `id >= '20000000-…'` 条件漏掉 V1.0 老权限段 → 整个模块除 super_admin 外全员 403**;**D14 `AnalyticsServiceTest` = 5×`assertTrue(true)` 假绿 → 删除**;D20 `getDailySummary` 无边界校验;前端 `AnalyticsView.vue` 未接 `/v11-dashboard`);建立指标字典 `MetricCatalog`(36 指标 × 12 字段)+ `docs/ANALYTICS_METRICS.md`;新增 V043 授权(**刻意排除 `organization_admin`** —— 相关表无组织维度,无范围全量数据即越权);新增守卫 `MetricCatalogGuardTest`(11) / `AnalyticsMetricsPhase20IntegrationTest`(21) / `AnalyticsSoftDeleteGuardTest`(4,结构性) / `AnalyticsPermissionGrantTest`(6);**跨阶段修复 X1~X3**(**注销写入 `user_identities` 状态域的 `'failed'` → 违反 V017 `chk_elig_verify_status` → 真实 PG 上账号注销 100% 不可用**;`checkCanWithdraw` 资格拦截死代码;测试库补两条 CHECK 后暴露 `ActivityPhase14Test` 夹具违规)并新增 `LocalEligibilityStatusVocabularyGuardTest`(9);负向验证两轮 7 注入 → 7 用例精准变红并还原(`md5sum` 逐字节一致);后端 **648 tests 全绿**;admin-web `vue-tsc` EXIT=0;**Phase 21 尚未获准** | | UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 | | UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 | | UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 | @@ -559,7 +589,7 @@ - P19-V11-T27 Program CRUD ✅ (GET/POST/PUT /service-programs) - P19-V11-T28 Member application ✅ (POST /service-programs/{id}/apply) - P19-V11-T29 Member approval ✅ (POST /operator/members/{id}/approve) -- P19-V11-T30 Program statistics basic ⏳ 待Phase 20 Analytics +- P19-V11-T30 Program statistics basic ✅ (Phase 20 已提供 `service.program.member.approved` 等统一口径) ### Banner管理(后端API) - P19-V11-T01~T07 ✅ (V019已有HomeBannerService + HomeController,后续补充Admin CRUD API) diff --git a/docs/Phase-20-V11-Recovery-Completion-Report-2026-09-17.md b/docs/Phase-20-V11-Recovery-Completion-Report-2026-09-17.md new file mode 100644 index 0000000..91a938f --- /dev/null +++ b/docs/Phase-20-V11-Recovery-Completion-Report-2026-09-17.md @@ -0,0 +1,338 @@ +# Phase 20 V1.1 Recovery Completion Report — Analytics 数据统计与管理驾驶舱 + +> 日期:2026-09-17 +> 阶段:Phase 20(Analytics — EXTEND) +> 分支:`main` | Tag:`phase20-recovery-2026-09-17` +> Bundle:`.workbuddy/backup/phase20-recovery-2026-09-17.bundle`(`complete history`,回读通过) +> 结论:**CLOSED(Gate 全项通过)** + +--- + +## 一、计划目标与 Gate + +官方计划(`WorkBuddy首轮启动提示词 + Phase 01~Phase 26 Jira级任务清单 + 每阶段验收条件.md` 第 1777 行起) +定义 `EPIC-P20 数据统计与管理驾驶舱`,任务 `P20-T01 ~ P20-T14`(指标字典 / User / Review / Recommendation / +Match / Chat Started / Matchmaker / Activity / Risk Metrics / Daily Summary / Rebuild / Platform Dashboard / +Organization Dashboard / Analytics 权限)。 + +### Gate 逐项结论 + +| Gate | 要求 | 结论 | 证据 | +|---|---|---|---| +| Gate ① | 每个指标登记名称、定义、分子分母、去重键、时间字段、时区、版本、负责人 | ✅ | `MetricCatalog`(36 指标 × 12 字段)+ `docs/ANALYTICS_METRICS.md`;`MetricCatalogGuardTest` 11 用例双向校验 | +| Gate ② | 固定数据集「注册10 / 实名7 / 审核通过4」显示一致 | ✅ | `AnalyticsMetricsPhase20IntegrationTest.gateUserFunnelCountsThreeDistinctConcepts` | +| Gate ③ | 2 个 Like = 1 个 Mutual Match | ✅ | `gateTwoLikesProduceOneMutualMatch` | +| Gate ④ | System Message 不算 Chat Started | ✅ | `gateSystemMessageDoesNotCountAsChatStarted` / `riskNoticeDoesNotCountAsChatStarted` | +| Gate ⑤ | 活动 10 对互选 ≠ 10 对 Success | ✅ | `gateMutualConfirmationDoesNotEqualSuccess`(互选 10 / 成功 3,且显式 `assertNotEquals`) | +| Gate ⑥ | 红娘看 20 候选、发 3 个 → Recommendation = 3 | ✅ | `gateMatchmakerRecommendationCountsOnlySent` | +| Gate ⑦ | Dashboard 与专项页相同指标必须一致 | ✅ | `gateDashboardAgreesWithDetailViews`;且由**统一口径层**从结构上保证(见 §三 D15) | +| Gate ⑧ | 服务用户数与服务人次分离 | ✅ | `gateServiceUsersDeduplicatedButVisitsAreNot`(1 人 / 4 人次) | +| Gate ⑨ | 有效匹配在口径确认前标 provisional,禁止对外宣传 | ✅ | `MetricCatalog` 标 `PROVISIONAL`;响应带 `effectiveMatchesProvisional` + 说明;`effectiveConnectionCarriesProvisionalFlag` | +| Gate ⑩ | 所有统计接口受 `analytics.view` 与 DataScope 约束,低权限 403 | ✅ | `V043` 授权 + `AnalyticsPermissionGrantTest` 6 用例(含低权限清单、`organization_admin` 刻意排除) | +| Gate ⑪ | 相同用户多次服务不污染去重人数 | ✅ | `gateServiceUsersDeduplicatedButVisitsAreNot` | +| Gate ⑫ | 软删数据不得计入统计 | ✅ | `AnalyticsSoftDeleteGuardTest`(结构性)+ 5 个行为用例 | + +--- + +## 二、复验方法(KEEP-VERIFY) + +本轮不采信任何「历史 ✅」。Phase 20 在 `STATUS.md` 里早已标注「✅ 已完成」,但复验起点是: +`AnalyticsController` 15 个端点、`AnalyticsService` 546 行**确实存在**, +其中却同时含有**硬编码 0**、**占位死代码**、**字母级口径造假**、**全员 403**、**假绿测试**。 + +逐项核对顺序: + +1. **运行时路径**:Controller → Service → Mapper → DDL 逐跳确认无断点、无死路。 +2. **生产约束**:核对迁移里的 `NOT NULL` / `CHECK` / `UNIQUE`,确认代码写入值合法。 +3. **测试库保真度**:测试库是否与生产同约束(缺约束 = 该非法路径零覆盖 = 假绿)。 +4. **守卫有效性**:守卫是否解析**真实 DDL/真实 SQL**、是否会**精准变红**。 +5. **前端落点**:后端能力是否有真实前端入口。 + +--- + +## 三、复验查出的真实缺陷与修复 + +### 3.1 口径造假类(数字是"算"出来的,但算错了概念) + +| 编号 | 指标 | 缺陷 | 修复 | +|---|---|---|---| +| **D1** | 实名认证通过用户数 | 用 `user_profiles.profile_status != 'rejected'`("资料没被退回")当作"已实名"。取值域里的 `in_progress`(资料还在填)、`need_edit`(被退回待改)全被算成已实名 | `UserIdentityMapper.countVerifiedUsers()`:`COUNT(DISTINCT user_id) FROM user_identities WHERE verification_status='verified'` | +| **D3** | 开启聊天会话数 | 直接数会话行数,从不看消息类型 —— 一条系统通知(「你们已互相喜欢」)就让会话计入 | `MessageMapper.countConversationsWithRealMessages()`:`COUNT(DISTINCT conversation_id) WHERE message_type NOT IN ('system','risk_notice')` | +| **D4** | Match 数 | 全来源混成一个数字,用户自主匹配 / 活动互选 / 红娘促成不可分。Gate「10 对互选 ≠ 10 对成功」在数据上无法体现 | 按 `source` 分离:`match.source.user_like` / `.activity_mutual` / `.matchmaker`,响应增 `matchesBySource` | +| **D6** | Banner 曝光量 / 点击量 | **硬编码 0**。0 被读成"投了没人点",真相是"项目里根本没有记录点击的地方" | 返回 `impressions: null` + `impressionsAvailable: false` + 原因说明;`MetricCatalog` 标 `NOT_INSTRUMENTED` | + +> D1 是最危险的一条:对外汇报"实名 X 人"时,把"资料还在填"的人也算进去了,数字系统性偏高且无法自查。 + +### 3.2 死代码 / 占位类(后台永远显示 0) + +| 编号 | 缺陷 | 修复 | +|---|---|---| +| **D5** | 活动漏斗**所有指标硬编码为 0**,并留着一行把「用户数」当「活动数」的死代码。后台"活动漏斗"不是没数据,是**根本没有查询** | 接入真实查询:`totalActivities` / `totalRegistrations` / `approvedRegistrations` / `checkedInCount`,并**三分离** `mutualBothConfirmed` / `mutualMatches` / `mutualSuccessful` | +| **D20** | `getDailySummary(days)` 无入参边界,越界静默返回 | 1~365 校验 → `BusinessException(VALIDATION_ERROR)` | + +### 3.3 系统性缺陷(D12:软删数据全部计入统计) + +**根因**:`BaseEntity.deletedAt` **没有** `@TableLogic` 注解,项目**也没有**配置 MyBatis-Plus 的全局逻辑删除字段。 +因此 `selectCount(new LambdaQueryWrapper())` 生成的 SQL 是 `SELECT COUNT(*) FROM t` —— +**软删除的数据被完整计入统计,且不产生任何报错或警告**。 + +| 影响 | 说明 | +|---|---| +| 累计注册用户数 | 用户删除后只增不减 | +| Banner / 活动 / 服务记录 | 软删后仍计入总数 | +| 每日汇总 | "新增用户/新增 Match"把已删记录继续算进当天 | + +**修复**:`AnalyticsService` + `AnalyticsMetricsCalculator` 内**全部**统计查询显式 `deleted_at IS NULL`(共 45 处直接 Mapper 统计调用,0 处遗漏)。 + +**补扫(本轮第 2 轮复验发现,属 D12 未扫净)**:首轮只修了"统一口径层"这条主路径,以下 **5 处**次级路径仍在漏: + +| 位置 | 后果 | +|---|---| +| `getUniqueServiceUsersStats` → `programUniqueUsers` | 分域明细与**已过滤的**总数同屏展示,各部分之和对不上总数 | +| `getUniqueServiceUsersStats` → `eligibilityUniqueUsers` | 同上 | +| `getServiceStationStats` → `totalAppointments` / `activeStations` | 服务站点统计偏高 | +| `getUserFunnel` → `genderRatio` | 分布与 `totalRegistered`(已过滤)对不上账 | +| `getUserFunnel` → `ageDistribution` | 同上 | + +### 3.4 结构类(三套实现 → 无单一事实来源) + +| 编号 | 缺陷 | 修复 | +|---|---|---| +| **D15** | Dashboard / 用户漏斗 / V11 专项仪表盘**各自实现了一遍**同名指标,且**互不相同**;字段名还不一致(`verifiedUsers` vs `verifiedCount`)。只要有一处漏改就永久不一致,且没有任何测试能发现 | 抽出 `AnalyticsMetricsCalculator` 作为**唯一口径来源**;`AnalyticsService` 全面委托,只保留组装与前端字段名兼容 | + +### 3.5 权限授予脱节(Gate ⑩:全员 403) + +| 缺陷 | 证据 | 修复 | +|---|---|---| +| V001 第 149 行定义了专看统计的角色 `data_viewer`(描述:"仅查看统计数据"),但**全仓迁移从未给它授予任何权限** —— 是个**空壳角色** | 角色存在 ≠ 角色可用 | `V043` 授予 `analytics.view` | +| `platform_admin` 的权限扩展在 V015 以 `id >= '20000000-...'`(V1.1 新增段)为条件,而 `analytics.view` 的 id 是 `10000000-...060`(**V1.0 老权限段**)→ 被漏掉 | 权限段划分造成的静默遗漏 | `V043` 一并授予 | +| **净效果**:整个 Analytics 模块(15 端点)除 `super_admin` 外**全员 403**,包括专门为它设计的 `data_viewer`。前端"数据统计"菜单对谁都是点了报错 | — | 见下 | + +> 两个既有守卫(`AnalyticsController` 的 `@PreAuthorize`、`OperatorEndpointPermissionCoverageTest`)**都是绿的** —— +> 前者检查"端点有没有声明权限",后者检查"端点有没有权限注解",**都不检查"这个权限有没有授予任何角色"**。 +> 这是"守卫覆盖不到的组合缺陷",故新增 `AnalyticsPermissionGrantTest` 补上「权限 → 角色」这一环。 + +**刻意不授予 `organization_admin`**:平台级统计接口所依赖的表(`users` / `matches` / `messages`)**没有 `organization_id` 维度**, +无法把机构管理员的数据范围限制在"本机构"。在没有范围限制的情况下授予全局统计权限 = 越权可见全平台数据。 +**宁可返回 403,也不给无范围的全量数据**;待统计口径支持组织维度后再评估。 + +### 3.6 测试假绿(本轮替换) + +| 缺陷 | 说明 | +|---|---| +| `AnalyticsServiceTest` | 由 **5 个 `assertTrue(true)`** 组成 —— **没有任何断言**,却让"Phase 20 已完成"看起来成立。测试通过不代表功能存在,只代表测试什么都没检查 | 已**删除**,替换为 `AnalyticsMetricsPhase20IntegrationTest`(21 用例,全部构造真实数据行、在 H2 上真正执行 SQL) | + +### 3.7 前端落点 + +| 缺陷 | 修复 | +|---|---| +| `AnalyticsView.vue` 未接 `/v11-dashboard` 综合端点;公益服务数据区、活动互选vs成功、按来源分离 Match、provisional / 未埋点标注**全部缺失** | 补接端点 + 新增上述区块 + 对 `PROVISIONAL` / `NOT_INSTRUMENTED` 显式标注(避免运营把 provisional 当正式口径引用) | + +### 3.8 其它修正 + +| 项 | 说明 | +|---|---| +| `CounselingServiceRecord` 注释过时 | 注释写 `counseling / premarital`,实际服务类型是 `relationship_guidance / emotional_support / premarital_guidance / anti_fraud_guidance` | +| `MigrationValidationTest` 基线 | `LATEST_MIGRATION` 42 → 43(新增 V043) | + +--- + +## 四、跨阶段发现(复验 Phase 20 时发现,缺陷本体属账号生命周期模块) + +按纪律「发现真实缺陷即修」,以下问题在核对**生产约束 vs 测试库**时暴露。其模块归属为 Phase 21,故单独列出。 + +### X1(**Critical**):账号注销写入了越界状态 → 生产必然 500 + +`AccountLifecycleService.invalidateLocalEligibility` 写入 +`verification_status = 'failed'`(`Constants.VERIFICATION_STATUS_FAILED`)。 + +但生产 `V017 chk_elig_verify_status` 只允许 +`declared / needs_evidence / pending_review / verified / rejected` —— **没有 `failed`**, +且**没有任何后续迁移放宽它**(已全量核对迁移目录)。 + +`failed` 实际属于 `user_identities` 的状态域 —— 生产 `V002` 的 CHECK 里才有它, +且 `IdentityVerificationService` 也正是这样用的。这是**跨状态域常量误用**。 + +**后果**:真实 PostgreSQL 上 CHECK 违约 → `closeAccount` **整个事务回滚** → +**账号注销 100% 不可用**(报 500)。而测试库此前**没有这两条 CHECK**,所以该必崩缺陷在测试期完全不可见。 + +> **负向验证实证**:把该行改回 `failed` 后,守卫报出的异常正是 **`DataIntegrityViolation`** —— +> 即数据库层真实拒绝了这次写入,直接证实了生产的失败模式。 + +**修复**:改用 `VERIFICATION_STATUS_REJECTED`(白名单内,语义为"资格失效")。 +**未**选择"新增迁移把 `failed` 加进白名单" —— 那是把一个跨域误用的值固化进契约, +与本项目 Phase 19 D16「收敛权限膨胀」的处理原则一致:优先收敛,不扩契约。 + +### X2:`checkCanWithdraw` 的资格拦截是**死代码** + +该方法用 `'submitted'` / `'pending'` 判断"本地资格审核进行中", +但这两个值在 `user_local_eligibilities` 里**永不可能出现**(同 X1 的状态域混用) +→ 拦截条件恒为 `false`,**从未生效过**:审核中的用户可以直接注销。 + +**修复**:改用 `needs_evidence` / `pending_review`(抽为 `LOCAL_ELIGIBILITY_IN_REVIEW` 常量并注明取值域来源)。 + +### X3:测试库约束漂移 + +测试库缺 `chk_elig_type` / `chk_elig_verify_status`。 +补齐后**立即暴露出**一处既有夹具违规:`ActivityPhase14Test` 使用 +`eligibility_type = "household"`(正确值是 `household_registration`)—— 已修正。 + +### X4 / X5:Phase 21 遗留(本轮**未**修,仅记录) + +| 项 | 说明 | +|---|---| +| X4 测试假绿 | `AccountLifecycleServiceTest` 由 **7 个 `assertTrue(true)`** 组成(仅有的真实断言是几个常量字符串)。零真实覆盖,这正是 X1/X2 长期隐形的原因之一 | +| X5 未实现 | `notifyActiveMatches` / `notifyMatchmaker` / `cancelPendingActivityRegistrations` 均为 TODO 桩(只写日志,无任何查询);`checkCanWithdraw` 的 `activeMatchmakerServices` 硬编码 `0`(注释:"需要 MatchmakerServiceMapper")→ 该阻塞项永不生效 | + +> X4/X5 属 Phase 21 交付范围(需产品确认通知语义与红娘服务口径),不在本轮静默扩权修复,留待 Phase 21 处理。 + +--- + +## 五、本轮新增能力(此前完全不存在) + +### 5.1 指标字典代码化 + +| 文件 | 作用 | +|---|---| +| `MetricStatus` | `OFFICIAL` / `PROVISIONAL` / `NOT_INSTRUMENTED` —— **把"口径是否可用"本身建模** | +| `MetricDefinition` | 12 字段 record:code / name / definition / numerator / denominator / dedupeKey / timeField / timezone / version / owner / status / sourceTable | +| `MetricCatalog` | 36 个指标注册表;`of()` 未登记即抛异常;`provisional()` / `notInstrumented()` 检索 | +| `docs/ANALYTICS_METRICS.md` | 指标字典文档,与代码逐条对应;含软删强制约定、变更流程、已知待办 | + +> **为什么状态本身要建模**:`0` 与「不可用」对决策的含义完全相反。 +> 「Banner 点击 0」读起来是"投了没人点",真相是"没有任何地方记录点击"。 + +### 5.2 统一口径计算层 + +`AnalyticsMetricsCalculator` —— 消除三套独立实现,所有查询显式过滤软删, +方法上的 javadoc 标注其对应 `MetricCatalog` 指标编码。 + +### 5.3 新增指标与方法 + +| 层 | 新增 | +|---|---| +| `UserIdentityMapper` | `countVerifiedUsers()` | +| `MessageMapper` | `countConversationsWithRealMessages()` | +| `MatchMapper` | `countAllMatches` / `countBySource` / `countBySourceAndStatus` / `countByStatus` | +| `RecommendationRecordMapper` | `countShown` / `countShownBySource` / `countBySourceAndAction` / `countByAction` | +| `ActivityRegistrationMapper` | `countAllRegistrations` / `countRegistrationsByStatus` / `countCheckedIn` / `countRegistrationsBySource` / `countDistinctRegisteredUsers` | +| `ActivityMutualConsentMapper` | `countBothConfirmed` / `countOneSidedAccepted` | +| `V043` | `analytics.view` → `data_viewer` + `platform_admin` | + +--- + +## 六、守卫与测试保真度(本轮新增) + +| 守卫 | 用例数 | 作用 | +|---|---|---| +| `MetricCatalogGuardTest` | 11 | 代码 ↔ 文档**双向**一致;12 项元数据完整;状态诚实性(provisional/not-instrumented 必须真的被标注);去重键差异;未登记编码抛异常 | +| `AnalyticsMetricsPhase20IntegrationTest` | 21 | Gate 全部 8 类 + 软删 5 例 + 入参校验 + 可用性诚实性。**真实 DB、真实 SQL**,非 mock | +| `AnalyticsSoftDeleteGuardTest` | 4 | **结构性**约束:统计包内每处 `*Mapper.selectCount/selectList` 实参必须含 `isNull(..)`。含"防假绿"下界(扫不到足够调用点即失败) | +| `AnalyticsPermissionGrantTest` | 6 | 「权限 → 角色」授权链;低权限角色清单;`organization_admin` 刻意排除;授予的权限码必须真实存在;`super_admin` 兜底路径 | +| `LocalEligibilityStatusVocabularyGuardTest` | 9 | X1/X2 守卫:生产 DDL 白名单解析、测试库**一致性**(防漂移)、越界值被 DB 真实拒绝、注销后状态落在白名单内、审核中确实能拦截注销 | + +`AnalyticsServiceTest`(5×`assertTrue(true)`)在本轮被**清除**; +`AnalyticsSoftDeleteGuardTest` 的"防假绿下界"与 `MetricCatalogGuardTest` 的解析自检, +共同保证守卫**不会因为解析失效而静默变绿**。 + +--- + +## 七、负向验证(证明守卫是承重的,而非装饰) + +一次性注入缺陷(均以 `NEG-VAL` 标记),跑定向回归,确认**精准变红且无任何无关失败**,再还原。 + +### 第 1 轮 + +| 注入缺陷 | 精准变红的用例 | 断言信息 | +|---|---|---| +| 把实名口径改回 `user_profiles.profile_status <> 'rejected'` | `realNameIsNotProfileStatusProxy` | `expected: <0> but was: <5>` | +| 移除 `registeredUsers()` 的 `isNull(DeletedAt)` | `softDeletedUserIsExcludedFromRegisteredCount` | `expected: <0> but was: <1>` | +| 从 V043 移除 `data_viewer` 授权 | `dataViewerRoleIsActuallyUsable` | `当前实际授予: []` | + +> `MetricCatalogGuardTest` 11/11 **保持绿** —— 口径守卫在 DB 层,目录守卫正确地**不**误报。 + +### 第 2 轮(针对本轮新增守卫) + +| 注入缺陷 | 精准变红的用例 | 断言信息 | +|---|---|---| +| `invalidateLocalEligibility` 改回 `failed` | `closeAccountLeavesEligibilityStatusWithinWhitelist` | **`DataIntegrityViolation`** —— DB 真实拒绝 | +| `checkCanWithdraw` 改回 `submitted`/`pending` | `pendingEligibilityReviewBlocksWithdrawal` | `expected: but was: ` | +| 移除 `programUsers` 的 `isNull(DeletedAt)` | `everyAnalyticsMapperQueryFiltersSoftDeletes`(结构性) | 列出未过滤的调用点 | +| 同上 | `softDeletedServiceRowsAreExcludedFromServiceMetrics`(行为) | `expected: <1> but was: <2>` | + +> 同一注入被**两层守卫**同时捕获(源码结构 + 运行时行为),说明结构性守卫对新代码同样生效 —— +> 这正是它存在的意义:数字断言只能覆盖"被构造出来的固定数据集"。 + +**还原后**:`grep -rc "NEG-VAL" backend/src` = **0**;`md5sum` 与注入前**逐字节一致**。 + +--- + +## 八、回归证据 + +``` +后端全量:Tests run: 648, Failures: 0, Errors: 0, Skipped: 3 / BUILD SUCCESS (约 6.5 分) + (复验起点 602 → 本轮 648,净 +46;其中新增守卫 9+4+6+11 = 30,集成用例 21) +前端 admin-web:vue-tsc --noEmit EXIT=0 +负向验证:2 轮、7 处注入 → 7 个用例精准变红;还原后 0 残留 +``` + +--- + +## 九、明确记录的设计决策(非缺陷,但需知情) + +1. **`organization_admin` 不授予全局统计权限**(见 §3.5)。要在机构维度开放统计, + 必须先给 `users` / `matches` / `messages` 等表建立组织维度,否则任何授予都是越权。 +2. **`match.effective` 标 `PROVISIONAL`**:「有效」是否要求线下见面 / 持续 N 天,业务方未裁决。 + 当前是保守近似(真人会话数与 Match 数取较小值)。**禁止**作为正式成功率或写入对外宣传材料。 +3. **Banner 曝光/点击保持 `NOT_INSTRUMENTED`**:不返回 `0`。要启用需先建事件表 + 上报端点 + 小程序埋点。 +4. **软删过滤是每个指标的隐含前置条件**:`BaseEntity.deletedAt` 无 `@TableLogic`, + 全局也无逻辑删除配置。本项目**不**建议为此加全局配置(会改变所有既有查询语义、风险外溢), + 而是用结构性守卫强制显式过滤。 +5. **功能开关关闭时的注册口径**:`user.registered.total` 统计**全部**未软删用户, + 不按 `account_status` 过滤 —— 「注销用户」不是「从未注册用户」,两者对"累计注册"含义不同。 + +--- + +## 十、遗留与下一阶段 + +| 项 | 状态 | +|---|---| +| X4 `AccountLifecycleServiceTest` 的 7 个假绿断言 | ⏳ Phase 21(本轮已就资格失效路径补真实覆盖) | +| X5 通知/活动报名取消的 TODO 桩、`activeMatchmakerServices` 硬编码 0 | ⏳ Phase 21,需产品确认语义 | +| Banner 曝光/点击埋点闭环 | ⏳ 需新建事件表 + 上报端点 | +| `match.effective` 口径裁决 | ⏳ 需业务方确认 | +| `service.user.unique` 等跨域去重改为 DB 侧聚合 | ⏳ 数据量达十万级时 | +| 组织 / 站点维度统计拆分 | ⏳ 依赖组织维度建模 | +| 下一阶段 | Phase 21(等用户确认) | + +--- + +## 附:本轮改动文件 + +**后端(新增)** +`analytics/metric/MetricStatus.java`、`analytics/metric/MetricDefinition.java`、 +`analytics/metric/MetricCatalog.java`、`analytics/metric/AnalyticsMetricsCalculator.java`、 +`db/migration/V043__grant_analytics_view_to_data_roles.sql` + +**后端(修改)** +`analytics/AnalyticsService.java`(重写为委托模式)、 +`account/service/AccountLifecycleService.java`(X1/X2)、 +`verification/mapper/UserIdentityMapper.java`、`chat/mapper/MessageMapper.java`、 +`match/mapper/MatchMapper.java`、`match/mapper/RecommendationRecordMapper.java`、 +`activity/mapper/ActivityRegistrationMapper.java`、`activity_mutual/mapper/ActivityMutualConsentMapper.java`、 +`counseling/entity/CounselingServiceRecord.java` + +**前端** +`admin-web/src/modules/analytics/AnalyticsView.vue` + +**测试** +`analytics/AnalyticsMetricsPhase20IntegrationTest.java`(新增,21)、 +`analytics/MetricCatalogGuardTest.java`(新增,11)、 +`analytics/AnalyticsSoftDeleteGuardTest.java`(新增,4)、 +`security/AnalyticsPermissionGrantTest.java`(新增,6)、 +`account/LocalEligibilityStatusVocabularyGuardTest.java`(新增,9)、 +`activity/ActivityPhase14Test.java`、`deployment/MigrationValidationTest.java`、 +`src/test/resources/schema-h2.sql`、~~`analytics/AnalyticsServiceTest.java`~~(删除) + +**文档** +`docs/ANALYTICS_METRICS.md`(新增) diff --git a/docs/STATUS.md b/docs/STATUS.md index c264041..6c314d8 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,8 +1,8 @@ # 项目状态跟踪 — STATUS.md -> **2026-09-17 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 19 COMPLETE / UI REDESIGN ACCEPTED BY USER / GIT CLEAN`。** +> **2026-09-17 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 20 COMPLETE / UI REDESIGN ACCEPTED BY USER / GIT CLEAN`。** > -> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)、Phase 15(活动报名、审核与签到)、Phase 16(活动互选与双方开启沟通确认)、Phase 17(举报与风控)、Phase 18(申诉)、Phase 19(统一运营后台 + 用户端公益服务页面)复验已关闭**,十三轮共修复 110+ 处真实缺陷(含 3 处隐藏编译缺陷),并各自补齐测试保真度与守卫。**Phase 20 起需用户确认后启动。** +> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)、Phase 15(活动报名、审核与签到)、Phase 16(活动互选与双方开启沟通确认)、Phase 17(举报与风控)、Phase 18(申诉)、Phase 19(统一运营后台 + 用户端公益服务页面)、Phase 20(Analytics 数据统计与管理驾驶舱)复验已关闭**,十四轮共修复 130+ 处真实缺陷(含 3 处隐藏编译缺陷),并各自补齐测试保真度与守卫。**Phase 21 起需用户确认后启动。** 最后更新: 2026-09-17 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点) @@ -21,8 +21,8 @@ | 阶段 2 管理端 Vue 适配 | 已完成 | 18 modules + 22 routes + vue-tsc 全绿 + vite build 11.68s | | 阶段 3 CapabilityService 独立化 | 已完成 | New `capability` 模块:CapabilityService(getCapabilities + 公共判断方法)+ CapabilityController(GET /api/v1/capabilities);HomeApplicationService 完全委托 | | 阶段 4 双端总验收 | **已完成**(2026-09-14) | 三端构建全绿 + 13/13 契约全绿 + 源码健康检查通过;验收报告见 docs/superpowers/plans/2026-09-14-phase4-acceptance-report.md | -| Flyway 迁移 | V001~V039 | V028~V038 已纳入 STATUS;V037 为 Phase 12 新增(实体列与生产迁移对齐,23 表 50 列);V038 为 Phase 13 新增(`matchmaker_recommendations.status` + `matchmaker_services.service_type` 的 CHECK 白名单扩展);V039 为 Phase 13A 新增(counseling 幂等键 + 三个 CHECK 白名单显式固化) | -| Backend 全量回归 | **已清偿**(2026-09-17) | 离线容器(`danyang-match-backend-test`)`mvn -o test` 实测:**494 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(约 4.4 分);含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest`、`DiscoverBlockInvisibilityIntegrationTest`、`ChatPhase09IntegrationTest`、`ChatWebSocketHandlerTest`、`PhotoUploadValidationTest`、`NotificationPhase11IntegrationTest`、`NotificationTemplateTypeGuardTest`、`NotificationPrivacyCopyTest`、`EntityMigrationParityTest`、`RelationshipProgressPhase12Test`、`HomePremaritalGuidancePhase12Test`、`MatchmakerReferralPhase13Test`、`MatchmakerServiceTest`、`ReportAndRiskPhase17IntegrationTest`、`AppealPhase18IntegrationTest`(Phase 18 新增 15 例:申诉真实落库+审计、仅 ban/suspend 可申诉、幂等、**Gate A 通过只解除目标处置**、**Gate B 拒绝维持原处置**、原子认领唯一赢家、二次处理被拒、escalate 后可再决策、appealType 白名单/一致性/缺省推导、证据字段映射、软删过滤、Feature Flag 关闭拦截) | +| Flyway 迁移 | V001~V043 | V028~V043 已纳入 STATUS;V037 为 Phase 12 新增(实体列与生产迁移对齐,23 表 50 列);V038 为 Phase 13 新增(`matchmaker_recommendations.status` + `matchmaker_services.service_type` 的 CHECK 白名单扩展);V039 为 Phase 13A 新增(counseling 幂等键 + 三个 CHECK 白名单显式固化);V042 为 Phase 19 新增(`service_location_announcements` 服务站临时公告表);V043 为 Phase 20 新增(授予 `data_viewer` / `platform_admin` 的 `analytics.view`) | +| Backend 全量回归 | **已清偿**(2026-09-17,Phase 20) | 离线容器(`danyang-match-backend-test`)`mvn -o test` 实测:**648 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(约 6.5 分);含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest`、`DiscoverBlockInvisibilityIntegrationTest`、`ChatPhase09IntegrationTest`、`ChatWebSocketHandlerTest`、`PhotoUploadValidationTest`、`NotificationPhase11IntegrationTest`、`NotificationTemplateTypeGuardTest`、`NotificationPrivacyCopyTest`、`EntityMigrationParityTest`、`RelationshipProgressPhase12Test`、`HomePremaritalGuidancePhase12Test`、`MatchmakerReferralPhase13Test`、`MatchmakerServiceTest`、`ReportAndRiskPhase17IntegrationTest`、`AppealPhase18IntegrationTest`(Phase 18 新增 15 例:申诉真实落库+审计、仅 ban/suspend 可申诉、幂等、**Gate A 通过只解除目标处置**、**Gate B 拒绝维持原处置**、原子认领唯一赢家、二次处理被拒、escalate 后可再决策、appealType 白名单/一致性/缺省推导、证据字段映射、软删过滤、Feature Flag 关闭拦截)、`MetricCatalogGuardTest`(11)、`AnalyticsMetricsPhase20IntegrationTest`(21)、`AnalyticsSoftDeleteGuardTest`(4)、`AnalyticsPermissionGrantTest`(6)、`LocalEligibilityStatusVocabularyGuardTest`(9) | | 路由/字段契约收敛 | **已完成**(2026-09-15) | 字段:会话 `target*` + `sentAt` + `relationStatus`(commit `687a156`);路径:采用 2026-09-14 canonical(`/likes` `/passes` `/blocks` `/matches`,缘分保留 `/relations/fate/*`),旧路径与 `success` 桩实现已移除;裁决由 `CanonicalRouteContractTest` 锁定 | | `/me/*` 路径族 | **已完成**(2026-09-16) | 小程序按 REST 规范(§16 / §22-§40 / §80 / §83-§84)调用 `/me/*`,后端此前**无 `/me` 基路径**致 17 个调用 404。新增 `MeController` 委托既有 service 暴露规范路径,旧路径全部保留,小程序三条历史写法(`/me/privacy-settings`、`/me/photos/{id}/main`、`/me/appeals`)一并兼容;由 `MeRouteContractTest` 锁定 | | Admin Web | **验证通过** | 2026-09-14 阶段 4:vue-tsc ✅、vite build ✅ 11.68s;18 modules 齐全;壳层+工作台通过参考图 QA | @@ -518,13 +518,41 @@ MinIO: localhost:9003 (凭证由本地环境变量提供) 新增守卫:`ServiceLocationAnnouncementPhase19IntegrationTest`(24) / `ServiceLocationControllerPermissionContractTest`(5) / `ServiceLocationAnnouncementConstraintParityTest`(5)。负向验证 3 处注入 → 4 个用例精准变红并还原(`NEG-VAL` 残留 0)。 -### Phase 20 — Analytics数据统计扩展 ✅ 已完成 +### Phase 20 — Analytics 数据统计与管理驾驶舱 ✅ 已完成(2026-09-17 复验关闭) + +> 复验报告:`docs/Phase-20-V11-Recovery-Completion-Report-2026-09-17.md` +> 指标字典:`docs/ANALYTICS_METRICS.md`(与 `MetricCatalog` 逐条对应) +> 后端全量回归:`Tests run: 648, Failures: 0, Errors: 0, Skipped: 3 / BUILD SUCCESS` +> admin-web:`vue-tsc --noEmit` EXIT=0;负向验证 2 轮 7 处注入 → 7 用例精准变红,还原后 0 残留 + | 任务 | 状态 | |------|------| -| P20-T01~T11 全部11项统计指标 | ✅ | -| GET /operator/analytics/v11-dashboard 综合接口 | ✅ | +| P20-T01 指标字典(`MetricCatalog` 36 指标 × 12 字段 + 文档 + 守卫) | ✅ | +| P20-T02~T09 User / Review / Recommendation / Match / Chat Started / Matchmaker / Activity / Risk Metrics | ✅ | +| P20-T10 Daily Summary(补 days 1~365 边界校验) | ✅ | +| P20-T11/T12 Platform Dashboard(统一口径层 `AnalyticsMetricsCalculator`) | ✅ | +| P20-T13 Organization Dashboard | ✅(受 `analytics.view` + DataScope;`organization_admin` 待组织维度建模) | +| P20-T14 Analytics 权限(V043 打通 `data_viewer` / `platform_admin`) | ✅ | +| `GET /operator/analytics/v11-dashboard` 综合接口 | ✅ | -**Phase 20(Analytics数据统计扩展)— 已完成** ✅ +**本轮复验修复的真实缺陷(高价值项)**: + +- **口径造假**:实名人数用 `profile_status != 'rejected'` 冒充(把"资料还在填"算成已实名); + Chat Started 直接数会话行数(系统通知计入);Match 不分来源(10 对互选被当 10 对成功引用); + Banner 曝光/点击**硬编码 0**(被读成"投了没人点",实为无埋点)。 +- **死代码**:活动漏斗**全部硬编码 0**(不是没数据,是根本没有查询)+ 一行把"用户数"当"活动数"的死代码。 +- **系统性软删缺陷(D12)**:`BaseEntity.deletedAt` 无 `@TableLogic`、全局无逻辑删除配置 + → `selectCount` 把**软删数据全部计入统计且无任何报错**。修复 45 处统计调用(2 轮复验,首轮漏扫 5 处次级路径)。 +- **权限授予脱节(Gate ⑩)**:`data_viewer` 角色被定义却**从未授权**(空壳角色); + `platform_admin` 因 `id >= '20000000-…'` 条件漏掉 V1.0 老权限段 → **整个模块除 super_admin 外全员 403**。 +- **假绿测试**:`AnalyticsServiceTest` = 5×`assertTrue(true)`(零断言),已删除并替换为 21 用例真实 DB 集成测试。 + +**跨阶段发现**(缺陷本体属 Phase 21,本轮已修 X1~X3): +`AccountLifecycleService` 注销时把 `user_identities` 状态域的 `'failed'` 写入本地资格表 +→ 违反生产 `V017 chk_elig_verify_status` → **真实 PG 上 closeAccount 事务回滚、账号注销 100% 不可用**; +`checkCanWithdraw` 的资格拦截用 `submitted`/`pending`(该表永不可能出现的值)→ 死代码。 +测试库补齐 `chk_elig_type` / `chk_elig_verify_status` 后立即暴露 `ActivityPhase14Test` 夹具违规。 +新增 `LocalEligibilityStatusVocabularyGuardTest`(9)。 ### Phase 21 — 账户生命周期增量 ✅ 已完成 | 任务 | 状态 |