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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user