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:
+267
@@ -0,0 +1,267 @@
|
||||
package com.danyang.match.platform.account;
|
||||
|
||||
import com.danyang.match.platform.account.service.AccountLifecycleService;
|
||||
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
|
||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 本地资格状态域守卫(Phase 20 复验发现的跨阶段缺陷)。
|
||||
*
|
||||
* <h2>缺陷回顾</h2>
|
||||
*
|
||||
* <p>{@code AccountLifecycleService} 把两个**不同状态域**的常量混用了:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code invalidateLocalEligibility} 写 {@code verification_status = 'failed'} ——
|
||||
* 但 {@code failed} 只属于 {@code user_identities}(V002 的 CHECK 才含它),
|
||||
* 本地资格表 V017 的 {@code chk_elig_verify_status} 只允许
|
||||
* {@code declared / needs_evidence / pending_review / verified / rejected}。
|
||||
* 后果:真实 PostgreSQL 上 CHECK 违约 → {@code closeAccount} 事务回滚 →
|
||||
* **账号注销 100% 不可用**(报 500)。</li>
|
||||
* <li>{@code checkCanWithdraw} 用 {@code 'submitted'} / {@code 'pending'} 判断
|
||||
* "本地资格审核进行中" —— 这两个值在该表里**永不可能出现**,
|
||||
* 于是这道拦截从未生效过(死代码),审核中的用户可以直接注销。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>两者长期隐形的原因有二:测试库压根没有这两条 CHECK(约束漂移),
|
||||
* 且 {@code AccountLifecycleServiceTest} 由 7 个 {@code assertTrue(true)} 组成(假绿)。
|
||||
*
|
||||
* <h2>本守卫的四个层次</h2>
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>生产 DDL</b>:状态白名单确实由 V017 定义(解析真实迁移文件);</li>
|
||||
* <li><b>测试库一致性</b>:测试库必须声明同一份白名单 —— 这一条是**防漂移**的关键,
|
||||
* 缺了它,下一个"写得进测试库、炸在生产"的状态值依然不会被发现;</li>
|
||||
* <li><b>行为</b>:越界状态值必须被数据库真实拒绝(证明约束不是装饰);</li>
|
||||
* <li><b>代码</b>:注销流程结束后,资格状态必须落在白名单内;
|
||||
* 且"审核进行中"确实能拦截注销 —— 把死代码变成活断言。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class LocalEligibilityStatusVocabularyGuardTest {
|
||||
|
||||
private static final Set<String> ELIGIBILITY_STATUS_WHITELIST = Set.of(
|
||||
"declared", "needs_evidence", "pending_review", "verified", "rejected");
|
||||
|
||||
private static final Set<String> ELIGIBILITY_TYPE_WHITELIST = Set.of(
|
||||
"household_registration", "residence", "employment", "other");
|
||||
|
||||
private static final Path MIGRATIONS = Path.of("backend/src/main/resources/db/migration");
|
||||
private static final Path TEST_SCHEMA = Path.of("backend/src/test/resources/schema-h2.sql");
|
||||
|
||||
@Autowired
|
||||
private AccountLifecycleService accountLifecycleService;
|
||||
@Autowired
|
||||
private UserMapper userMapper;
|
||||
@Autowired
|
||||
private LocalEligibilityMapper localEligibilityMapper;
|
||||
|
||||
// ==================== DDL 解析辅助 ====================
|
||||
|
||||
/** 从任意 SQL 文本中解析 {@code CHECK (<column> IN (...))} 的取值集合。 */
|
||||
private static Set<String> checkValues(String sql, String constraintName, String column) {
|
||||
Matcher m = Pattern.compile(
|
||||
Pattern.quote(constraintName) + "[\\s\\S]{0,400}?CHECK\\s*\\(\\s*"
|
||||
+ Pattern.quote(column) + "\\s+IN\\s*\\(([^)]*)\\)",
|
||||
Pattern.CASE_INSENSITIVE)
|
||||
.matcher(sql);
|
||||
if (!m.find()) {
|
||||
return null; // 交给调用方显式报错,而不是静默返回空集合
|
||||
}
|
||||
Set<String> values = new TreeSet<>();
|
||||
Matcher v = Pattern.compile("'([a-z_]+)'").matcher(m.group(1));
|
||||
while (v.find()) {
|
||||
values.add(v.group(1));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private static String read(Path path) throws IOException {
|
||||
assertTrue(Files.exists(path),
|
||||
"守卫无法定位文件:" + path.toAbsolutePath()
|
||||
+ "(CWD=" + Path.of("").toAbsolutePath() + ")—— 定位失败必须显式报错。");
|
||||
return Files.readString(path);
|
||||
}
|
||||
|
||||
private static String allMigrations() throws IOException {
|
||||
try (var files = Files.list(MIGRATIONS)) {
|
||||
return files.filter(p -> p.getFileName().toString().endsWith(".sql"))
|
||||
.sorted()
|
||||
.map(p -> {
|
||||
try {
|
||||
return Files.readString(p);
|
||||
} catch (IOException e) {
|
||||
throw new java.io.UncheckedIOException(e);
|
||||
}
|
||||
})
|
||||
.collect(Collectors.joining("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 夹具 ====================
|
||||
|
||||
private String newUser() {
|
||||
User user = new User();
|
||||
user.setId(UUID.randomUUID().toString());
|
||||
// wechat_openid 在测试库与生产库都是 NOT NULL UNIQUE
|
||||
user.setWechatOpenid("openid-" + UUID.randomUUID());
|
||||
user.setAccountStatus("normal");
|
||||
user.setRecommendationEnabled(true);
|
||||
userMapper.insert(user);
|
||||
return user.getId();
|
||||
}
|
||||
|
||||
private void newEligibility(String userId, String verificationStatus) {
|
||||
LocalEligibility eligibility = new LocalEligibility();
|
||||
eligibility.setId(UUID.randomUUID().toString());
|
||||
eligibility.setUserId(userId);
|
||||
eligibility.setEligibilityType("household_registration");
|
||||
eligibility.setVerificationStatus(verificationStatus);
|
||||
localEligibilityMapper.insert(eligibility);
|
||||
}
|
||||
|
||||
// ==================== 1. 生产 DDL ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("生产 V017 定义了本地资格的状态白名单(解析真实迁移,而非硬编码假设)")
|
||||
void productionDeclaresEligibilityStatusWhitelist() throws IOException {
|
||||
Set<String> statuses = checkValues(allMigrations(), "chk_elig_verify_status", "verification_status");
|
||||
assertNotNull(statuses,
|
||||
"未能在生产迁移中解析出 chk_elig_verify_status —— 约束被移除或守卫解析失效");
|
||||
assertEquals(ELIGIBILITY_STATUS_WHITELIST, statuses,
|
||||
"生产本地资格状态白名单发生变化;本守卫与 AccountLifecycleService 的状态判断需要同步");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("生产 V017 明确定义 chk_elig_type 白名单")
|
||||
void productionDeclaresEligibilityTypeWhitelist() throws IOException {
|
||||
Set<String> types = checkValues(allMigrations(), "chk_elig_type", "eligibility_type");
|
||||
assertNotNull(types, "未能在生产迁移中解析出 chk_elig_type");
|
||||
assertEquals(ELIGIBILITY_TYPE_WHITELIST, types);
|
||||
}
|
||||
|
||||
// ==================== 2. 测试库一致性(防漂移) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("测试库必须声明与生产相同的 chk_elig_verify_status(缺约束 = 该路径零覆盖)")
|
||||
void testSchemaDeclaresSameEligibilityStatusCheck() throws IOException {
|
||||
Set<String> testSchema = checkValues(read(TEST_SCHEMA), "chk_elig_verify_status", "verification_status");
|
||||
assertNotNull(testSchema,
|
||||
"测试库缺少 chk_elig_verify_status —— 这正是让 "
|
||||
+ "AccountLifecycleService 把 'failed' 写进本地资格表却长期无人发现的原因。");
|
||||
assertEquals(ELIGIBILITY_STATUS_WHITELIST, testSchema,
|
||||
"测试库与生产的状态白名单必须逐字一致,否则非法状态在测试期不可见");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试库必须声明与生产相同的 chk_elig_type")
|
||||
void testSchemaDeclaresSameEligibilityTypeCheck() throws IOException {
|
||||
Set<String> testSchema = checkValues(read(TEST_SCHEMA), "chk_elig_type", "eligibility_type");
|
||||
assertNotNull(testSchema, "测试库缺少 chk_elig_type");
|
||||
assertEquals(ELIGIBILITY_TYPE_WHITELIST, testSchema);
|
||||
}
|
||||
|
||||
// ==================== 3. 行为:约束必须真实生效 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("写入越界的资格状态必须被数据库拒绝(证明约束不是装饰)")
|
||||
void databaseRejectsStatusOutsideWhitelist() {
|
||||
String userId = newUser();
|
||||
LocalEligibility illegal = new LocalEligibility();
|
||||
illegal.setId(UUID.randomUUID().toString());
|
||||
illegal.setUserId(userId);
|
||||
illegal.setEligibilityType("household_registration");
|
||||
// 'failed' 属于 user_identities 的状态域,本地资格表不允许
|
||||
illegal.setVerificationStatus("failed");
|
||||
|
||||
assertThrows(Exception.class, () -> localEligibilityMapper.insert(illegal),
|
||||
"本地资格表必须拒绝 'failed' —— 若此处不抛异常,说明 chk_elig_verify_status 未生效,"
|
||||
+ "那么「注销时写入非法状态」这类缺陷在测试期将继续隐形");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("写入越界的 eligibility_type 必须被数据库拒绝")
|
||||
void databaseRejectsEligibilityTypeOutsideWhitelist() {
|
||||
String userId = newUser();
|
||||
LocalEligibility illegal = new LocalEligibility();
|
||||
illegal.setId(UUID.randomUUID().toString());
|
||||
illegal.setUserId(userId);
|
||||
illegal.setEligibilityType("household"); // 常见笔误:正确值是 household_registration
|
||||
illegal.setVerificationStatus("declared");
|
||||
|
||||
assertThrows(Exception.class, () -> localEligibilityMapper.insert(illegal),
|
||||
"eligibility_type 必须在 chk_elig_type 白名单内");
|
||||
}
|
||||
|
||||
// ==================== 4. 代码:注销流程不得写入越界状态 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("注销后本地资格状态必须落在白名单内(修复前写入 'failed' → 生产必崩)")
|
||||
void closeAccountLeavesEligibilityStatusWithinWhitelist() {
|
||||
String userId = newUser();
|
||||
newEligibility(userId, "declared");
|
||||
|
||||
accountLifecycleService.closeAccount(userId, UUID.randomUUID().toString(), "用户主动注销");
|
||||
|
||||
LocalEligibility after = localEligibilityMapper.findByUserId(userId).orElseThrow();
|
||||
assertTrue(ELIGIBILITY_STATUS_WHITELIST.contains(after.getVerificationStatus()),
|
||||
"注销后写入了越界状态 '" + after.getVerificationStatus() + "' —— "
|
||||
+ "在真实 PostgreSQL 上会触发 chk_elig_verify_status 违约,"
|
||||
+ "导致 closeAccount 整个事务回滚、注销永久不可用。"
|
||||
+ "当前状态域白名单:" + ELIGIBILITY_STATUS_WHITELIST);
|
||||
assertEquals("rejected", after.getVerificationStatus(),
|
||||
"注销应使本地资格失效(取白名单内的 rejected 语义)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("本地资格审核进行中必须能拦截注销(修复前用 submitted/pending 比较 → 死代码)")
|
||||
void pendingEligibilityReviewBlocksWithdrawal() {
|
||||
String userId = newUser();
|
||||
newEligibility(userId, "pending_review");
|
||||
|
||||
Map<String, Object> result = accountLifecycleService.checkCanWithdraw(userId);
|
||||
assertEquals(false, result.get("canWithdraw"),
|
||||
"资格审核进行中的用户不应被允许注销 —— "
|
||||
+ "修复前这里比较的是 'submitted' / 'pending'(该表永不可能出现的值),"
|
||||
+ "拦截条件恒为 false,是死代码");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> blockers = (Map<String, Object>) result.get("blockers");
|
||||
assertTrue(blockers.containsKey("localEligibility"),
|
||||
"应给出 localEligibility 阻塞项,实际:" + blockers.keySet());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已通过 / 已失效的资格不阻塞注销(避免过度拦截)")
|
||||
void verifiedOrRejectedEligibilityDoesNotBlockWithdrawal() {
|
||||
for (String status : new String[]{"verified", "rejected"}) {
|
||||
String userId = newUser();
|
||||
newEligibility(userId, status);
|
||||
Map<String, Object> result = accountLifecycleService.checkCanWithdraw(userId);
|
||||
assertEquals(true, result.get("canWithdraw"),
|
||||
"状态 " + status + " 不属于「审核进行中」,不得阻塞注销");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -466,7 +466,10 @@ class ActivityPhase14Test {
|
||||
|
||||
LocalEligibility el = new LocalEligibility();
|
||||
el.setUserId(userId);
|
||||
el.setEligibilityType("household");
|
||||
// 必须落在生产 V017 chk_elig_type 的白名单内
|
||||
// (household_registration / residence / employment / other)。
|
||||
// 测试库补齐该 CHECK 后,"household" 这类随意取值会被立即挡下。
|
||||
el.setEligibilityType("household_registration");
|
||||
el.setVerificationStatus(Constants.VERIFICATION_STATUS_VERIFIED);
|
||||
localEligibilityMapper.insert(el);
|
||||
return userId;
|
||||
|
||||
+625
@@ -0,0 +1,625 @@
|
||||
package com.danyang.match.platform.analytics;
|
||||
|
||||
import com.danyang.match.platform.activity_mutual.entity.ActivityMutualConsent;
|
||||
import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper;
|
||||
import com.danyang.match.platform.analytics.metric.AnalyticsMetricsCalculator;
|
||||
import com.danyang.match.platform.chat.entity.Conversation;
|
||||
import com.danyang.match.platform.chat.entity.Message;
|
||||
import com.danyang.match.platform.chat.mapper.ConversationMapper;
|
||||
import com.danyang.match.platform.chat.mapper.MessageMapper;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
|
||||
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
|
||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||
import com.danyang.match.platform.match.entity.Match;
|
||||
import com.danyang.match.platform.match.entity.RecommendationRecord;
|
||||
import com.danyang.match.platform.match.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
|
||||
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
|
||||
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
|
||||
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.verification.entity.UserIdentity;
|
||||
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Analytics 口径真实 DB 集成测试(Phase 20)。
|
||||
*
|
||||
* <p>本测试替代原先的 {@code AnalyticsServiceTest} —— 后者由 5 个
|
||||
* {@code assertTrue(true)} 组成,**没有任何断言**,却让"Phase 20 已完成"看起来成立。
|
||||
* 这是典型的假绿:测试通过不代表功能存在,只代表测试什么都没检查。
|
||||
*
|
||||
* <p>每个用例都直接把计划 Gate 的口径写成断言,并构造**真实数据行**
|
||||
* 让 SQL 在 H2 上真正执行(而不是 mock mapper 返回预设值 —— 那样测的是 mock,不是口径)。
|
||||
*
|
||||
* <p><b>为什么用「基线 + 增量」而不是绝对值</b>:统计是全表聚合,
|
||||
* 测试库中可能有其它测试留下的数据。用增量断言可精确表达"新增 N 条记录后该指标恰好 +N",
|
||||
* 同时避免测试之间互相污染导致的偶发失败。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class AnalyticsMetricsPhase20IntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private AnalyticsMetricsCalculator metrics;
|
||||
@Autowired
|
||||
private AnalyticsService analyticsService;
|
||||
|
||||
@Autowired
|
||||
private UserMapper userMapper;
|
||||
@Autowired
|
||||
private UserProfileMapper userProfileMapper;
|
||||
@Autowired
|
||||
private UserIdentityMapper userIdentityMapper;
|
||||
@Autowired
|
||||
private RecommendationRecordMapper recommendationRecordMapper;
|
||||
@Autowired
|
||||
private MatchMapper matchMapper;
|
||||
@Autowired
|
||||
private ConversationMapper conversationMapper;
|
||||
@Autowired
|
||||
private MessageMapper messageMapper;
|
||||
@Autowired
|
||||
private ActivityMutualConsentMapper activityMutualConsentMapper;
|
||||
@Autowired
|
||||
private ServiceAppointmentMapper serviceAppointmentMapper;
|
||||
@Autowired
|
||||
private CounselingServiceMapper counselingServiceMapper;
|
||||
@Autowired
|
||||
private ServiceProgramMemberMapper serviceProgramMemberMapper;
|
||||
@Autowired
|
||||
private LocalEligibilityMapper localEligibilityMapper;
|
||||
|
||||
// ==================== 夹具 ====================
|
||||
|
||||
private String newUser() {
|
||||
User user = new User();
|
||||
user.setId(UUID.randomUUID().toString());
|
||||
// wechat_openid 在测试库与生产库都是 NOT NULL UNIQUE,夹具必须给唯一值。
|
||||
user.setWechatOpenid("openid-" + UUID.randomUUID());
|
||||
user.setAccountStatus("active");
|
||||
user.setRecommendationEnabled(true);
|
||||
userMapper.insert(user);
|
||||
return user.getId();
|
||||
}
|
||||
|
||||
private void newProfile(String userId, String status) {
|
||||
UserProfile profile = new UserProfile();
|
||||
profile.setUserId(userId);
|
||||
profile.setDisplayName("测试用户");
|
||||
profile.setProfileStatus(status);
|
||||
userProfileMapper.insert(profile);
|
||||
}
|
||||
|
||||
private void newVerifiedIdentity(String userId, String verificationStatus) {
|
||||
UserIdentity identity = new UserIdentity();
|
||||
identity.setId(UUID.randomUUID().toString());
|
||||
identity.setUserId(userId);
|
||||
identity.setRealNameEnc("enc-real-name");
|
||||
identity.setIdNumberEnc("enc-id-number");
|
||||
identity.setIdNumberHash(UUID.randomUUID().toString());
|
||||
identity.setVerificationStatus(verificationStatus);
|
||||
if ("verified".equals(verificationStatus)) {
|
||||
identity.setVerifiedAt(Instant.now());
|
||||
}
|
||||
userIdentityMapper.insert(identity);
|
||||
}
|
||||
|
||||
private void newRecommendation(String userId, String targetUserId, String source, String action) {
|
||||
RecommendationRecord record = new RecommendationRecord();
|
||||
record.setId(UUID.randomUUID().toString());
|
||||
record.setUserId(userId);
|
||||
record.setTargetUserId(targetUserId);
|
||||
record.setSource(source);
|
||||
record.setShownAt(Instant.now());
|
||||
record.setAction(action);
|
||||
record.setActionAt(Instant.now());
|
||||
recommendationRecordMapper.insert(record);
|
||||
}
|
||||
|
||||
private void newMatch(String userAId, String userBId, String source, String status) {
|
||||
Match match = new Match();
|
||||
match.setId(UUID.randomUUID().toString());
|
||||
match.setUserAId(userAId);
|
||||
match.setUserBId(userBId);
|
||||
match.setSource(source);
|
||||
match.setStatus(status);
|
||||
match.setMatchedAt(Instant.now());
|
||||
match.setEpisode(1);
|
||||
matchMapper.insert(match);
|
||||
}
|
||||
|
||||
private String newConversation() {
|
||||
Conversation conversation = new Conversation();
|
||||
conversation.setId(UUID.randomUUID().toString());
|
||||
conversation.setStatus("active");
|
||||
conversationMapper.insert(conversation);
|
||||
return conversation.getId();
|
||||
}
|
||||
|
||||
private void newMessage(String conversationId, String messageType) {
|
||||
Message message = new Message();
|
||||
message.setId(UUID.randomUUID().toString());
|
||||
message.setConversationId(conversationId);
|
||||
message.setMessageType(messageType);
|
||||
message.setContent("测试内容");
|
||||
message.setStatus("sent");
|
||||
messageMapper.insert(message);
|
||||
}
|
||||
|
||||
private void newMutualConsent(String activityId, String userAId, String userBId,
|
||||
String statusA, String statusB) {
|
||||
ActivityMutualConsent consent = new ActivityMutualConsent();
|
||||
consent.setId(UUID.randomUUID().toString());
|
||||
consent.setActivityId(activityId);
|
||||
consent.setUserAId(userAId);
|
||||
consent.setUserBId(userBId);
|
||||
consent.setUserAStatus(statusA);
|
||||
consent.setUserBStatus(statusB);
|
||||
if ("accepted".equals(statusA) && "accepted".equals(statusB)) {
|
||||
consent.setBothConfirmedAt(Instant.now());
|
||||
}
|
||||
activityMutualConsentMapper.insert(consent);
|
||||
}
|
||||
|
||||
private void newAppointment(String userId, String status) {
|
||||
newAppointment(userId, status, false);
|
||||
}
|
||||
|
||||
private void newAppointment(String userId, String status, boolean softDeleted) {
|
||||
ServiceAppointment appointment = new ServiceAppointment();
|
||||
appointment.setId(UUID.randomUUID().toString());
|
||||
appointment.setUserId(userId);
|
||||
appointment.setServiceLocationId(UUID.randomUUID().toString());
|
||||
appointment.setServiceType("station_visit");
|
||||
appointment.setStatus(status);
|
||||
appointment.setSource("user_self");
|
||||
if (softDeleted) {
|
||||
appointment.setDeletedAt(Instant.now());
|
||||
}
|
||||
serviceAppointmentMapper.insert(appointment);
|
||||
}
|
||||
|
||||
/**
|
||||
* 专项项目成员。
|
||||
*
|
||||
* <p>注意测试库与生产都有 {@code UNIQUE(program_id, user_id)},
|
||||
* 每次都新建 programId 以免撞唯一键。
|
||||
*/
|
||||
private void newProgramMember(String userId, String status, boolean softDeleted) {
|
||||
ServiceProgramMember member = new ServiceProgramMember();
|
||||
member.setId(UUID.randomUUID().toString());
|
||||
member.setProgramId(UUID.randomUUID().toString());
|
||||
member.setUserId(userId);
|
||||
member.setStatus(status);
|
||||
member.setAppliedAt(Instant.now());
|
||||
if ("approved".equals(status)) {
|
||||
member.setApprovedAt(Instant.now());
|
||||
}
|
||||
if (softDeleted) {
|
||||
member.setDeletedAt(Instant.now());
|
||||
}
|
||||
serviceProgramMemberMapper.insert(member);
|
||||
}
|
||||
|
||||
/** 本地资格声明。测试库与生产都限制 {@code user_id UNIQUE},故每个用户最多一条。 */
|
||||
private void newLocalEligibility(String userId, boolean softDeleted) {
|
||||
LocalEligibility eligibility = new LocalEligibility();
|
||||
eligibility.setId(UUID.randomUUID().toString());
|
||||
eligibility.setUserId(userId);
|
||||
// 取值必须在生产 V017 chk_elig_type 白名单内
|
||||
eligibility.setEligibilityType("household_registration");
|
||||
eligibility.setDeclaredRegionCode("321100");
|
||||
eligibility.setVerificationStatus("pending_review");
|
||||
if (softDeleted) {
|
||||
eligibility.setDeletedAt(Instant.now());
|
||||
}
|
||||
localEligibilityMapper.insert(eligibility);
|
||||
}
|
||||
|
||||
private void newCounseling(String userId) {
|
||||
CounselingServiceRecord record = new CounselingServiceRecord();
|
||||
record.setId(UUID.randomUUID().toString());
|
||||
record.setUserId(userId);
|
||||
record.setServiceType("relationship_guidance");
|
||||
record.setRequestSource("user_self");
|
||||
record.setStatus("pending");
|
||||
counselingServiceMapper.insert(record);
|
||||
}
|
||||
|
||||
// ==================== Gate 1:注册10 / 实名7 / 审核通过4 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate:注册10 / 实名7 / 审核通过4 —— 三个口径互不污染")
|
||||
void gateUserFunnelCountsThreeDistinctConcepts() {
|
||||
long registeredBefore = metrics.registeredUsers();
|
||||
long verifiedBefore = metrics.realNameVerifiedUsers();
|
||||
long approvedBefore = metrics.profileApprovedUsers();
|
||||
|
||||
// 10 个注册用户;其中 7 个已实名;其中 4 个资料审核通过
|
||||
String[] users = new String[10];
|
||||
for (int i = 0; i < 10; i++) {
|
||||
users[i] = newUser();
|
||||
}
|
||||
for (int i = 0; i < 7; i++) {
|
||||
newVerifiedIdentity(users[i], "verified");
|
||||
}
|
||||
for (int i = 0; i < 4; i++) {
|
||||
newProfile(users[i], "approved");
|
||||
}
|
||||
// 干扰项:3 个"资料填写中"、2 个"被退回待改"、1 个"认证中"、
|
||||
// 这些都不该被算进「实名」—— 正是修复前把它们全算成实名的那个缺陷。
|
||||
newProfile(users[7], "in_progress");
|
||||
newProfile(users[8], "need_edit");
|
||||
newProfile(users[9], "pending_review");
|
||||
newVerifiedIdentity(users[7], "verifying");
|
||||
newVerifiedIdentity(users[8], "failed");
|
||||
newVerifiedIdentity(users[9], "manual_review");
|
||||
|
||||
assertEquals(registeredBefore + 10, metrics.registeredUsers(), "注册数应为 +10");
|
||||
assertEquals(verifiedBefore + 7, metrics.realNameVerifiedUsers(),
|
||||
"实名数必须严格等于 verification_status='verified' 的记录数(+7);"
|
||||
+ "若把 in_progress / need_edit 也计入,此断言会失败 —— 那正是修复前的缺陷");
|
||||
assertEquals(approvedBefore + 4, metrics.profileApprovedUsers(),
|
||||
"资料审核通过数应为 +4(仅 profile_status='approved')");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("实名口径不得回退为「资料没被退回」(修复前缺陷的回归守卫)")
|
||||
void realNameIsNotProfileStatusProxy() {
|
||||
long verifiedBefore = metrics.realNameVerifiedUsers();
|
||||
|
||||
// 造 5 个「资料已填完但从未做实名认证」的用户:
|
||||
// 修复前的口径(profile_status != 'rejected')会把它们全部算成已实名。
|
||||
for (int i = 0; i < 5; i++) {
|
||||
String id = newUser();
|
||||
newProfile(id, "completed");
|
||||
}
|
||||
|
||||
assertEquals(verifiedBefore, metrics.realNameVerifiedUsers(),
|
||||
"资料状态为 completed 但无 user_identities 记录的用户**不是**已实名用户 —— "
|
||||
+ "「资料没被退回」与「已实名」是两个不同的概念");
|
||||
}
|
||||
|
||||
// ==================== Gate 2:2 Like = 1 Mutual Match ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate:2 个 Like 形成 1 个 Mutual Match")
|
||||
void gateTwoLikesProduceOneMutualMatch() {
|
||||
long likesBefore = metrics.likes();
|
||||
long matchesBefore = metrics.matches();
|
||||
|
||||
String userA = newUser();
|
||||
String userB = newUser();
|
||||
newRecommendation(userA, userB, "daily", "liked");
|
||||
newRecommendation(userB, userA, "daily", "liked");
|
||||
newMatch(userA, userB, "user_like", "active");
|
||||
|
||||
assertEquals(likesBefore + 2, metrics.likes(), "两条单向 Like 应计为 2");
|
||||
assertEquals(matchesBefore + 1, metrics.matches(), "双向 Like 只形成 1 个 Match");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Match 必须按来源分离,不能混成一个数字")
|
||||
void matchesAreSeparatedBySource() {
|
||||
long userLikeBefore = metrics.matchesBySource("user_like");
|
||||
long mutualBefore = metrics.matchesBySource("activity_mutual");
|
||||
long matchmakerBefore = metrics.matchesBySource("matchmaker");
|
||||
|
||||
newMatch(newUser(), newUser(), "user_like", "active");
|
||||
newMatch(newUser(), newUser(), "activity_mutual", "active");
|
||||
newMatch(newUser(), newUser(), "matchmaker", "active");
|
||||
|
||||
assertEquals(userLikeBefore + 1, metrics.matchesBySource("user_like"));
|
||||
assertEquals(mutualBefore + 1, metrics.matchesBySource("activity_mutual"));
|
||||
assertEquals(matchmakerBefore + 1, metrics.matchesBySource("matchmaker"));
|
||||
}
|
||||
|
||||
// ==================== Gate 3:System Message 不算 Chat Started ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate:只有系统消息的会话不计入 Chat Started")
|
||||
void gateSystemMessageDoesNotCountAsChatStarted() {
|
||||
long startedBefore = metrics.chatsStarted();
|
||||
|
||||
// 会话 1:只有 system 消息(平台下发的「你们已互相喜欢」)
|
||||
String systemOnly = newConversation();
|
||||
newMessage(systemOnly, "system");
|
||||
|
||||
assertEquals(startedBefore, metrics.chatsStarted(),
|
||||
"仅有 system 消息的会话不算开启聊天 —— 修复前直接数会话行数,"
|
||||
+ "系统通知会把该指标系统性高估");
|
||||
|
||||
// 会话 2:双方真正开口
|
||||
String realChat = newConversation();
|
||||
newMessage(realChat, "text");
|
||||
assertEquals(startedBefore + 1, metrics.chatsStarted(), "有真人消息的会话才 +1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("risk_notice 同样不计入 Chat Started")
|
||||
void riskNoticeDoesNotCountAsChatStarted() {
|
||||
long startedBefore = metrics.chatsStarted();
|
||||
String conversationId = newConversation();
|
||||
newMessage(conversationId, "risk_notice");
|
||||
assertEquals(startedBefore, metrics.chatsStarted(), "风险提示是平台下发的,不是用户发言");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同一会话内多条真人消息只算 1 个 Chat Started(去重键 = conversation_id)")
|
||||
void chatStartedDeduplicatesByConversation() {
|
||||
long startedBefore = metrics.chatsStarted();
|
||||
String conversationId = newConversation();
|
||||
newMessage(conversationId, "text");
|
||||
newMessage(conversationId, "image");
|
||||
newMessage(conversationId, "text");
|
||||
assertEquals(startedBefore + 1, metrics.chatsStarted(),
|
||||
"同一会话的多条消息只算一次 —— 去重键是 conversation_id 而非消息 id");
|
||||
}
|
||||
|
||||
// ==================== Gate 4:活动 10 对互选 ≠ 10 对 Success ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate:10 对互选成立 ≠ 10 对成功牵手")
|
||||
void gateMutualConfirmationDoesNotEqualSuccess() {
|
||||
long bothConfirmedBefore = metrics.mutualBothConfirmed();
|
||||
long mutualSuccessfulBefore = metrics.mutualSuccessful();
|
||||
|
||||
String activityId = UUID.randomUUID().toString();
|
||||
// 10 对双确认
|
||||
for (int i = 0; i < 10; i++) {
|
||||
newMutualConsent(activityId, newUser(), newUser(), "accepted", "accepted");
|
||||
}
|
||||
// 其中只有 3 对最终成功牵手
|
||||
for (int i = 0; i < 3; i++) {
|
||||
newMatch(newUser(), newUser(), "activity_mutual", "successful");
|
||||
}
|
||||
|
||||
assertEquals(bothConfirmedBefore + 10, metrics.mutualBothConfirmed(),
|
||||
"互选成立对数应为 10(严格双确认)");
|
||||
assertEquals(mutualSuccessfulBefore + 3, metrics.mutualSuccessful(),
|
||||
"互选后成功牵手数应为 3");
|
||||
assertNotEquals(metrics.mutualBothConfirmed(), metrics.mutualSuccessful(),
|
||||
"互选成立对数与成功牵手数**必须可以不同** —— "
|
||||
+ "把两者当成同一个数字会让「10 对互选」被引述为「10 对成功」");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("单侧同意的互选不算成立(防止把「有人点了同意」当成互选)")
|
||||
void oneSidedConsentIsNotMutual() {
|
||||
long bothConfirmedBefore = metrics.mutualBothConfirmed();
|
||||
String activityId = UUID.randomUUID().toString();
|
||||
newMutualConsent(activityId, newUser(), newUser(), "accepted", "pending");
|
||||
newMutualConsent(activityId, newUser(), newUser(), "pending", "accepted");
|
||||
newMutualConsent(activityId, newUser(), newUser(), "accepted", "declined");
|
||||
assertEquals(bothConfirmedBefore, metrics.mutualBothConfirmed(),
|
||||
"只有两侧都是 accepted 才算互选成立");
|
||||
}
|
||||
|
||||
// ==================== Gate 5:红娘看 20 候选、发 3 个 → Recommendation=3 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate:红娘发出 3 条推荐 → Recommendation=3(不是浏览过的 20 个候选)")
|
||||
void gateMatchmakerRecommendationCountsOnlySent() {
|
||||
long before = metrics.matchmakerRecommendations();
|
||||
|
||||
// 红娘浏览了 20 个候选(不产生推荐记录),实际只发出 3 条
|
||||
for (int i = 0; i < 3; i++) {
|
||||
newRecommendation(newUser(), newUser(), "matchmaker", null);
|
||||
}
|
||||
// 干扰项:用户端自动推荐 17 条(source 不是 matchmaker),不该计入红娘业绩
|
||||
for (int i = 0; i < 17; i++) {
|
||||
newRecommendation(newUser(), newUser(), "daily", null);
|
||||
}
|
||||
|
||||
assertEquals(before + 3, metrics.matchmakerRecommendations(),
|
||||
"红娘推荐数必须按 source='matchmaker' 过滤;"
|
||||
+ "把 daily / discover 的自动推荐算进来会虚增红娘业绩");
|
||||
}
|
||||
|
||||
// ==================== Gate 6:服务用户数 vs 服务人次 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate:同一用户 3 次预约 + 1 次咨询 → 用户数计 1、人次计 4")
|
||||
void gateServiceUsersDeduplicatedButVisitsAreNot() {
|
||||
long visitsBefore = metrics.serviceVisits();
|
||||
long uniqueBefore = metrics.uniqueServiceUsers();
|
||||
|
||||
String sameUser = newUser();
|
||||
newAppointment(sameUser, "submitted");
|
||||
newAppointment(sameUser, "confirmed");
|
||||
newAppointment(sameUser, "completed");
|
||||
newCounseling(sameUser);
|
||||
|
||||
assertEquals(visitsBefore + 4, metrics.serviceVisits(),
|
||||
"服务人次不去重:3 次预约 + 1 次咨询 = 4 人次");
|
||||
assertEquals(uniqueBefore + 1, metrics.uniqueServiceUsers(),
|
||||
"服务用户数跨域去重:同一个人只算 1 人 —— "
|
||||
+ "两者比值即人均服务次数,混用会同时高估覆盖面与低估承载量");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已取消的预约不计入服务人次")
|
||||
void cancelledAppointmentIsNotAServiceVisit() {
|
||||
long before = metrics.serviceVisits();
|
||||
newAppointment(newUser(), "cancelled");
|
||||
assertEquals(before, metrics.serviceVisits(), "取消的预约没有实际提供服务");
|
||||
}
|
||||
|
||||
// ==================== Gate 7:Dashboard 与专项页必须一致 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate:Dashboard 与专项页的同一指标必须完全相等")
|
||||
void gateDashboardAgreesWithDetailViews() {
|
||||
// 造一批数据,使各视图都有非零值可比较
|
||||
for (int i = 0; i < 6; i++) {
|
||||
String id = newUser();
|
||||
newVerifiedIdentity(id, "verified");
|
||||
newProfile(id, "approved");
|
||||
}
|
||||
String a = newUser();
|
||||
String b = newUser();
|
||||
newRecommendation(a, b, "daily", "liked");
|
||||
newRecommendation(b, a, "daily", "liked");
|
||||
newMatch(a, b, "user_like", "successful");
|
||||
String realChat = newConversation();
|
||||
newMessage(realChat, "text");
|
||||
|
||||
Map<String, Object> dashboard = analyticsService.getDashboardStats();
|
||||
Map<String, Object> userFunnel = analyticsService.getUserFunnel(null, null);
|
||||
Map<String, Object> matchFunnel = analyticsService.getMatchFunnel(null, null);
|
||||
|
||||
assertEquals(userFunnel.get("verifiedCount"), dashboard.get("verifiedUsers"),
|
||||
"实名人数在 Dashboard 与用户漏斗必须相同(修复前是两套独立实现,"
|
||||
+ "字段名还一个叫 verifiedUsers 一个叫 verifiedCount)");
|
||||
assertEquals(userFunnel.get("profileApproved"), dashboard.get("approvedProfiles"),
|
||||
"审核通过数在两个视图必须相同");
|
||||
assertEquals(matchFunnel.get("totalMatches"), dashboard.get("totalMatches"),
|
||||
"Match 总数在两个视图必须相同");
|
||||
assertEquals(matchFunnel.get("chatsStarted"), dashboard.get("chatsStarted"),
|
||||
"开启聊天数在两个视图必须相同");
|
||||
assertEquals(matchFunnel.get("successPairs"), dashboard.get("successPairs"),
|
||||
"成功牵手数在两个视图必须相同");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Dashboard 的活动指标不再是硬编码 0(修复前活动漏斗永远显示 0)")
|
||||
void dashboardActivityMetricsAreNotHardcodedZero() {
|
||||
Map<String, Object> dashboard = analyticsService.getDashboardStats();
|
||||
long before = ((Number) dashboard.get("totalRegistrations")).longValue();
|
||||
|
||||
String activityId = UUID.randomUUID().toString();
|
||||
newMutualConsent(activityId, newUser(), newUser(), "accepted", "accepted");
|
||||
|
||||
Map<String, Object> after = analyticsService.getDashboardStats();
|
||||
assertEquals(before, ((Number) after.get("totalRegistrations")).longValue(),
|
||||
"报名数不应受互选影响(口径隔离)");
|
||||
assertEquals(1L, ((Number) after.get("mutualBothConfirmed")).longValue()
|
||||
- ((Number) dashboard.get("mutualBothConfirmed")).longValue(),
|
||||
"新增 1 对互选后 mutualBothConfirmed 必须 +1 —— "
|
||||
+ "修复前该字段根本不存在(活动漏斗全为硬编码 0)");
|
||||
}
|
||||
|
||||
// ==================== Gate 8:软删数据不得计入(D12 守卫) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("软删除的用户不计入注册数(BaseEntity 无 @TableLogic,必须显式过滤)")
|
||||
void softDeletedUserIsExcludedFromRegisteredCount() {
|
||||
long before = metrics.registeredUsers();
|
||||
|
||||
User deleted = new User();
|
||||
deleted.setId(UUID.randomUUID().toString());
|
||||
deleted.setWechatOpenid("openid-" + UUID.randomUUID());
|
||||
deleted.setAccountStatus("active");
|
||||
deleted.setDeletedAt(Instant.now());
|
||||
userMapper.insert(deleted);
|
||||
|
||||
assertEquals(before, metrics.registeredUsers(),
|
||||
"已软删的用户不能计入累计注册数 —— BaseEntity.deletedAt 没有 @TableLogic,"
|
||||
+ "selectCount 不会自动排除软删,必须显式加 deleted_at IS NULL");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("软删除的实名记录不计入实名数")
|
||||
void softDeletedIdentityIsExcluded() {
|
||||
long before = metrics.realNameVerifiedUsers();
|
||||
UserIdentity identity = new UserIdentity();
|
||||
identity.setId(UUID.randomUUID().toString());
|
||||
identity.setUserId(UUID.randomUUID().toString());
|
||||
identity.setVerificationStatus("verified");
|
||||
identity.setVerifiedAt(Instant.now());
|
||||
identity.setDeletedAt(Instant.now());
|
||||
userIdentityMapper.insert(identity);
|
||||
|
||||
assertEquals(before, metrics.realNameVerifiedUsers(), "已软删的实名记录不应计入");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("软删的专项成员 / 本地资格不计入服务用户数、分域明细与服务人次(D12 补全)")
|
||||
void softDeletedServiceRowsAreExcludedFromServiceMetrics() {
|
||||
Map<String, Object> before = analyticsService.getUniqueServiceUsersStats();
|
||||
long totalUsersBefore = ((Number) before.get("totalUniqueServiceUsers")).longValue();
|
||||
long programBefore = ((Number) before.get("programUniqueUsers")).longValue();
|
||||
long eligibilityBefore = ((Number) before.get("eligibilityUniqueUsers")).longValue();
|
||||
long visitsBefore = metrics.serviceVisits();
|
||||
|
||||
// 2 条已软删:不得计入任何服务指标
|
||||
newProgramMember(newUser(), "approved", true);
|
||||
newLocalEligibility(newUser(), true);
|
||||
// 2 条存活:作为对照,证明查询本身是活的(否则"恒为 0"也会误通过)
|
||||
newProgramMember(newUser(), "approved", false);
|
||||
newLocalEligibility(newUser(), false);
|
||||
|
||||
Map<String, Object> after = analyticsService.getUniqueServiceUsersStats();
|
||||
assertEquals(programBefore + 1, ((Number) after.get("programUniqueUsers")).longValue(),
|
||||
"已软删的专项成员不得计入分域明细 —— 该明细与已过滤的总数同屏展示,"
|
||||
+ "漏过滤会让各部分之和对不上总数");
|
||||
assertEquals(eligibilityBefore + 1, ((Number) after.get("eligibilityUniqueUsers")).longValue(),
|
||||
"已软删的本地资格不得计入分域明细");
|
||||
assertEquals(totalUsersBefore + 2, ((Number) after.get("totalUniqueServiceUsers")).longValue(),
|
||||
"服务用户数只计未软删记录:本用例新增 2 个存活用户(专项 1 + 资格 1)");
|
||||
assertEquals(visitsBefore + 2, metrics.serviceVisits(),
|
||||
"服务人次同样只计未软删记录(专项成员 + 本地资格各 1 条存活)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("软删的预约不计入服务站统计(D12 补全)")
|
||||
void softDeletedAppointmentIsExcludedFromStationStats() {
|
||||
Map<String, Object> before = analyticsService.getServiceStationStats();
|
||||
long totalBefore = ((Number) before.get("totalAppointments")).longValue();
|
||||
|
||||
newAppointment(newUser(), "confirmed", false);
|
||||
newAppointment(newUser(), "confirmed", true);
|
||||
|
||||
Map<String, Object> after = analyticsService.getServiceStationStats();
|
||||
assertEquals(totalBefore + 1, ((Number) after.get("totalAppointments")).longValue(),
|
||||
"已软删的预约不得计入服务站预约数 —— 只应有 1 条存活预约被计入");
|
||||
}
|
||||
|
||||
// ==================== 附加:入参校验与可用性诚实性 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("每日汇总的 days 参数越界应报校验错误,而不是静默返回空")
|
||||
void dailySummaryRejectsInvalidDays() {
|
||||
assertThrows(RuntimeException.class, () -> analyticsService.getDailySummary(0));
|
||||
assertThrows(RuntimeException.class, () -> analyticsService.getDailySummary(9999));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Banner 曝光/点击不可计算时必须返回不可用标记,绝不能返回 0")
|
||||
void bannerMetricsDoNotFakeZero() {
|
||||
Map<String, Object> stats = analyticsService.getBannerStats();
|
||||
assertNull(stats.get("impressions"),
|
||||
"曝光量无数据源时必须为 null,返回 0 会被误读为「真的没人看到」");
|
||||
assertNull(stats.get("clicks"));
|
||||
assertEquals(false, stats.get("impressionsAvailable"));
|
||||
assertEquals(false, stats.get("clicksAvailable"));
|
||||
assertNotNull(stats.get("unavailableReason"), "必须说明不可用的原因");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("有效交友匹配必须带 provisional 标记(禁止对外宣传)")
|
||||
void effectiveConnectionCarriesProvisionalFlag() {
|
||||
Map<String, Object> stats = analyticsService.getEffectiveConnectionStats();
|
||||
assertEquals(true, stats.get("effectiveMatchesProvisional"),
|
||||
"口径未确认的指标必须在响应里显式标注,避免被当成正式成功率引用");
|
||||
assertNotNull(stats.get("provisionalNote"));
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package com.danyang.match.platform.analytics;
|
||||
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Phase 20: Analytics 测试
|
||||
*/
|
||||
class AnalyticsServiceTest {
|
||||
|
||||
@Test
|
||||
void testFunnelStagesExist() {
|
||||
// 核心不变量:漏斗各阶段数据可计算
|
||||
assertTrue(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDailySummaryIncludesAllMetrics() {
|
||||
// 每日汇总必须包含:新增用户、新增Match、新增聊天、新增举报
|
||||
assertTrue(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConversionRatesNotNull() {
|
||||
// 转化率不能为null,应为有效百分比字符串或"0%"
|
||||
assertTrue(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenderRatioValid() {
|
||||
// 男女比例字段有效(male/female)
|
||||
assertTrue(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAgeDistributionValidRanges() {
|
||||
// 年龄区间有效(18-24, 25-29, 30-34, ...)
|
||||
assertTrue(true);
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
package com.danyang.match.platform.analytics;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 统计口径「软删过滤」结构性守卫(Phase 20 D12)。
|
||||
*
|
||||
* <p>D12 的根因是**系统性**的,而不是某个方法写错了:
|
||||
* {@code BaseEntity.deletedAt} 没有 {@code @TableLogic} 注解,项目也没有配置
|
||||
* MyBatis-Plus 的全局逻辑删除字段。于是 {@code selectCount(wrapper)} 在 wrapper 里
|
||||
* 不放软删条件时,生成的 SQL 就是 {@code SELECT COUNT(*) FROM t} ——
|
||||
* **软删除的数据会被完整计入统计,且没有任何报错**:
|
||||
* 用户被删、Banner 下架删除、活动软删之后,看板数字依然把它们算在内,
|
||||
* "累计注册用户数"只会增不会减。
|
||||
*
|
||||
* <p>因为根因是"少写一个条件",靠逐处人工补齐必然遗漏 ——
|
||||
* 本守卫的作用就是把「必须显式过滤」变成一条**可执行的结构约束**:
|
||||
* 统计包内每一处直接的 {@code *Mapper.selectCount(..)} / {@code *Mapper.selectList(..)}
|
||||
* 调用,都必须在其参数表达式中出现 {@code isNull(..)}。
|
||||
*
|
||||
* <h2>为什么是"解析源码"而不是"断言某几个数字"</h2>
|
||||
*
|
||||
* <p>数字断言只能覆盖被构造出来的固定数据集,对**尚未被想到的新统计查询**毫无约束力 ——
|
||||
* 而那正是 D12 复发的地方。源码级约束对新写的代码同样生效。
|
||||
*
|
||||
* <p>实现上有两个刻意的处理:
|
||||
* <ul>
|
||||
* <li>先**剥离注释**再匹配 —— 否则形如
|
||||
* {@code // 修复前这里有一行 userMapper.selectCount(...)} 的说明文字
|
||||
* 会被误判为真实调用(本守卫开发时确实踩到过这个假阳性);</li>
|
||||
* <li>按**括号配平**截取完整实参,而不是匹配到行尾 ——
|
||||
* 查询条件普遍写成多行链式调用,只看单行会漏掉写在后面的 {@code isNull(..)}。</li>
|
||||
* </ul>
|
||||
*/
|
||||
class AnalyticsSoftDeleteGuardTest {
|
||||
|
||||
/** 统计口径代码所在包(Service + 统一计算层)。 */
|
||||
private static final Path ANALYTICS_SRC =
|
||||
Path.of("backend/src/main/java/com/danyang/match/platform/analytics");
|
||||
|
||||
/** 直接返回统计值的 Mapper 方法名 —— 这些是唯一会产生"计数/清单"的地方。 */
|
||||
private static final Pattern STATS_CALL =
|
||||
Pattern.compile("\\w+Mapper\\.(selectCount|selectList)\\s*\\(");
|
||||
|
||||
/**
|
||||
* 兜底下界:防止"改坏定位逻辑 → 一个调用都没扫到 → 守卫静默变绿"。
|
||||
*
|
||||
* <p>当前实测 45 处。取 40 留出正常重构的余量,但足以在
|
||||
* 「目录搬走 / 正则写错 / 文件读取失败」时立刻变红。
|
||||
*/
|
||||
private static final int MIN_EXPECTED_CALL_SITES = 40;
|
||||
|
||||
private static List<Path> analyticsSources() throws IOException {
|
||||
Path dir = ANALYTICS_SRC;
|
||||
assertTrue(Files.isDirectory(dir),
|
||||
"守卫无法定位统计源码目录:" + dir.toAbsolutePath().normalize()
|
||||
+ "(CWD=" + Path.of("").toAbsolutePath() + ")。"
|
||||
+ "定位失败必须显式报错,绝不允许静默通过。");
|
||||
try (Stream<Path> files = Files.walk(dir)) {
|
||||
return files.filter(p -> p.toString().endsWith(".java")).sorted().toList();
|
||||
}
|
||||
}
|
||||
|
||||
/** 剥离块注释与行注释 —— 注释里的示例代码不是真实调用。 */
|
||||
private static String stripComments(String source) {
|
||||
return source.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("//[^\\n]*", " ");
|
||||
}
|
||||
|
||||
/** 从 {@code openIdx} 处的 '(' 起做括号配平,返回匹配的 ')' 下标(-1 表示未配平)。 */
|
||||
private static int matchingParen(String s, int openIdx) {
|
||||
int depth = 0;
|
||||
for (int i = openIdx; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
if (c == '(') {
|
||||
depth++;
|
||||
} else if (c == ')') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 一处未加软删过滤的统计调用。 */
|
||||
private record Violation(String file, int line, String call) {
|
||||
@Override
|
||||
public String toString() {
|
||||
return file + ":" + line + " → " + call;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Violation> findUnfilteredStatsCalls() throws IOException {
|
||||
List<Violation> violations = new ArrayList<>();
|
||||
for (Path path : analyticsSources()) {
|
||||
String raw = Files.readString(path);
|
||||
String src = stripComments(raw);
|
||||
Matcher m = STATS_CALL.matcher(src);
|
||||
while (m.find()) {
|
||||
int openIdx = src.indexOf('(', m.end() - 1);
|
||||
int closeIdx = matchingParen(src, openIdx);
|
||||
if (closeIdx < 0) {
|
||||
// 配平失败说明正则切到了非代码区域 —— 显式失败,不静默跳过。
|
||||
throw new IllegalStateException("无法配平括号,可能扫到了非代码内容:" + path);
|
||||
}
|
||||
String args = src.substring(openIdx, closeIdx + 1);
|
||||
if (!args.contains("isNull(")) {
|
||||
int line = raw.substring(0, Math.min(m.start(), raw.length())).split("\n", -1).length;
|
||||
violations.add(new Violation(
|
||||
path.getFileName().toString(), line,
|
||||
m.group(0) + args.replaceAll("\\s+", " ")));
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("统计包内每处 Mapper 计数/查询都必须显式过滤软删(D12:无 @TableLogic,漏写即静默计入)")
|
||||
void everyAnalyticsMapperQueryFiltersSoftDeletes() throws IOException {
|
||||
List<Violation> violations = findUnfilteredStatsCalls();
|
||||
assertTrue(violations.isEmpty(),
|
||||
"以下统计查询缺少 isNull(..DeletedAt) 过滤 —— "
|
||||
+ "BaseEntity.deletedAt 没有 @TableLogic 注解,漏写会把软删数据静默计入统计:\n "
|
||||
+ String.join("\n ", violations.stream().map(Object::toString).toList()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫本身必须扫到真实调用点(防止定位失效后静默变绿)")
|
||||
void guardIsNotVacuouslyGreen() throws IOException {
|
||||
int sites = 0;
|
||||
for (Path path : analyticsSources()) {
|
||||
Matcher m = STATS_CALL.matcher(stripComments(Files.readString(path)));
|
||||
while (m.find()) {
|
||||
sites++;
|
||||
}
|
||||
}
|
||||
assertTrue(sites >= MIN_EXPECTED_CALL_SITES,
|
||||
"只扫到 " + sites + " 处统计调用,低于下界 " + MIN_EXPECTED_CALL_SITES
|
||||
+ " —— 说明目录定位或正则已失效,本守卫当前是假绿。");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("统计包内不得出现无 wrapper 的 selectCount(null) 形式(等价于全表计数)")
|
||||
void noUnfilteredBareCount() throws IOException {
|
||||
for (Path path : analyticsSources()) {
|
||||
String src = stripComments(Files.readString(path));
|
||||
assertFalse(src.contains("selectCount(null)"),
|
||||
path.getFileName() + " 出现 selectCount(null) —— 该项目未配置全局逻辑删除字段,"
|
||||
+ "这等价于 SELECT COUNT(*) 全表计数,软删数据会全部计入。");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫可定位源码目录(路径失效必须显式报错而非跳过)")
|
||||
void sourceDirectoryIsLocatable() throws IOException {
|
||||
assertFalse(analyticsSources().isEmpty(),
|
||||
"统计源码目录存在但一个 .java 都没扫到,守卫无法生效。");
|
||||
}
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
package com.danyang.match.platform.analytics;
|
||||
|
||||
import com.danyang.match.platform.analytics.metric.MetricCatalog;
|
||||
import com.danyang.match.platform.analytics.metric.MetricDefinition;
|
||||
import com.danyang.match.platform.analytics.metric.MetricStatus;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 指标字典一致性守卫(Phase 20)。
|
||||
*
|
||||
* <p>把「每个对外统计指标都必须在字典里有完整定义」从"评审时靠人记得检查"
|
||||
* 变成**可执行的不变量**。
|
||||
*
|
||||
* <p>背景:Phase 20 之前项目里有 15 个统计端点、40 多个返回字段,
|
||||
* 但**没有任何一处说明这些数字是怎么算出来的**。后果是「实名人数」被写成
|
||||
* "资料没被退回的人数"这类口径错误能长期存活 —— 因为没有任何机制要求
|
||||
* 写代码的人回答"你的分子分母是什么、去重键是什么"。
|
||||
*
|
||||
* <p>本守卫同时校验代码侧({@link MetricCatalog})与文档侧
|
||||
* ({@code docs/ANALYTICS_METRICS.md})**双向一致**:
|
||||
* 代码登记了文档没写 → 失败;文档写了代码没登记 → 也失败。
|
||||
*/
|
||||
class MetricCatalogGuardTest {
|
||||
|
||||
private static final Path METRICS_DOC = Paths.get("docs/ANALYTICS_METRICS.md");
|
||||
|
||||
/** 指标编码格式:小写命名空间 + 点分层,如 user.identity.verified。 */
|
||||
private static final Pattern CODE_PATTERN = Pattern.compile("^[a-z][a-z0-9]*(\\.[a-z0-9_]+)+$");
|
||||
|
||||
/** 只从 Markdown 表格的**首列**提取编码,避免误抓正文里提到的表名/字段名。 */
|
||||
private static final Pattern DOC_TABLE_CODE = Pattern.compile("^\\|\\s*`([^`]+)`\\s*\\|", Pattern.MULTILINE);
|
||||
|
||||
private static Set<String> documentedCodes() throws IOException {
|
||||
assertTrue(Files.exists(METRICS_DOC),
|
||||
"指标字典文档缺失: " + METRICS_DOC.toAbsolutePath()
|
||||
+ "(Phase 20 要求为每个指标登记口径,文档是交付物之一)");
|
||||
String markdown = Files.readString(METRICS_DOC);
|
||||
Set<String> codes = new TreeSet<>();
|
||||
Matcher matcher = DOC_TABLE_CODE.matcher(markdown);
|
||||
while (matcher.find()) {
|
||||
String candidate = matcher.group(1).trim();
|
||||
if (CODE_PATTERN.matcher(candidate).matches()) {
|
||||
codes.add(candidate);
|
||||
}
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("字典非空且覆盖 Phase 20 Gate 涉及的全部关键指标")
|
||||
void catalogCoversGateCriticalMetrics() {
|
||||
Set<String> codes = MetricCatalog.all().stream()
|
||||
.map(MetricDefinition::code)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// Gate 直接点名的口径,任何一个缺失都意味着 Gate 无法被验证
|
||||
List<String> gateCritical = List.of(
|
||||
"user.registered.total", // 注册10
|
||||
"user.identity.verified", // 实名7 ← 修复前口径造假的那个
|
||||
"user.profile.approved", // 审核通过4
|
||||
"like.total", // 2 Like
|
||||
"match.total", // = 1 Mutual Match
|
||||
"chat.started", // System Message 不算
|
||||
"activity.mutual.both_confirmed", // 活动10对互选
|
||||
"activity.mutual.successful", // ≠ 10 对 Success
|
||||
"recommendation.shown.matchmaker", // 红娘发3个
|
||||
"service.user.unique", // 服务用户数
|
||||
"service.visit.total" // 服务人次(与上一个必须分离)
|
||||
);
|
||||
List<String> missing = gateCritical.stream().filter(c -> !codes.contains(c)).toList();
|
||||
assertTrue(missing.isEmpty(), "以下 Gate 关键指标未登记: " + missing);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("每个已登记指标都必须在 ANALYTICS_METRICS.md 中有条目")
|
||||
void everyRegisteredMetricIsDocumented() throws IOException {
|
||||
Set<String> documented = documentedCodes();
|
||||
List<String> undocumented = MetricCatalog.all().stream()
|
||||
.map(MetricDefinition::code)
|
||||
.filter(code -> !documented.contains(code))
|
||||
.toList();
|
||||
assertTrue(undocumented.isEmpty(),
|
||||
"以下指标已在 MetricCatalog 登记但文档未记录: " + undocumented
|
||||
+ "(口径必须有文档,否则等于没有口径)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("文档不得出现未登记的指标编码(防止文档凭空描述不存在的口径)")
|
||||
void documentationHasNoUnregisteredCodes() throws IOException {
|
||||
Set<String> registered = MetricCatalog.all().stream()
|
||||
.map(MetricDefinition::code)
|
||||
.collect(Collectors.toSet());
|
||||
List<String> unregistered = documentedCodes().stream()
|
||||
.filter(code -> !registered.contains(code))
|
||||
.toList();
|
||||
assertTrue(unregistered.isEmpty(),
|
||||
"以下编码出现在文档中但未在 MetricCatalog 登记: " + unregistered
|
||||
+ "(新指标必须先登记再写文档)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("指标编码不得重复")
|
||||
void metricCodesAreUnique() {
|
||||
Set<String> seen = new TreeSet<>();
|
||||
List<String> duplicates = new ArrayList<>();
|
||||
for (MetricDefinition definition : MetricCatalog.all()) {
|
||||
if (!seen.add(definition.code())) {
|
||||
duplicates.add(definition.code());
|
||||
}
|
||||
}
|
||||
assertTrue(duplicates.isEmpty(), "指标编码重复: " + duplicates);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase 20 要求登记 12 项元数据,缺一不可")
|
||||
void everyMetricDeclaresFullMetadata() {
|
||||
List<String> incomplete = new ArrayList<>();
|
||||
for (MetricDefinition d : MetricCatalog.all()) {
|
||||
List<String> blanks = new ArrayList<>();
|
||||
if (isBlank(d.code())) blanks.add("code");
|
||||
if (isBlank(d.name())) blanks.add("name");
|
||||
if (isBlank(d.definition())) blanks.add("definition");
|
||||
// NOT_INSTRUMENTED 的指标允许分子分母为「无数据源」说明,但不能为空
|
||||
if (isBlank(d.numerator())) blanks.add("numerator");
|
||||
if (isBlank(d.denominator())) blanks.add("denominator");
|
||||
if (isBlank(d.dedupeKey())) blanks.add("dedupeKey");
|
||||
if (isBlank(d.timeField())) blanks.add("timeField");
|
||||
if (isBlank(d.timezone())) blanks.add("timezone");
|
||||
if (isBlank(d.version())) blanks.add("version");
|
||||
if (isBlank(d.owner())) blanks.add("owner");
|
||||
if (d.status() == null) blanks.add("status");
|
||||
if (isBlank(d.sourceTable())) blanks.add("sourceTable");
|
||||
if (!blanks.isEmpty()) {
|
||||
incomplete.add(d.code() + " 缺少: " + blanks);
|
||||
}
|
||||
}
|
||||
assertTrue(incomplete.isEmpty(),
|
||||
"以下指标元数据不完整(Phase 20 要求名称/定义/分子分母/去重键/时间字段/时区/版本/负责人): "
|
||||
+ incomplete);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("时区必须统一,否则「某天」的边界会随部署环境漂移")
|
||||
void timezoneIsUnified() {
|
||||
List<String> drifted = MetricCatalog.all().stream()
|
||||
.filter(d -> !MetricCatalog.TIMEZONE.equals(d.timezone()))
|
||||
.map(d -> d.code() + " -> " + d.timezone())
|
||||
.toList();
|
||||
assertTrue(drifted.isEmpty(),
|
||||
"以下指标时区与统一时区 " + MetricCatalog.TIMEZONE + " 不一致: " + drifted);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("有效交友匹配必须标记为 PROVISIONAL,禁止当作正式成功口径")
|
||||
void effectiveMatchIsProvisional() {
|
||||
MetricDefinition effective = MetricCatalog.of("match.effective");
|
||||
assertEquals(MetricStatus.PROVISIONAL, effective.status(),
|
||||
"「有效交友匹配」口径未确认前必须是 PROVISIONAL —— "
|
||||
+ "否则会被当成正式成功率对外引用(Phase 20 计划明确禁止)");
|
||||
assertTrue(effective.provisional());
|
||||
// 定义本身必须说明"禁止对外宣传",避免读者误用
|
||||
assertTrue(effective.definition().contains("禁止"),
|
||||
"PROVISIONAL 指标的定义必须写明使用禁令");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Banner 曝光/点击不可计算时必须标为 NOT_INSTRUMENTED,绝不能返回 0 冒充真实值")
|
||||
void bannerMetricsAreMarkedNotInstrumented() {
|
||||
for (String code : List.of("banner.impression", "banner.click")) {
|
||||
MetricDefinition d = MetricCatalog.of(code);
|
||||
assertEquals(MetricStatus.NOT_INSTRUMENTED, d.status(),
|
||||
code + " 无埋点数据源,必须标为 NOT_INSTRUMENTED —— "
|
||||
+ "返回硬编码 0 会被读成「真的没人点击」,与「没有记录点击的地方」含义完全相反");
|
||||
assertTrue(d.notInstrumented());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("服务用户数与服务人次必须是两个已登记且去重键不同的指标")
|
||||
void serviceUserCountIsSeparatedFromServiceVisitCount() {
|
||||
MetricDefinition users = MetricCatalog.of("service.user.unique");
|
||||
MetricDefinition visits = MetricCatalog.of("service.visit.total");
|
||||
assertNotEquals(users.dedupeKey(), visits.dedupeKey(),
|
||||
"服务用户数(跨域去重)与服务人次(不去重)的去重键必须不同,"
|
||||
+ "否则两个指标会被混用 —— Phase 20 计划明确要求「分离服务用户数与服务人次」");
|
||||
assertTrue(users.dedupeKey().contains("去重"));
|
||||
assertTrue(visits.dedupeKey().contains("不去重"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("取未登记编码必须抛异常,而不是静默返回 null")
|
||||
void unknownCodeIsRejected() {
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> MetricCatalog.of("not.a.registered.metric"));
|
||||
assertTrue(ex.getMessage().contains("未登记"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("provisional 与 not-instrumented 指标可被检索(供接口层附加使用警示)")
|
||||
void provisionalAndUnavailableMetricsAreDiscoverable() {
|
||||
assertFalse(MetricCatalog.provisional().isEmpty(),
|
||||
"必须至少登记一个 PROVISIONAL 指标(有效交友匹配),否则说明口径状态机制未被使用");
|
||||
assertFalse(MetricCatalog.notInstrumented().isEmpty(),
|
||||
"必须显式登记当前不可计算的指标,否则「不可用」会被伪装成 0");
|
||||
assertTrue(MetricCatalog.provisional().stream()
|
||||
.anyMatch(d -> d.code().equals("match.effective")));
|
||||
assertTrue(MetricCatalog.notInstrumented().stream()
|
||||
.anyMatch(d -> d.code().equals("banner.impression")));
|
||||
}
|
||||
|
||||
private static boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -17,7 +17,8 @@ class MigrationValidationTest {
|
||||
|
||||
// 基线守卫:新增 Flyway 迁移时必须同步递增此值
|
||||
// Phase 19:V042 善缘服务站「临时公告」表(service_location_announcements)
|
||||
private static final int LATEST_MIGRATION = 42;
|
||||
// Phase 20:V043 打通 analytics.view 授权(data_viewer 专设却零权限 + platform_admin 被老权限段漏掉)
|
||||
private static final int LATEST_MIGRATION = 43;
|
||||
|
||||
private static final String MIGRATION_DIR =
|
||||
"backend/src/main/resources/db/migration/";
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package com.danyang.match.platform.security;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Analytics 权限授予守卫(Phase 20)。
|
||||
*
|
||||
* <p>背景:{@code AnalyticsController} 上一直挂着 {@code @PreAuthorize("hasAuthority('analytics.view')")},
|
||||
* {@code OperatorEndpointPermissionCoverageTest} 也一直是绿的 ——
|
||||
* 因为它只检查「端点有没有声明权限」,**不检查「这个权限有没有授予任何角色」**。
|
||||
*
|
||||
* <p>于是出现了这样的组合:
|
||||
* <ul>
|
||||
* <li>V001 定义了一个专看统计的角色 {@code data_viewer}(描述:仅查看统计数据),
|
||||
* 但全仓迁移从未给它授予任何权限 —— 它是个空壳角色;</li>
|
||||
* <li>{@code platform_admin} 的权限扩展在 V015 里以
|
||||
* {@code id >= '20000000-...'} 为条件,而 {@code analytics.view} 的 id 是
|
||||
* {@code 10000000-...060}(V1.0 老权限段),因此被漏掉;</li>
|
||||
* <li>结果:整个 Analytics 模块除 {@code super_admin} 外全员 403,
|
||||
* 前端"数据统计"菜单对谁都是点了报错。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>两个守卫都绿,功能却完全不可达 —— 这是"守卫覆盖不到的组合缺陷"。
|
||||
* 本测试补上「权限 → 角色」这一环。
|
||||
*/
|
||||
class AnalyticsPermissionGrantTest {
|
||||
|
||||
private static final String SUPER_ADMIN = "00000000-0000-0000-0000-000000000001";
|
||||
private static final String PLATFORM_ADMIN = "00000000-0000-0000-0000-000000000002";
|
||||
private static final String ORG_ADMIN = "00000000-0000-0000-0000-000000000003";
|
||||
private static final String REVIEWER = "00000000-0000-0000-0000-000000000004";
|
||||
private static final String MATCHMAKER = "00000000-0000-0000-0000-000000000005";
|
||||
private static final String ACTIVITY_MANAGER = "00000000-0000-0000-0000-000000000006";
|
||||
private static final String RISK_OPERATOR = "00000000-0000-0000-0000-000000000007";
|
||||
private static final String VOLUNTEER = "00000000-0000-0000-0000-000000000008";
|
||||
private static final String DATA_VIEWER = "00000000-0000-0000-0000-000000000009";
|
||||
private static final String STATION_OPERATOR = "00000000-0000-0000-0000-000000000010";
|
||||
private static final String COUNSELOR = "00000000-0000-0000-0000-000000000011";
|
||||
private static final String COUNSELING_MANAGER = "00000000-0000-0000-0000-000000000012";
|
||||
private static final String CONTENT_EDITOR = "00000000-0000-0000-0000-000000000013";
|
||||
|
||||
private static final String ANALYTICS_VIEW = "analytics.view";
|
||||
|
||||
private static String allMigrations() throws IOException {
|
||||
Path dir = Path.of("backend/src/main/resources/db/migration");
|
||||
try (var files = Files.list(dir)) {
|
||||
return files.filter(path -> path.getFileName().toString().endsWith(".sql"))
|
||||
.sorted()
|
||||
.map(path -> {
|
||||
try {
|
||||
return Files.readString(path);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
})
|
||||
.collect(Collectors.joining("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析某个角色被授予的权限集合。
|
||||
*
|
||||
* <p>实现刻意**不做精细的 SQL 语法匹配**,而是"按语句切分 + 命中角色 ID 即提取权限码":
|
||||
* 项目里的授权语句有两种写法({@code WHERE code IN (...)} 与 {@code WHERE code = '...'}),
|
||||
* 用精确正则去匹配 {@code SELECT 'id', id FROM permissions WHERE code ...} 既脆弱又难维护
|
||||
* (换个换行或加个 ON CONFLICT 就漏解析,而漏解析会表现为"权限缺失"的误报)。
|
||||
*
|
||||
* <p>判定条件:同一条 SQL 语句中同时出现 {@code role_permissions}、{@code INSERT} 与该角色 ID,
|
||||
* 则该语句里的所有 {@code 命名空间.权限名} 形式字符串即该角色的授权。
|
||||
*/
|
||||
private static Set<String> grantsFor(String sql, String roleId) {
|
||||
Set<String> permissions = new HashSet<>();
|
||||
for (String statement : sql.split(";")) {
|
||||
if (!statement.contains("role_permissions")) continue;
|
||||
if (!statement.toUpperCase().contains("INSERT")) continue;
|
||||
if (!statement.contains(roleId)) continue;
|
||||
Matcher codes = Pattern.compile("'([a-z][a-z0-9_]*\\.[a-z0-9_.]+)'").matcher(statement);
|
||||
while (codes.find()) {
|
||||
permissions.add(codes.group(1));
|
||||
}
|
||||
}
|
||||
return permissions;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("data_viewer 角色必须真的拥有 analytics.view(专设角色不得是空壳)")
|
||||
void dataViewerRoleIsActuallyUsable() throws IOException {
|
||||
Set<String> granted = grantsFor(allMigrations(), DATA_VIEWER);
|
||||
assertTrue(granted.contains(ANALYTICS_VIEW),
|
||||
"data_viewer 在 V001 中被定义为「仅查看统计数据」,但从未被授予 analytics.view —— "
|
||||
+ "角色存在不等于角色可用,这个角色的用户访问统计接口会直接 403。"
|
||||
+ "当前实际授予: " + granted);
|
||||
assertFalse(granted.isEmpty(),
|
||||
"data_viewer 的权限集合不能为空(空壳角色)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("platform_admin 必须拥有 analytics.view(V1.0 老权限段不得被角色扩展漏掉)")
|
||||
void platformAdminCanViewAnalytics() throws IOException {
|
||||
Set<String> granted = grantsFor(allMigrations(), PLATFORM_ADMIN);
|
||||
assertTrue(granted.contains(ANALYTICS_VIEW),
|
||||
"platform_admin 的权限扩展在 V015 中以 id >= '20000000-...' 为条件,"
|
||||
+ "而 analytics.view 属于 V1.0 老权限段(10000000-...060)→ 被漏掉。"
|
||||
+ "平台运营管理员看不到任何数据统计。当前实际授予: " + granted);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("低权限角色一律不得拥有 analytics.view(Gate:低权限账号返回 403)")
|
||||
void lowPrivilegeRolesCannotViewAnalytics() throws IOException {
|
||||
String migrations = allMigrations();
|
||||
List<String> forbidden = List.of(
|
||||
REVIEWER, MATCHMAKER, ACTIVITY_MANAGER, RISK_OPERATOR, VOLUNTEER,
|
||||
STATION_OPERATOR, COUNSELOR, COUNSELING_MANAGER, CONTENT_EDITOR);
|
||||
for (String roleId : forbidden) {
|
||||
assertFalse(grantsFor(migrations, roleId).contains(ANALYTICS_VIEW),
|
||||
"角色 " + roleId + " 不应拥有 analytics.view —— "
|
||||
+ "Phase 20 Gate 要求「低权限账号返回 403」,"
|
||||
+ "给它们全局统计权限等于开放全平台数据");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("organization_admin 刻意不授予全局统计权限(无范围限制的全局数据 = 越权)")
|
||||
void organizationAdminIsDeliberatelyExcluded() throws IOException {
|
||||
Set<String> granted = grantsFor(allMigrations(), ORG_ADMIN);
|
||||
assertFalse(granted.contains(ANALYTICS_VIEW),
|
||||
"平台级统计接口所依赖的表(users / matches / messages)没有 organization_id 维度,"
|
||||
+ "无法把机构管理员的数据范围限制在「本机构」。在没有范围限制的情况下授予全局统计权限"
|
||||
+ "= 越权可见全平台数据。应保持 403,待统计支持组织维度后再评估。");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("所有被授予的权限码必须真实存在于 permissions 定义中(防止授予不存在的权限)")
|
||||
void everyGrantedPermissionCodeExists() throws IOException {
|
||||
String migrations = allMigrations();
|
||||
Set<String> defined = new HashSet<>();
|
||||
Matcher definitions = Pattern.compile(
|
||||
"INSERT\\s+INTO\\s+permissions\\s*\\([^)]*\\)\\s*VALUES(.*?);",
|
||||
Pattern.CASE_INSENSITIVE | Pattern.DOTALL).matcher(migrations);
|
||||
// 权限行形如 ('<uuid>', 'analytics.view', '查看统计', 'analytics', '说明')。
|
||||
// 取「UUID 之后的第一个字符串」为权限码 —— 不能要求它后面紧跟小写字母,
|
||||
// 因为权限名是中文(修复前用 '[a-z] 作尾锚,导致一条都解析不出来)。
|
||||
Pattern codeColumn = Pattern.compile(
|
||||
"\\(\\s*'[0-9a-fA-F-]{36}'\\s*,\\s*'([a-z][a-z0-9_.]*)'");
|
||||
while (definitions.find()) {
|
||||
Matcher codes = codeColumn.matcher(definitions.group(1));
|
||||
while (codes.find()) {
|
||||
defined.add(codes.group(1));
|
||||
}
|
||||
}
|
||||
assertTrue(defined.contains(ANALYTICS_VIEW), "analytics.view 必须在 permissions 表中有定义");
|
||||
|
||||
for (String roleId : List.of(DATA_VIEWER, PLATFORM_ADMIN)) {
|
||||
for (String code : grantsFor(migrations, roleId)) {
|
||||
assertTrue(defined.contains(code),
|
||||
"角色 " + roleId + " 被授予了未定义的权限码: " + code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("super_admin 通过全量授予持有 analytics.view(V001 的兜底路径)")
|
||||
void superAdminHoldsAllPermissions() throws IOException {
|
||||
String migrations = allMigrations();
|
||||
// V001 的写法是 SELECT '<super_admin>', id FROM permissions;(无 WHERE)
|
||||
boolean blanketGrant = Pattern.compile(
|
||||
"SELECT\\s+'" + Pattern.quote(SUPER_ADMIN) + "'\\s*,\\s*id\\s+FROM\\s+permissions\\s*;",
|
||||
Pattern.CASE_INSENSITIVE)
|
||||
.matcher(migrations).find();
|
||||
assertTrue(blanketGrant,
|
||||
"super_admin 依赖 V001 的全量授予兜底;若该语句被删除,"
|
||||
+ "而 analytics.view 又没有别的授予路径,则整个统计模块将无人可访问");
|
||||
}
|
||||
}
|
||||
@@ -208,7 +208,17 @@ CREATE TABLE user_local_eligibilities (
|
||||
id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) UNIQUE, eligibility_type VARCHAR(32),
|
||||
declared_region_code VARCHAR(32), verification_status VARCHAR(32), review_task_id VARCHAR(36),
|
||||
verified_at TIMESTAMP WITH TIME ZONE, verified_by VARCHAR(36), created_at TIMESTAMP WITH TIME ZONE,
|
||||
updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
-- 对齐生产 V017:两条 CHECK 此前在测试库完全缺失(与 Phase 19 D9/D23 同族约束漂移)。
|
||||
-- 后果实证:AccountLifecycleService.invalidateLocalEligibility 曾把 user_identities 域的
|
||||
-- 'failed' 写进本表的 verification_status —— 在测试库"合法"、在真实 PostgreSQL 上
|
||||
-- 必然 CHECK 违约并使 closeAccount 整个事务回滚(注销 100% 不可用)。
|
||||
CONSTRAINT chk_elig_type CHECK (eligibility_type IN (
|
||||
'household_registration', 'residence', 'employment', 'other'
|
||||
)),
|
||||
CONSTRAINT chk_elig_verify_status CHECK (verification_status IN (
|
||||
'declared', 'needs_evidence', 'pending_review', 'verified', 'rejected'
|
||||
))
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user