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>
|
||||
|
||||
+25
-3
@@ -47,6 +47,18 @@ public class AccountLifecycleService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AccountLifecycleService.class);
|
||||
|
||||
/**
|
||||
* 本地资格「审核进行中」的状态集合。
|
||||
*
|
||||
* <p>取值域严格来自生产 {@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<String> 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<LocalEligibility> 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<LocalEligibility> 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);
|
||||
}
|
||||
|
||||
+26
@@ -176,4 +176,30 @@ public interface ActivityRegistrationMapper extends BaseMapper<ActivityRegistrat
|
||||
int rejectIfPending(@Param("id") String id,
|
||||
@Param("operatorId") String operatorId,
|
||||
@Param("reason") String reason);
|
||||
|
||||
// ==================== Phase 20:活动漏斗口径统计 ====================
|
||||
// 此前 AnalyticsService.getActivityFunnel()/activityFunnelStats() 把全部指标
|
||||
// 硬编码为 0(并且留着一行 `userMapper.selectCount(...)` 当作"活动数"的死代码),
|
||||
// 后台"活动漏斗"永远显示 0 —— 不是没数据,是根本没查。
|
||||
|
||||
/** 报名总记录数(去重键 = id;含 pending/rejected/cancelled)。 */
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations WHERE deleted_at IS NULL")
|
||||
long countAllRegistrations();
|
||||
|
||||
/** 按状态统计报名数(pending / approved / rejected / cancelled)。 */
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations WHERE status = #{status} AND deleted_at IS NULL")
|
||||
long countRegistrationsByStatus(@Param("status") String status);
|
||||
|
||||
/** 已签到人数(去重键 = id)。 */
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations "
|
||||
+ "WHERE checkin_status = 'checked_in' AND deleted_at IS NULL")
|
||||
long countCheckedIn();
|
||||
|
||||
/** 按报名来源统计(chk_reg_source: user_self / matchmaker_referral / activity_followup / service_station / operator_assisted)。 */
|
||||
@Select("SELECT COUNT(*) FROM activity_registrations WHERE source = #{source} AND deleted_at IS NULL")
|
||||
long countRegistrationsBySource(@Param("source") String source);
|
||||
|
||||
/** 去重报名人数(同一用户报多个活动只算一人)。 */
|
||||
@Select("SELECT COUNT(DISTINCT user_id) FROM activity_registrations WHERE deleted_at IS NULL")
|
||||
long countDistinctRegisteredUsers();
|
||||
}
|
||||
|
||||
+22
@@ -85,4 +85,26 @@ public interface ActivityMutualConsentMapper extends BaseMapper<ActivityMutualCo
|
||||
@Update("UPDATE activity_mutual_connection_consents SET match_id = #{matchId}, updated_at = NOW() " +
|
||||
"WHERE id = #{id} AND deleted_at IS NULL")
|
||||
int bindMatch(@Param("id") String id, @Param("matchId") String matchId);
|
||||
|
||||
// ==================== Phase 20:互选口径统计 ====================
|
||||
|
||||
/**
|
||||
* **双向已确认**的互选对数(Gate「活动10对互选 ≠ 10 对 Success」的分母侧)。
|
||||
*
|
||||
* <p>「互选」的严格定义是两侧均为 {@code accepted},而不是「有人点了同意」。
|
||||
* Match 的 {@code activity_mutual} 来源行只有在双确认后才创建,
|
||||
* 但互选**对数**与后续是否走到 {@code status='successful'} 是两件事:
|
||||
* 前者是活动互选环节的产出,后者是关系推进的产出,二者不可互相替代。
|
||||
*
|
||||
* <p>去重键:{@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();
|
||||
}
|
||||
|
||||
+347
-294
@@ -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 服务 —— 对外提供各统计视图的数据装配。
|
||||
*
|
||||
* <p><b>Phase 20 重构说明</b>:本类不再**自己**计算任何指标数字,
|
||||
* 一律委托 {@link AnalyticsMetricsCalculator}(唯一口径来源)。
|
||||
*
|
||||
* <p>修复前的问题:Dashboard / 用户漏斗 / V11 专项仪表盘各自实现了一遍同名指标,
|
||||
* 且互不相同:
|
||||
* <ul>
|
||||
* <li>"实名人数"被算成 {@code profile_status != 'rejected'}(资料没被退回),
|
||||
* 把「资料还在填」和「被退回待改」都算成了已实名;</li>
|
||||
* <li>"开启聊天数"直接数会话行数,从不看消息类型,一条系统通知即计入;</li>
|
||||
* <li>"Match 数"混用全部来源,导致活动互选、红娘促成、用户自主匹配
|
||||
* 被当成同一个数字,Gate 要求的「10 对互选 ≠ 10 对 Success」无法体现;</li>
|
||||
* <li>活动漏斗全部硬编码为 0,后台永远显示 0。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>字段名兼容性</b>: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 ====================
|
||||
|
||||
/**
|
||||
* 综合看板。
|
||||
*
|
||||
* <p>与 {@link #getUserFunnel} / {@link #getMatchFunnel} 共用同一批口径,
|
||||
* 确保 Gate「Dashboard 和专项页面相同指标必须一致」成立。
|
||||
*/
|
||||
public Map<String, Object> getDashboardStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
stats.putAll(userBasicStats());
|
||||
stats.putAll(matchFunnelStats());
|
||||
stats.putAll(safetyStats());
|
||||
Map<String, Object> 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<String, Object> getUserFunnel(LocalDate since, LocalDate until) {
|
||||
if (since == null) since = LocalDate.now().minusMonths(1);
|
||||
if (until == null) until = LocalDate.now();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
// 各阶段总量
|
||||
long totalRegistered = userMapper.selectCount(null);
|
||||
long verifiedCount = userProfileMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserProfile>()
|
||||
.ne(UserProfile::getProfileStatus, "rejected"));
|
||||
long profileApproved = userProfileMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserProfile>()
|
||||
.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<User>()
|
||||
.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<Map<String, Object>> dailyTrend = buildDailyNewUsers(since, until);
|
||||
result.put("dailyTrend", dailyTrend);
|
||||
result.put("dailyTrend", buildDailyNewUsers(since, until));
|
||||
|
||||
// 男女比例
|
||||
// Phase 20 D12(补全):性别/年龄分布此前漏了软删过滤,
|
||||
// 会让"分布"与已完成软删过滤的 totalRegistered 对不上账。
|
||||
Map<String, Long> genderRatio = userProfileMapper.selectList(
|
||||
new LambdaQueryWrapper<UserProfile>().isNotNull(UserProfile::getGender))
|
||||
new LambdaQueryWrapper<UserProfile>()
|
||||
.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<UserProfile> profiles = userProfileMapper.selectList(
|
||||
new LambdaQueryWrapper<UserProfile>().isNotNull(UserProfile::getBirthDate));
|
||||
new LambdaQueryWrapper<UserProfile>()
|
||||
.isNotNull(UserProfile::getBirthDate)
|
||||
.isNull(UserProfile::getDeletedAt));
|
||||
List<Integer> breaks = Arrays.asList(18, 25, 30, 35, 40, 45, 100);
|
||||
Map<String, Long> 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<String, Object> getMatchFunnel(LocalDate since, LocalDate until) {
|
||||
if (since == null) since = LocalDate.now().minusMonths(1);
|
||||
if (until == null) until = LocalDate.now();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
// 总量
|
||||
long totalRecommended = recommendationRecordMapper.selectCount(
|
||||
new LambdaQueryWrapper<RecommendationRecord>()
|
||||
.isNotNull(RecommendationRecord::getShownAt));
|
||||
long totalLiked = recommendationRecordMapper.selectCount(
|
||||
new LambdaQueryWrapper<RecommendationRecord>()
|
||||
.eq(RecommendationRecord::getAction, "liked"));
|
||||
long totalMatches = matchMapper.selectCount(null);
|
||||
long chatsStarted = conversationMapper.selectCount(null);
|
||||
long gettingToKnow = matchMapper.selectCount(
|
||||
new LambdaQueryWrapper<Match>()
|
||||
.eq(Match::getStatus, "getting_to_know"));
|
||||
long successPairs = successRequestMapper.selectCount(
|
||||
new LambdaQueryWrapper<RelationshipSuccessRequest>()
|
||||
.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<Map<String, Object>> dailyTrend = buildDailyNewMatches(since, until);
|
||||
result.put("dailyTrend", dailyTrend);
|
||||
// Phase 20 D4:按来源分离,避免把活动互选/红娘促成的量算进用户自主匹配。
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
|
||||
// ==================== 活动漏斗 ====================
|
||||
|
||||
/**
|
||||
* 活动漏斗。
|
||||
*
|
||||
* <p><b>Phase 20 D5 修复</b>:修复前本方法的所有指标**硬编码为 0**
|
||||
* (并且留着一行 {@code userMapper.selectCount(...)} 把"用户数"当成"活动数"的死代码),
|
||||
* 后台"活动漏斗"因此永远显示 0 —— 不是没有数据,是根本没有查询。
|
||||
*
|
||||
* <p>同时严格区分「互选成立」与「互选后成功」:Gate 要求
|
||||
* 「活动10对互选 ≠ 10 对 Success」。
|
||||
*/
|
||||
public Map<String, Object> getActivityFunnel() {
|
||||
Map<String, Object> 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<String, Object> activityFunnelStats() {
|
||||
Map<String, Object> 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<String, Object> getSafetyStats() {
|
||||
Map<String, Object> 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<RiskAction>()
|
||||
.in(RiskAction::getActionType,
|
||||
Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_TEMPORARY_SUSPEND)
|
||||
.eq(RiskAction::getStatus, "active"));
|
||||
long bannedUsers = riskActionMapper.selectCount(
|
||||
new LambdaQueryWrapper<RiskAction>()
|
||||
.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<String, Object> safetyStats() {
|
||||
Map<String, Object> 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<Map<String, Object>> getDailySummary(int days) {
|
||||
if (days < 1 || days > 365) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR.getCode(), "days 必须在 1~365 之间");
|
||||
}
|
||||
List<Map<String, Object>> 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<String, Object> day = new HashMap<>();
|
||||
Map<String, Object> 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<String, Object> userBasicStats() {
|
||||
Map<String, Object> s = new HashMap<>();
|
||||
s.put("totalUsers", userMapper.selectCount(null));
|
||||
s.put("verifiedUsers", userProfileMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserProfile>().ne(UserProfile::getProfileStatus, "rejected")));
|
||||
s.put("approvedProfiles", userProfileMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getProfileStatus, "approved")));
|
||||
s.put("activeUsers7d", userMapper.selectCount(
|
||||
new LambdaQueryWrapper<User>()
|
||||
.ge(User::getLastLoginAt, Instant.now().minusSeconds(7L * 86400))));
|
||||
return s;
|
||||
}
|
||||
|
||||
private Map<String, Object> matchFunnelStats() {
|
||||
Map<String, Object> s = new HashMap<>();
|
||||
s.put("totalLikes", recommendationRecordMapper.selectCount(
|
||||
new LambdaQueryWrapper<RecommendationRecord>().eq(RecommendationRecord::getAction, "liked")));
|
||||
s.put("totalMatches", matchMapper.selectCount(null));
|
||||
s.put("gettingToKnow", matchMapper.selectCount(
|
||||
new LambdaQueryWrapper<Match>().eq(Match::getStatus, "getting_to_know")));
|
||||
s.put("successPairs", successRequestMapper.selectCount(
|
||||
new LambdaQueryWrapper<RelationshipSuccessRequest>().eq(RelationshipSuccessRequest::getStatus, "confirmed")));
|
||||
return s;
|
||||
}
|
||||
|
||||
private Map<String, Object> safetyStats() {
|
||||
Map<String, Object> 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<RiskAction>()
|
||||
.in(RiskAction::getActionType, Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_TEMPORARY_SUSPEND)
|
||||
.eq(RiskAction::getStatus, "active")));
|
||||
s.put("bannedUsers", riskActionMapper.selectCount(
|
||||
new LambdaQueryWrapper<RiskAction>()
|
||||
.eq(RiskAction::getActionType, Constants.RISK_ACTION_BAN)
|
||||
.eq(RiskAction::getStatus, "active")));
|
||||
return s;
|
||||
}
|
||||
|
||||
private Map<String, Object> activityFunnelStats() {
|
||||
Map<String, Object> s = new HashMap<>();
|
||||
s.put("totalActivities", 0);
|
||||
s.put("totalRegistrations", 0);
|
||||
return s;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildDailyNewUsers(LocalDate since, LocalDate until) {
|
||||
List<Map<String, Object>> 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<String, Object> 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 统计。
|
||||
*
|
||||
* <p><b>Phase 20 D6 修复</b>:修复前 {@code impressions} / {@code clicks} 被**硬编码为 0**。
|
||||
* 0 会被读成"真的没人看到 / 没人点击",而实际是项目内根本不存在曝光与点击的埋点表或上报端点。
|
||||
* 两者对运营决策的含义完全相反,因此改为显式标注不可用,绝不返回 0 冒充真实值。
|
||||
*/
|
||||
public Map<String, Object> getBannerStats() {
|
||||
Map<String, Object> 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<com.danyang.match.platform.banner.entity.HomeBanner>()
|
||||
.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<String, Object> 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<String, Object> getServiceStationStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
List<ServiceAppointment> appointments = serviceAppointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.isNotNull(ServiceAppointment::getServiceLocationId)
|
||||
.ne(ServiceAppointment::getStatus, "cancelled")
|
||||
);
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.isNotNull(ServiceAppointment::getServiceLocationId)
|
||||
.ne(ServiceAppointment::getStatus, "cancelled")
|
||||
.isNull(ServiceAppointment::getDeletedAt));
|
||||
Set<String> uniqueLocations = new HashSet<>();
|
||||
for (ServiceAppointment a : appointments) {
|
||||
if (a.getServiceLocationId() != null) uniqueLocations.add(a.getServiceLocationId());
|
||||
@@ -351,140 +313,181 @@ public class AnalyticsService {
|
||||
}
|
||||
|
||||
public Map<String, Object> getServiceAppointmentStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<ServiceAppointment> all = serviceAppointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceAppointment>().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<String, Object> 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<String, Object> getCounselingStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<CounselingServiceRecord> all = counselingServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>().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<String, Object> 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<String, Object> getPremaritalStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<CounselingServiceRecord> all = counselingServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.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<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("totalRequests", countCounselingByServiceType("premarital_guidance"));
|
||||
stats.put("completed", counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getServiceType, "premarital_guidance")
|
||||
.eq(CounselingServiceRecord::getStatus, "completed")
|
||||
.isNull(CounselingServiceRecord::getDeletedAt)));
|
||||
stats.put("pending", counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getServiceType, "premarital_guidance")
|
||||
.eq(CounselingServiceRecord::getStatus, "pending")
|
||||
.isNull(CounselingServiceRecord::getDeletedAt)));
|
||||
stats.put("withCompanion", counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getServiceType, "premarital_guidance")
|
||||
.isNotNull(CounselingServiceRecord::getCompanionUserId)
|
||||
.isNull(CounselingServiceRecord::getDeletedAt)));
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getContentStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
List<ContentArticle> articles = contentArticleMapper.selectList(
|
||||
new LambdaQueryWrapper<ContentArticle>()
|
||||
.eq(ContentArticle::getStatus, "published")
|
||||
.isNull(ContentArticle::getDeletedAt)
|
||||
);
|
||||
long totalViews = articles.stream()
|
||||
.mapToInt(a -> a.getViewCount() != null ? a.getViewCount() : 0).sum();
|
||||
new LambdaQueryWrapper<ContentArticle>()
|
||||
.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<Map<String, Object>> top = articles.stream().limit(5)
|
||||
.map(a -> { Map<String, Object> m = new HashMap<>(); m.put("id", a.getId()); m.put("title", a.getTitle()); m.put("views", a.getViewCount()); return m; })
|
||||
.toList();
|
||||
.map(a -> {
|
||||
Map<String, Object> 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<String, Object> getLocalEligibilityStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<LocalEligibility> 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<String, Object> stats = new LinkedHashMap<>();
|
||||
long total = localEligibilityMapper.selectCount(
|
||||
new LambdaQueryWrapper<LocalEligibility>().isNull(LocalEligibility::getDeletedAt));
|
||||
stats.put("total", total);
|
||||
stats.put("verified", localEligibilityMapper.selectCount(
|
||||
new LambdaQueryWrapper<LocalEligibility>()
|
||||
.eq(LocalEligibility::getVerificationStatus, "verified")
|
||||
.isNull(LocalEligibility::getDeletedAt)));
|
||||
stats.put("pending", localEligibilityMapper.selectCount(
|
||||
new LambdaQueryWrapper<LocalEligibility>()
|
||||
.in(LocalEligibility::getVerificationStatus, "pending_review", "pending")
|
||||
.isNull(LocalEligibility::getDeletedAt)));
|
||||
stats.put("rejected", localEligibilityMapper.selectCount(
|
||||
new LambdaQueryWrapper<LocalEligibility>()
|
||||
.eq(LocalEligibility::getVerificationStatus, "rejected")
|
||||
.isNull(LocalEligibility::getDeletedAt)));
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getServiceProgramStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<ServiceProgram> programs = serviceProgramMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceProgram>().isNull(ServiceProgram::getDeletedAt));
|
||||
List<ServiceProgramMember> 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<String, Object> 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<ServiceProgramMember>()
|
||||
.eq(ServiceProgramMember::getStatus, "pending")
|
||||
.isNull(ServiceProgramMember::getDeletedAt)));
|
||||
return stats;
|
||||
}
|
||||
|
||||
public Map<String, Object> getRegistrationSourceStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
List<ActivityRegistration> regs = activityRegistrationMapper.selectList(
|
||||
new LambdaQueryWrapper<ActivityRegistration>().ne(ActivityRegistration::getStatus, "cancelled"));
|
||||
Map<String, Long> 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<String, Object> stats = new LinkedHashMap<>();
|
||||
List<String> sources = List.of("user_self", "matchmaker_referral", "activity_followup",
|
||||
"service_station", "operator_assisted");
|
||||
Map<String, Long> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 公益服务用户数与服务人次。
|
||||
*
|
||||
* <p><b>Phase 20 计划明确要求「分离服务用户数与服务人次」</b>:
|
||||
* 前者是去重后的**人数**(覆盖面),后者是不去重的**人次**(承载量),
|
||||
* 两者比值即人均服务次数。修复前只有去重人数、没有服务人次。
|
||||
*/
|
||||
public Map<String, Object> getUniqueServiceUsersStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
Set<String> cu = counselingServiceMapper.selectList(new LambdaQueryWrapper<CounselingServiceRecord>().isNull(CounselingServiceRecord::getDeletedAt))
|
||||
.stream().map(CounselingServiceRecord::getUserId).collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> au = serviceAppointmentMapper.selectList(new LambdaQueryWrapper<ServiceAppointment>().isNull(ServiceAppointment::getDeletedAt))
|
||||
.stream().map(ServiceAppointment::getUserId).collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> pu = serviceProgramMemberMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceProgramMember>().eq(ServiceProgramMember::getStatus, "approved"))
|
||||
.stream().map(ServiceProgramMember::getUserId).collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> 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<String> all = new HashSet<>(); all.addAll(cu); all.addAll(au); all.addAll(pu); all.addAll(eu);
|
||||
stats.put("totalUniqueServiceUsers", all.size());
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
Set<String> counselingUsers = new HashSet<>();
|
||||
counselingServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.isNull(CounselingServiceRecord::getDeletedAt))
|
||||
.forEach(c -> counselingUsers.add(c.getUserId()));
|
||||
Set<String> appointmentUsers = new HashSet<>();
|
||||
serviceAppointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.isNull(ServiceAppointment::getDeletedAt))
|
||||
.forEach(a -> appointmentUsers.add(a.getUserId()));
|
||||
Set<String> programUsers = new HashSet<>();
|
||||
serviceProgramMemberMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceProgramMember>()
|
||||
.eq(ServiceProgramMember::getStatus, "approved")
|
||||
.isNull(ServiceProgramMember::getDeletedAt))
|
||||
.forEach(m -> programUsers.add(m.getUserId()));
|
||||
Set<String> eligibilityUsers = new HashSet<>();
|
||||
localEligibilityMapper.selectList(
|
||||
new LambdaQueryWrapper<LocalEligibility>()
|
||||
.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<String, Object> getEffectiveConnectionStats() {
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
long activeMatches = matchMapper.selectCount(
|
||||
new LambdaQueryWrapper<Match>().eq(Match::getStatus, "active"));
|
||||
long matchesWithChat = conversationMapper.selectCount(
|
||||
new LambdaQueryWrapper<Conversation>().in(Conversation::getStatus, "active", "muted"));
|
||||
long successRequests = successRequestMapper.selectCount(
|
||||
new LambdaQueryWrapper<RelationshipSuccessRequest>().eq(RelationshipSuccessRequest::getStatus, "confirmed"));
|
||||
Map<String, Object> 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<String, Object> getV11ServiceDashboard() {
|
||||
Map<String, Object> d = new HashMap<>();
|
||||
Map<String, Object> 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<Map<String, Object>> buildDailyNewMatches(LocalDate since, LocalDate until) {
|
||||
private static int sizeOf(Set<String> 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<ServiceAppointment>()
|
||||
.eq(ServiceAppointment::getStatus, status)
|
||||
.isNull(ServiceAppointment::getDeletedAt));
|
||||
}
|
||||
|
||||
private long countCounselingByStatus(String status) {
|
||||
return counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getStatus, status)
|
||||
.isNull(CounselingServiceRecord::getDeletedAt));
|
||||
}
|
||||
|
||||
private long countCounselingByServiceType(String serviceType) {
|
||||
return counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.eq(CounselingServiceRecord::getServiceType, serviceType)
|
||||
.isNull(CounselingServiceRecord::getDeletedAt));
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildDailyNewUsers(LocalDate since, LocalDate until) {
|
||||
return buildDailyTrend(since, until, this::countEntityByDateRange);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildDailyNewMatches(LocalDate since, LocalDate until) {
|
||||
return buildDailyTrend(since, until, this::countMatchByDateRange);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildDailyTrend(LocalDate since, LocalDate until,
|
||||
java.util.function.BiFunction<Instant, Instant, Long> counter) {
|
||||
List<Map<String, Object>> 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<String, Object> point = new HashMap<>();
|
||||
Map<String, Object> 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<User>()
|
||||
.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<Match>()
|
||||
.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<Conversation>()
|
||||
.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<Report>()
|
||||
.ge(Report::getCreatedAt, start)
|
||||
.le(Report::getCreatedAt, end));
|
||||
.le(Report::getCreatedAt, end)
|
||||
.isNull(Report::getDeletedAt));
|
||||
}
|
||||
}
|
||||
|
||||
+380
@@ -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;
|
||||
|
||||
/**
|
||||
* 统一口径计算层 —— 所有统计数字的**唯一**出处。
|
||||
*
|
||||
* <p>为什么要单独抽一层:Phase 20 的 Gate 是「Dashboard 和专项页面相同指标必须一致」。
|
||||
* 修复前 {@code AnalyticsService} 里同一个指标有 2~3 份实现
|
||||
* ({@code userBasicStats} / {@code getUserFunnel} / {@code getV11ServiceDashboard}),
|
||||
* 名字还各不相同({@code verifiedUsers} vs {@code verifiedCount}),
|
||||
* 只要有一处漏改就永久不一致,且没有任何测试能发现。
|
||||
*
|
||||
* <p>本类中每个方法都对应 {@link MetricCatalog} 里的一个已登记指标,
|
||||
* 方法上标注的指标编码即其口径来源。
|
||||
*
|
||||
* <h2>为什么每个查询都显式写 {@code deleted_at IS NULL}</h2>
|
||||
*
|
||||
* <p><b>Phase 20 D12</b>:{@code BaseEntity.deletedAt} **没有** {@code @TableLogic} 注解,
|
||||
* 项目也没有配置 MyBatis-Plus 的全局逻辑删除字段。这意味着
|
||||
* {@code selectCount(null)} 生成的是 {@code SELECT COUNT(*) FROM t} ——
|
||||
* **软删除的数据会被完整计入统计**。
|
||||
*
|
||||
* <p>实测影响:用户注销/被删、Banner 下架删除、活动软删之后,
|
||||
* 所有看板数字依然把它们算在内。这类偏差不会有任何报错,
|
||||
* 只会让"累计注册用户数"永远只增不减。因此本层所有查询统一显式过滤。
|
||||
*
|
||||
* <p><b>关于性能</b>:少量需要"跨域去重"的指标(如服务用户数)仍采用
|
||||
* 「查出各域 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<User>().isNull(User::getDeletedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code user.identity.verified} —— 实名认证通过用户数。
|
||||
*
|
||||
* <p><b>Phase 20 D1 修复点</b>:此前该口径被写成
|
||||
* {@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<UserProfile>()
|
||||
.eq(UserProfile::getProfileStatus, "approved")
|
||||
.isNull(UserProfile::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code user.active.7d} —— 近 7 日活跃用户数(不含软删)。 */
|
||||
public long activeUsers7d() {
|
||||
return userMapper.selectCount(
|
||||
new LambdaQueryWrapper<User>()
|
||||
.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} —— 红娘发出的推荐数。
|
||||
*
|
||||
* <p>按 {@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<com.danyang.match.platform.chat.entity.Conversation>()
|
||||
.isNull(com.danyang.match.platform.chat.entity.Conversation::getDeletedAt));
|
||||
}
|
||||
|
||||
// ==================== 活动域 ====================
|
||||
|
||||
/** {@code activity.total} —— 活动总数(不含软删)。 */
|
||||
public long activities() {
|
||||
return activityMapper.selectCount(new LambdaQueryWrapper<Activity>().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} —— 互选后成功牵手数。
|
||||
*
|
||||
* <p>与 {@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<ServiceAppointment>()
|
||||
.ne(ServiceAppointment::getStatus, "cancelled")
|
||||
.isNull(ServiceAppointment::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code counseling.total} —— 婚恋咨询受理量(不含软删)。 */
|
||||
public long counselingServices() {
|
||||
return counselingServiceMapper.selectCount(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.isNull(CounselingServiceRecord::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code service.program.member.approved} —— 专项项目在册成员数(不含软删)。 */
|
||||
public long approvedProgramMembers() {
|
||||
return serviceProgramMemberMapper.selectCount(
|
||||
new LambdaQueryWrapper<ServiceProgramMember>()
|
||||
.eq(ServiceProgramMember::getStatus, "approved")
|
||||
.isNull(ServiceProgramMember::getDeletedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code service.visit.total} —— 公益服务人次(**不去重**)。
|
||||
*
|
||||
* <p>与 {@link #uniqueServiceUsers()} 是**两个不同的指标**:
|
||||
* 本指标衡量服务承载量,同一用户来 3 次算 3 人次。
|
||||
*/
|
||||
public long serviceVisits() {
|
||||
return serviceAppointments()
|
||||
+ counselingServices()
|
||||
+ approvedProgramMembers()
|
||||
+ localEligibilityCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code service.user.unique} —— 服务用户数(**跨域去重**)。
|
||||
*
|
||||
* <p>同一用户既预约又咨询又参加项目,只算 1 人。与 {@link #serviceVisits()}
|
||||
* 的比值即人均服务次数,因此两者绝不可混用或互相替代。
|
||||
*/
|
||||
public long uniqueServiceUsers() {
|
||||
Set<String> users = new HashSet<>();
|
||||
serviceAppointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.isNull(ServiceAppointment::getDeletedAt))
|
||||
.forEach(a -> users.add(a.getUserId()));
|
||||
counselingServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<CounselingServiceRecord>()
|
||||
.isNull(CounselingServiceRecord::getDeletedAt))
|
||||
.forEach(c -> users.add(c.getUserId()));
|
||||
serviceProgramMemberMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceProgramMember>()
|
||||
.eq(ServiceProgramMember::getStatus, "approved")
|
||||
.isNull(ServiceProgramMember::getDeletedAt))
|
||||
.forEach(m -> users.add(m.getUserId()));
|
||||
localEligibilityMapper.selectList(
|
||||
new LambdaQueryWrapper<LocalEligibility>().isNull(LocalEligibility::getDeletedAt))
|
||||
.forEach(e -> users.add(e.getUserId()));
|
||||
users.remove(null);
|
||||
return users.size();
|
||||
}
|
||||
|
||||
private long localEligibilityCount() {
|
||||
return localEligibilityMapper.selectCount(
|
||||
new LambdaQueryWrapper<LocalEligibility>().isNull(LocalEligibility::getDeletedAt));
|
||||
}
|
||||
|
||||
/** 专项项目总数(不含软删),供服务仪表盘使用。 */
|
||||
public long servicePrograms() {
|
||||
return serviceProgramMapper.selectCount(
|
||||
new LambdaQueryWrapper<ServiceProgram>().isNull(ServiceProgram::getDeletedAt));
|
||||
}
|
||||
|
||||
/** 状态为 active 的专项项目数(不含软删)。 */
|
||||
public long activeServicePrograms() {
|
||||
return serviceProgramMapper.selectCount(
|
||||
new LambdaQueryWrapper<ServiceProgram>()
|
||||
.eq(ServiceProgram::getStatus, "active")
|
||||
.isNull(ServiceProgram::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code content.published.total} —— 已发布内容数(不含软删)。 */
|
||||
public long publishedArticles() {
|
||||
return contentArticleMapper.selectCount(
|
||||
new LambdaQueryWrapper<ContentArticle>()
|
||||
.eq(ContentArticle::getStatus, "published")
|
||||
.isNull(ContentArticle::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code content.views.total} —— 内容累计阅读量。 */
|
||||
public long contentViews() {
|
||||
return contentArticleMapper.selectList(
|
||||
new LambdaQueryWrapper<ContentArticle>()
|
||||
.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<HomeBanner>().isNull(HomeBanner::getDeletedAt));
|
||||
}
|
||||
|
||||
/** {@code banner.active} —— 生效中的 Banner 数(不含软删)。 */
|
||||
public long activeBanners() {
|
||||
Instant now = Instant.now();
|
||||
return homeBannerMapper.selectCount(
|
||||
new LambdaQueryWrapper<HomeBanner>()
|
||||
.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<RiskAction>()
|
||||
.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<RiskAction>()
|
||||
.eq(RiskAction::getActionType, Constants.RISK_ACTION_BAN)
|
||||
.eq(RiskAction::getStatus, "active")
|
||||
.isNull(RiskAction::getDeletedAt));
|
||||
}
|
||||
}
|
||||
@@ -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 的**单一事实来源**。
|
||||
*
|
||||
* <p>修复前的状态:同一个概念在 {@code AnalyticsService} 里有 2~3 份独立实现
|
||||
* ({@code userBasicStats()} / {@code getUserFunnel()} / {@code getV11ServiceDashboard()}
|
||||
* 各算一遍),且**同名不同义**(Dashboard 叫 {@code totalLikes},漏斗叫 {@code totalLiked};
|
||||
* Dashboard 叫 {@code verifiedUsers},漏斗叫 {@code verifiedCount})。
|
||||
* Phase 20 的 Gate 明确要求「Dashboard 和专项页面相同指标必须一致」,
|
||||
* 靠"记得同步改两处"是守不住的,必须有唯一来源。
|
||||
*
|
||||
* <p>本类同时是 {@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<MetricDefinition> 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<String, MetricDefinition> BY_CODE = ALL.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(MetricDefinition::code, Function.identity()));
|
||||
|
||||
/** 全部指标定义(顺序稳定,供文档生成与守卫遍历)。 */
|
||||
public static List<MetricDefinition> 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<MetricDefinition> provisional() {
|
||||
return ALL.stream().filter(MetricDefinition::provisional).toList();
|
||||
}
|
||||
|
||||
/** 因缺少埋点而当前无法计算的指标。 */
|
||||
public static List<MetricDefinition> notInstrumented() {
|
||||
return ALL.stream().filter(MetricDefinition::notInstrumented).toList();
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.danyang.match.platform.analytics.metric;
|
||||
|
||||
/**
|
||||
* 单个指标的完整定义(Phase 20 指标字典的代码化载体)。
|
||||
*
|
||||
* <p>Phase 20 计划原文要求:「为每个指标登记名称、定义、分子分母、去重键、时间字段、
|
||||
* 时区、版本和负责人」。这些字段此前散落在各统计方法里(大多数情况下**根本不存在**,
|
||||
* 口径只存在于写代码人的脑子里),导致:
|
||||
* <ul>
|
||||
* <li>同一个概念在不同接口里各算一遍,结果不一致(Dashboard 与专项页对不上);</li>
|
||||
* <li>「实名人数」被误算成「资料没被退回的人数」,没人能在评审时发现;</li>
|
||||
* <li>「服务用户数」与「服务人次」混用,去重与否无从判断。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>把定义变成结构化数据后,{@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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.danyang.match.platform.analytics.metric;
|
||||
|
||||
/**
|
||||
* 指标口径状态(Phase 20 指标字典的一部分)。
|
||||
*
|
||||
* <p>存在的理由:统计接口最大的风险不是"算错",而是**算出来的数字被当成别的意思用了**。
|
||||
* 「有效交友匹配」在口径确认前如果被后台当成"成功数"展示、被运营写进宣传稿,
|
||||
* 错误就从代码扩散到了对外承诺。把状态显式建模,可让守卫测试把
|
||||
* 「provisional 指标不得标记为正式」变成可执行的不变量。
|
||||
*/
|
||||
public enum MetricStatus {
|
||||
|
||||
/**
|
||||
* 口径已确认,可作为正式统计口径使用。
|
||||
*/
|
||||
OFFICIAL,
|
||||
|
||||
/**
|
||||
* 口径尚未确认,禁止当作正式成功口径,禁止对外宣传。
|
||||
*
|
||||
* <p>Phase 20 计划:「有效交友匹配在口径未确认前标为 provisional,
|
||||
* 不能当正式成功或对外宣传」。
|
||||
*/
|
||||
PROVISIONAL,
|
||||
|
||||
/**
|
||||
* 数据源尚未埋点,当前**无法**计算。
|
||||
*
|
||||
* <p>此状态的意义在于**诚实**:宁可明确返回「不可用」,
|
||||
* 也不能像修复前那样硬编码返回 0 —— 0 会被读成"真的没人点击",
|
||||
* 而实际是"根本没有记录点击的地方",两者对决策的含义完全相反。
|
||||
*/
|
||||
NOT_INSTRUMENTED
|
||||
}
|
||||
@@ -32,4 +32,22 @@ public interface MessageMapper extends BaseMapper<Message> {
|
||||
Optional<Message> findByClientMessageId(@Param("convId") String convId,
|
||||
@Param("senderId") String senderId,
|
||||
@Param("clientMsgId") String clientMsgId);
|
||||
|
||||
/**
|
||||
* **Phase 20 D3(Gate「System Message 不算 Chat Started」)**:真正开启过聊天的会话数。
|
||||
*
|
||||
* <p>口径:**至少存在一条真人消息**的会话数。{@code message_type} 的取值域由
|
||||
* {@code chk_msg_type} 限定为 {@code text / image / system / risk_notice},
|
||||
* 后两者是平台下发的通知(如「你们已互相喜欢,可以开始聊天了」),
|
||||
* 用户本人没有任何发言,因此**不能**算作 Chat Started。
|
||||
*
|
||||
* <p>此前 Analytics 用 {@code conversationMapper.selectCount(null)} 统计"开启聊天数",
|
||||
* 只看会话行数、**从不看消息类型** —— 一条系统通知就能让一个会话计入 Chat Started,
|
||||
* 导致该指标被系统性高估。
|
||||
*
|
||||
* <p>去重键:{@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();
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -69,4 +69,29 @@ public interface MatchMapper extends BaseMapper<Match> {
|
||||
"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);
|
||||
}
|
||||
|
||||
+26
@@ -20,4 +20,30 @@ public interface RecommendationRecordMapper extends BaseMapper<RecommendationRec
|
||||
|
||||
@Select("SELECT * FROM recommendation_records WHERE user_id = #{userId} AND target_user_id = #{targetUserId} AND action IS NOT NULL ORDER BY shown_at DESC LIMIT 1")
|
||||
RecommendationRecord findLatestAction(@Param("userId") String userId, @Param("targetUserId") String targetUserId);
|
||||
|
||||
// ==================== Phase 20:分开「看过」与「发出」两种口径 ====================
|
||||
// 生产 chk_rec_source 限定 source ∈ (daily, discover, matchmaker, activity);
|
||||
// chk_rec_action 限定 action ∈ (liked, passed, blocked, matched, NULL)。
|
||||
//
|
||||
// Gate「红娘看20候选、发3个 → Recommendation=3」的准确含义:
|
||||
// * 「看20候选」= 红娘浏览过 20 条候选(红娘工作台的浏览行为,不是推荐记录行数);
|
||||
// * 「发3个」 = 红娘实际向用户**发出** 3 条推荐 → source='matchmaker' 的推荐记录 = 3。
|
||||
// 因此 Recommendation 指标必须按 source 过滤,否则会把用户端「每日推荐」
|
||||
// (source='daily') 与「发现页」(source='discover') 的量混进红娘业绩。
|
||||
|
||||
/** 已展示的推荐总数(去重键 = id)。 */
|
||||
@Select("SELECT COUNT(*) FROM recommendation_records WHERE shown_at IS NOT NULL AND deleted_at IS NULL")
|
||||
long countShown();
|
||||
|
||||
/** 按来源统计已展示的推荐数。 */
|
||||
@Select("SELECT COUNT(*) FROM recommendation_records WHERE source = #{source} AND shown_at IS NOT NULL AND deleted_at IS NULL")
|
||||
long countShownBySource(@Param("source") String source);
|
||||
|
||||
/** 按来源统计用户已表态(liked / passed / blocked / matched)的推荐数。 */
|
||||
@Select("SELECT COUNT(*) FROM recommendation_records WHERE source = #{source} AND action = #{action} AND deleted_at IS NULL")
|
||||
long countBySourceAndAction(@Param("source") String source, @Param("action") String action);
|
||||
|
||||
/** 全量按行为统计(如全站 liked 数)。 */
|
||||
@Select("SELECT COUNT(*) FROM recommendation_records WHERE action = #{action} AND deleted_at IS NULL")
|
||||
long countByAction(@Param("action") String action);
|
||||
}
|
||||
|
||||
+15
@@ -19,4 +19,19 @@ public interface UserIdentityMapper extends BaseMapper<UserIdentity> {
|
||||
|
||||
@Select("SELECT * FROM user_identities WHERE id_number_hash = #{hash} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<UserIdentity> findByIdNumberHash(@Param("hash") String hash);
|
||||
|
||||
/**
|
||||
* **Phase 20 D1(口径造假修复)**:实名认证通过的用户数。
|
||||
*
|
||||
* <p>统计口径的唯一合法来源是 {@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」中的实名口径完全不是一回事。
|
||||
*
|
||||
* <p>去重键:{@code user_id}(同一用户多次提交实名只算一人)。
|
||||
*/
|
||||
@Select("SELECT COUNT(DISTINCT user_id) FROM user_identities "
|
||||
+ "WHERE verification_status = 'verified' AND deleted_at IS NULL")
|
||||
long countVerifiedUsers();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
+267
@@ -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 复验发现的跨阶段缺陷)。
|
||||
*
|
||||
* <h2>缺陷回顾</h2>
|
||||
*
|
||||
* <p>{@code AccountLifecycleService} 把两个**不同状态域**的常量混用了:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@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)。</li>
|
||||
* <li>{@code checkCanWithdraw} 用 {@code 'submitted'} / {@code 'pending'} 判断
|
||||
* "本地资格审核进行中" —— 这两个值在该表里**永不可能出现**,
|
||||
* 于是这道拦截从未生效过(死代码),审核中的用户可以直接注销。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>两者长期隐形的原因有二:测试库压根没有这两条 CHECK(约束漂移),
|
||||
* 且 {@code AccountLifecycleServiceTest} 由 7 个 {@code assertTrue(true)} 组成(假绿)。
|
||||
*
|
||||
* <h2>本守卫的四个层次</h2>
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>生产 DDL</b>:状态白名单确实由 V017 定义(解析真实迁移文件);</li>
|
||||
* <li><b>测试库一致性</b>:测试库必须声明同一份白名单 —— 这一条是**防漂移**的关键,
|
||||
* 缺了它,下一个"写得进测试库、炸在生产"的状态值依然不会被发现;</li>
|
||||
* <li><b>行为</b>:越界状态值必须被数据库真实拒绝(证明约束不是装饰);</li>
|
||||
* <li><b>代码</b>:注销流程结束后,资格状态必须落在白名单内;
|
||||
* 且"审核进行中"确实能拦截注销 —— 把死代码变成活断言。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class LocalEligibilityStatusVocabularyGuardTest {
|
||||
|
||||
private static final Set<String> ELIGIBILITY_STATUS_WHITELIST = Set.of(
|
||||
"declared", "needs_evidence", "pending_review", "verified", "rejected");
|
||||
|
||||
private static final Set<String> 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 (<column> IN (...))} 的取值集合。 */
|
||||
private static Set<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String, Object> result = accountLifecycleService.checkCanWithdraw(userId);
|
||||
assertEquals(false, result.get("canWithdraw"),
|
||||
"资格审核进行中的用户不应被允许注销 —— "
|
||||
+ "修复前这里比较的是 'submitted' / 'pending'(该表永不可能出现的值),"
|
||||
+ "拦截条件恒为 false,是死代码");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> blockers = (Map<String, Object>) 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<String, Object> result = accountLifecycleService.checkCanWithdraw(userId);
|
||||
assertEquals(true, result.get("canWithdraw"),
|
||||
"状态 " + status + " 不属于「审核进行中」,不得阻塞注销");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+625
@@ -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)。
|
||||
*
|
||||
* <p>本测试替代原先的 {@code AnalyticsServiceTest} —— 后者由 5 个
|
||||
* {@code assertTrue(true)} 组成,**没有任何断言**,却让"Phase 20 已完成"看起来成立。
|
||||
* 这是典型的假绿:测试通过不代表功能存在,只代表测试什么都没检查。
|
||||
*
|
||||
* <p>每个用例都直接把计划 Gate 的口径写成断言,并构造**真实数据行**
|
||||
* 让 SQL 在 H2 上真正执行(而不是 mock mapper 返回预设值 —— 那样测的是 mock,不是口径)。
|
||||
*
|
||||
* <p><b>为什么用「基线 + 增量」而不是绝对值</b>:统计是全表聚合,
|
||||
* 测试库中可能有其它测试留下的数据。用增量断言可精确表达"新增 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 专项项目成员。
|
||||
*
|
||||
* <p>注意测试库与生产都有 {@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<String, Object> dashboard = analyticsService.getDashboardStats();
|
||||
Map<String, Object> userFunnel = analyticsService.getUserFunnel(null, null);
|
||||
Map<String, Object> 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<String, Object> dashboard = analyticsService.getDashboardStats();
|
||||
long before = ((Number) dashboard.get("totalRegistrations")).longValue();
|
||||
|
||||
String activityId = UUID.randomUUID().toString();
|
||||
newMutualConsent(activityId, newUser(), newUser(), "accepted", "accepted");
|
||||
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> before = analyticsService.getServiceStationStats();
|
||||
long totalBefore = ((Number) before.get("totalAppointments")).longValue();
|
||||
|
||||
newAppointment(newUser(), "confirmed", false);
|
||||
newAppointment(newUser(), "confirmed", true);
|
||||
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> stats = analyticsService.getEffectiveConnectionStats();
|
||||
assertEquals(true, stats.get("effectiveMatchesProvisional"),
|
||||
"口径未确认的指标必须在响应里显式标注,避免被当成正式成功率引用");
|
||||
assertNotNull(stats.get("provisionalNote"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+174
@@ -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)。
|
||||
*
|
||||
* <p>D12 的根因是**系统性**的,而不是某个方法写错了:
|
||||
* {@code BaseEntity.deletedAt} 没有 {@code @TableLogic} 注解,项目也没有配置
|
||||
* MyBatis-Plus 的全局逻辑删除字段。于是 {@code selectCount(wrapper)} 在 wrapper 里
|
||||
* 不放软删条件时,生成的 SQL 就是 {@code SELECT COUNT(*) FROM t} ——
|
||||
* **软删除的数据会被完整计入统计,且没有任何报错**:
|
||||
* 用户被删、Banner 下架删除、活动软删之后,看板数字依然把它们算在内,
|
||||
* "累计注册用户数"只会增不会减。
|
||||
*
|
||||
* <p>因为根因是"少写一个条件",靠逐处人工补齐必然遗漏 ——
|
||||
* 本守卫的作用就是把「必须显式过滤」变成一条**可执行的结构约束**:
|
||||
* 统计包内每一处直接的 {@code *Mapper.selectCount(..)} / {@code *Mapper.selectList(..)}
|
||||
* 调用,都必须在其参数表达式中出现 {@code isNull(..)}。
|
||||
*
|
||||
* <h2>为什么是"解析源码"而不是"断言某几个数字"</h2>
|
||||
*
|
||||
* <p>数字断言只能覆盖被构造出来的固定数据集,对**尚未被想到的新统计查询**毫无约束力 ——
|
||||
* 而那正是 D12 复发的地方。源码级约束对新写的代码同样生效。
|
||||
*
|
||||
* <p>实现上有两个刻意的处理:
|
||||
* <ul>
|
||||
* <li>先**剥离注释**再匹配 —— 否则形如
|
||||
* {@code // 修复前这里有一行 userMapper.selectCount(...)} 的说明文字
|
||||
* 会被误判为真实调用(本守卫开发时确实踩到过这个假阳性);</li>
|
||||
* <li>按**括号配平**截取完整实参,而不是匹配到行尾 ——
|
||||
* 查询条件普遍写成多行链式调用,只看单行会漏掉写在后面的 {@code isNull(..)}。</li>
|
||||
* </ul>
|
||||
*/
|
||||
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*\\(");
|
||||
|
||||
/**
|
||||
* 兜底下界:防止"改坏定位逻辑 → 一个调用都没扫到 → 守卫静默变绿"。
|
||||
*
|
||||
* <p>当前实测 45 处。取 40 留出正常重构的余量,但足以在
|
||||
* 「目录搬走 / 正则写错 / 文件读取失败」时立刻变红。
|
||||
*/
|
||||
private static final int MIN_EXPECTED_CALL_SITES = 40;
|
||||
|
||||
private static List<Path> analyticsSources() throws IOException {
|
||||
Path dir = ANALYTICS_SRC;
|
||||
assertTrue(Files.isDirectory(dir),
|
||||
"守卫无法定位统计源码目录:" + dir.toAbsolutePath().normalize()
|
||||
+ "(CWD=" + Path.of("").toAbsolutePath() + ")。"
|
||||
+ "定位失败必须显式报错,绝不允许静默通过。");
|
||||
try (Stream<Path> 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<Violation> findUnfilteredStatsCalls() throws IOException {
|
||||
List<Violation> 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<Violation> 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 都没扫到,守卫无法生效。");
|
||||
}
|
||||
}
|
||||
+229
@@ -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)。
|
||||
*
|
||||
* <p>把「每个对外统计指标都必须在字典里有完整定义」从"评审时靠人记得检查"
|
||||
* 变成**可执行的不变量**。
|
||||
*
|
||||
* <p>背景:Phase 20 之前项目里有 15 个统计端点、40 多个返回字段,
|
||||
* 但**没有任何一处说明这些数字是怎么算出来的**。后果是「实名人数」被写成
|
||||
* "资料没被退回的人数"这类口径错误能长期存活 —— 因为没有任何机制要求
|
||||
* 写代码的人回答"你的分子分母是什么、去重键是什么"。
|
||||
*
|
||||
* <p>本守卫同时校验代码侧({@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<String> documentedCodes() throws IOException {
|
||||
assertTrue(Files.exists(METRICS_DOC),
|
||||
"指标字典文档缺失: " + METRICS_DOC.toAbsolutePath()
|
||||
+ "(Phase 20 要求为每个指标登记口径,文档是交付物之一)");
|
||||
String markdown = Files.readString(METRICS_DOC);
|
||||
Set<String> 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<String> codes = MetricCatalog.all().stream()
|
||||
.map(MetricDefinition::code)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// Gate 直接点名的口径,任何一个缺失都意味着 Gate 无法被验证
|
||||
List<String> 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<String> missing = gateCritical.stream().filter(c -> !codes.contains(c)).toList();
|
||||
assertTrue(missing.isEmpty(), "以下 Gate 关键指标未登记: " + missing);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("每个已登记指标都必须在 ANALYTICS_METRICS.md 中有条目")
|
||||
void everyRegisteredMetricIsDocumented() throws IOException {
|
||||
Set<String> documented = documentedCodes();
|
||||
List<String> 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<String> registered = MetricCatalog.all().stream()
|
||||
.map(MetricDefinition::code)
|
||||
.collect(Collectors.toSet());
|
||||
List<String> unregistered = documentedCodes().stream()
|
||||
.filter(code -> !registered.contains(code))
|
||||
.toList();
|
||||
assertTrue(unregistered.isEmpty(),
|
||||
"以下编码出现在文档中但未在 MetricCatalog 登记: " + unregistered
|
||||
+ "(新指标必须先登记再写文档)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("指标编码不得重复")
|
||||
void metricCodesAreUnique() {
|
||||
Set<String> seen = new TreeSet<>();
|
||||
List<String> 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<String> incomplete = new ArrayList<>();
|
||||
for (MetricDefinition d : MetricCatalog.all()) {
|
||||
List<String> 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<String> 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();
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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/";
|
||||
|
||||
+188
@@ -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)。
|
||||
*
|
||||
* <p>背景:{@code AnalyticsController} 上一直挂着 {@code @PreAuthorize("hasAuthority('analytics.view')")},
|
||||
* {@code OperatorEndpointPermissionCoverageTest} 也一直是绿的 ——
|
||||
* 因为它只检查「端点有没有声明权限」,**不检查「这个权限有没有授予任何角色」**。
|
||||
*
|
||||
* <p>于是出现了这样的组合:
|
||||
* <ul>
|
||||
* <li>V001 定义了一个专看统计的角色 {@code data_viewer}(描述:仅查看统计数据),
|
||||
* 但全仓迁移从未给它授予任何权限 —— 它是个空壳角色;</li>
|
||||
* <li>{@code platform_admin} 的权限扩展在 V015 里以
|
||||
* {@code id >= '20000000-...'} 为条件,而 {@code analytics.view} 的 id 是
|
||||
* {@code 10000000-...060}(V1.0 老权限段),因此被漏掉;</li>
|
||||
* <li>结果:整个 Analytics 模块除 {@code super_admin} 外全员 403,
|
||||
* 前端"数据统计"菜单对谁都是点了报错。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>两个守卫都绿,功能却完全不可达 —— 这是"守卫覆盖不到的组合缺陷"。
|
||||
* 本测试补上「权限 → 角色」这一环。
|
||||
*/
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析某个角色被授予的权限集合。
|
||||
*
|
||||
* <p>实现刻意**不做精细的 SQL 语法匹配**,而是"按语句切分 + 命中角色 ID 即提取权限码":
|
||||
* 项目里的授权语句有两种写法({@code WHERE code IN (...)} 与 {@code WHERE code = '...'}),
|
||||
* 用精确正则去匹配 {@code SELECT 'id', id FROM permissions WHERE code ...} 既脆弱又难维护
|
||||
* (换个换行或加个 ON CONFLICT 就漏解析,而漏解析会表现为"权限缺失"的误报)。
|
||||
*
|
||||
* <p>判定条件:同一条 SQL 语句中同时出现 {@code role_permissions}、{@code INSERT} 与该角色 ID,
|
||||
* 则该语句里的所有 {@code 命名空间.权限名} 形式字符串即该角色的授权。
|
||||
*/
|
||||
private static Set<String> grantsFor(String sql, String roleId) {
|
||||
Set<String> 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<String> 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<String> 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<String> 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<String> 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<String> defined = new HashSet<>();
|
||||
Matcher definitions = Pattern.compile(
|
||||
"INSERT\\s+INTO\\s+permissions\\s*\\([^)]*\\)\\s*VALUES(.*?);",
|
||||
Pattern.CASE_INSENSITIVE | Pattern.DOTALL).matcher(migrations);
|
||||
// 权限行形如 ('<uuid>', '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 '<super_admin>', 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 又没有别的授予路径,则整个统计模块将无人可访问");
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
))
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
|
||||
@@ -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<T>())` 生成的 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` 聚合 |
|
||||
+32
-2
@@ -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)
|
||||
|
||||
@@ -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<T>())` 生成的 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: <false> but was: <true>` |
|
||||
| 移除 `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`(新增)
|
||||
+36
-8
@@ -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 — 账户生命周期增量 ✅ 已完成
|
||||
| 任务 | 状态 |
|
||||
|
||||
Reference in New Issue
Block a user