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

口径造假:
- D1 实名人数用 user_profiles.profile_status != 'rejected' 冒充 → in_progress/need_edit 被算成已实名
- D3 Chat Started 直接数会话行数 → system/risk_notice 系统通知计入
- D4 Match 不分来源 → 活动 10 对互选被当 10 对成功引用(Gate)
- D6 Banner 曝光/点击硬编码 0 → 改返回 null + NOT_INSTRUMENTED 标记

死代码 / 占位:
- D5 活动漏斗全部硬编码 0 + 一行把"用户数"当"活动数"的死代码
- D20 getDailySummary(days) 无边界校验

系统性软删缺陷(D12):
- BaseEntity.deletedAt 无 @TableLogic 且全局无逻辑删除配置
  → selectCount 把软删数据全部计入统计且无任何报错
- 修复 45 处统计调用显式 deleted_at IS NULL(2 轮复验,首轮漏扫 5 处次级路径:
  服务用户数分域明细 / 服务站统计 / 性别与年龄分布)

结构类:
- D15 Dashboard/用户漏斗/专项页各实现一遍同名指标且互不相同 → 抽 AnalyticsMetricsCalculator 统一口径

权限(Gate ⑩):
- data_viewer 在 V001 定义却全仓从未授权(空壳角色)
- platform_admin 的 V015 扩展条件 id >= '20000000-…' 漏掉 V1.0 老权限段
  → 整个 Analytics 模块除 super_admin 外全员 403
- V043 授予两角色;刻意不授予 organization_admin(相关表无组织维度,无范围全量即越权)

假绿测试:
- 删除 AnalyticsServiceTest(5× assertTrue(true),零断言)

新增:
- 指标字典 MetricCatalog(36 指标 × 12 字段)+ MetricStatus/MetricDefinition + docs/ANALYTICS_METRICS.md
- 守卫 51 用例:MetricCatalogGuardTest(11) / AnalyticsMetricsPhase20IntegrationTest(21)
  / AnalyticsSoftDeleteGuardTest(4,结构性) / AnalyticsPermissionGrantTest(6)
  / LocalEligibilityStatusVocabularyGuardTest(9)

跨阶段修复(缺陷本体属账号生命周期模块,按"发现即修"处理):
- X1(Critical)注销写入 user_identities 状态域的 'failed' → 违反生产 V017 chk_elig_verify_status
  → 真实 PG 上 closeAccount 事务回滚、账号注销 100% 不可用(负向验证实测 DataIntegrityViolation)
- X2 checkCanWithdraw 资格拦截用 submitted/pending(该表永不可能出现)→ 死代码
- X3 测试库补 chk_elig_type/chk_elig_verify_status,暴露 ActivityPhase14Test 夹具违规

验证:
- 负向验证两轮共 7 处注入 → 7 个用例精准变红且无无关失败(一处被源码结构+运行时行为两层捕获)
- 还原后 md5sum 逐字节一致,NEG-VAL 残留 0
- 后端全量 648 tests / 0 failures / 0 errors / BUILD SUCCESS;admin-web vue-tsc EXIT=0
This commit is contained in:
WorkBuddy
2026-09-17 23:25:31 +08:00
parent ff22c0d06a
commit 22c778b111
28 changed files with 3652 additions and 359 deletions
@@ -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);
}
@@ -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();
}
@@ -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();
}
@@ -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));
}
}
@@ -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();
}
}
@@ -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();
}
@@ -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);
}
@@ -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);
}
@@ -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;
@@ -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;
@@ -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);
}
}
@@ -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 都没扫到,守卫无法生效。");
}
}
@@ -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();
}
}
@@ -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/";
@@ -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 又没有别的授予路径,则整个统计模块将无人可访问");
}
}
+11 -1
View File
@@ -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'
))
);
-- ============================================================================