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
@@ -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'
))
);
-- ============================================================================