fix(analytics): close Phase 20 KEEP-VERIFY — 20 defects (fake metric definitions, hardcoded zeros, systemic soft-delete leak, module-wide 403, fake-green tests); add MetricCatalog + unified calculator + V043 grants + 51 guard tests; full regression 648 green

口径造假:
- 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
This commit is contained in:
WorkBuddy
2026-09-17 23:25:31 +08:00
parent ff22c0d06a
commit 22c778b111
28 changed files with 3652 additions and 359 deletions
@@ -32,6 +32,10 @@
<el-descriptions-item label="审核通过">{{ funnelStats.profileApproved }}</el-descriptions-item>
<el-descriptions-item label="7日活跃">{{ funnelStats.activeUsers }}</el-descriptions-item>
</el-descriptions>
<div class="metric-note">
说明:「实名用户」指身份证核验通过的人数(user_identities.verification_status = verified),
与「审核通过」(资料审核)是两个不同口径,两者互不包含。
</div>
</el-card>
<!-- Match漏斗 -->
@@ -46,6 +50,39 @@
</div>
</el-col>
</el-row>
<!-- 按来源分离 -->
<el-divider content-position="left">Match 按来源分离</el-divider>
<el-row :gutter="16">
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ matchFunnelStats.matchesBySource?.userLike ?? 0 }}</div>
<div class="mini-label">用户互相 Like</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ matchFunnelStats.matchesBySource?.activityMutual ?? 0 }}</div>
<div class="mini-label">活动互选</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ matchFunnelStats.matchesBySource?.matchmaker ?? 0 }}</div>
<div class="mini-label">红娘促成</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ matchFunnelStats.matchmakerRecommendations ?? 0 }}</div>
<div class="mini-label">红娘发出推荐</div>
</div>
</el-col>
</el-row>
<div class="metric-note">
说明:「开启聊天」只统计存在真人消息的会话(系统通知不计);Match 按来源分离统计,
不同来源的量不可互相替代。2 个单向 Like 才形成 1 个 Match。
</div>
</el-card>
<!-- 安全统计 -->
@@ -58,6 +95,109 @@
</el-col>
</el-row>
<!-- 公益服务数据 -->
<el-card shadow="never" style="margin-bottom:16px">
<template #header>
<span>公益服务数据</span>
<el-tag v-if="v11 && v11.effectiveConnections?.effectiveMatchesProvisional"
type="warning" size="small" style="margin-left:8px">含未确认口径</el-tag>
</template>
<el-row :gutter="16" style="margin-bottom:12px">
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ v11?.uniqueServiceUsers?.totalUniqueServiceUsers ?? 0 }}</div>
<div class="mini-label">服务用户数(去重)</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ v11?.uniqueServiceUsers?.totalServiceVisits ?? 0 }}</div>
<div class="mini-label">公益服务人次(不去重)</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ v11?.serviceAppointments?.total ?? 0 }}</div>
<div class="mini-label">服务预约(不含取消)</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ v11?.counseling?.total ?? 0 }}</div>
<div class="mini-label">婚恋咨询受理</div>
</div>
</el-col>
</el-row>
<!-- 活动漏斗:互选 vs 成功 -->
<el-divider content-position="left">活动互选与成功(两个不同指标)</el-divider>
<el-row :gutter="16" style="margin-bottom:12px">
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ v11?.activityFunnel?.mutualBothConfirmed ?? 0 }}</div>
<div class="mini-label">互选成立对数</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ v11?.activityFunnel?.mutualMatches ?? 0 }}</div>
<div class="mini-label">互选后建立 Match</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ v11?.activityFunnel?.mutualSuccessful ?? 0 }}</div>
<div class="mini-label">互选后成功牵手</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ v11?.activityFunnel?.checkedInCount ?? 0 }}</div>
<div class="mini-label">活动实际签到</div>
</div>
</el-col>
</el-row>
<!-- 口径警示 -->
<el-alert
v-if="v11?.effectiveConnections?.effectiveMatchesProvisional"
type="warning" :closable="false" show-icon style="margin-bottom:12px"
title="「有效交友匹配」口径尚未确认,仅供内部参考,禁止对外宣传">
<div>{{ v11?.effectiveConnections?.provisionalNote }}</div>
</el-alert>
<el-alert
v-if="v11?.banners && !v11.banners.impressionsAvailable"
type="info" :closable="false" show-icon style="margin-bottom:12px"
title="Banner 曝光量 / 点击量当前不可用(未埋点)">
<div>{{ v11?.banners?.unavailableReason }}</div>
</el-alert>
<el-descriptions :column="4" border size="small">
<el-descriptions-item label="Banner 总数">{{ v11?.banners?.totalBanners ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="生效中 Banner">{{ v11?.banners?.activeBanners ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="曝光量">
<span v-if="v11?.banners?.impressionsAvailable">{{ v11.banners.impressions }}</span>
<span v-else class="unavailable">未埋点</span>
</el-descriptions-item>
<el-descriptions-item label="点击量">
<span v-if="v11?.banners?.clicksAvailable">{{ v11.banners.clicks }}</span>
<span v-else class="unavailable">未埋点</span>
</el-descriptions-item>
<el-descriptions-item label="已发布内容">{{ v11?.content?.totalPublishedArticles ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="内容阅读量">{{ v11?.content?.totalViews ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="专项项目数">{{ v11?.servicePrograms?.totalPrograms ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="项目在册成员">{{ v11?.servicePrograms?.totalMembers ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="本地资格已核验">{{ v11?.localEligibility?.verified ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="婚前辅导受理">{{ v11?.premaritalGuidance?.totalRequests ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="安全处理中举报">{{ v11?.counseling?.pending ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="有效交友匹配(未确认口径)">
<el-tag type="warning" size="small">{{ v11?.effectiveConnections?.successConnections ?? 0 }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 每日汇总表 -->
<el-card shadow="never" v-if="dailySummary.length > 0">
<template #header><span>每日汇总(最近{{ dailySummary.length }}天)</span></template>
@@ -84,13 +224,14 @@ const dateRange = ref<[string, string]>([
const funnelStats = ref<any>({})
const matchFunnelStats = ref<any>({})
const safetyStats = ref<any>({})
const v11 = ref<any>({})
const dailySummary = ref<any[]>([])
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; }
</style>