fix(counseling): close Phase 13A KEEP-VERIFY/EXTEND — 9 real defects + 4 test-infra defects
Phase 13A(婚恋咨询与婚前辅导)复验关闭。历史 ✅ 不可信:counseling 模块存在双实现,
线上权威 CounselingService(167 行)零测试,唯一被测的 CounselingServiceApp(280 行)
是孤儿类(无注入点、无 HTTP 端点可达,生产永不执行)→ 已合并并删除。
生产缺陷(D1~D9):
- D1 Critical 生产故障 · INV-013 泄露:咨询正文 userRequestSummary 原文被写入 WARN 日志
(同 Phase 10 D2 一族)→ 改为只记 serviceId + riskLevel,绝不记正文。
- D2 High 越权:申请 userId 取自请求体而非认证主体 → Controller 强制 principal.id()。
- D3 High 并发:分配用「先读后写」→ 两管理员并发分配静默覆盖;
改 assignIfPending 条件更新(WHERE status='pending' AND assigned_operator_id IS NULL),
affectedRows=0 明确报错。
- D4 High 越权读:counseling_manager 经 getDetail 可读 followups 正文 → INV-013 绕过通道;
manager 只加回 userRequestSummary,不装配 followups。
- D5 Medium:无状态机 → 「已完成」可被再取消;新增 ALLOWED_TRANSITIONS + assertTransition。
- D6 Medium 生产故障:GET /counseling/services/intro 后端不存在(且被 /services/{id} 吞掉)
→ 小程序 intro 页 100% 无数据;补端点并声明在变量路径之前。
- D7 Medium 契约:Admin 调用 5 条不存在的 /services/operator/*(404)+ 缺四态
→ 后端补 8 个运营端端点(@PreAuthorize),前端整体重写 + 注册 /counseling 路由。
- D8 Medium 契约:小程序字段名错(userRequestSummary/preferredServiceMode)+ 无幂等
+ my-counseling 未检查 success(403 静默空列表)+ 跳转不存在的页 + WXML 内调用方法。
- D9 Medium 测试保真度:测试库 counseling 两表零 CHECK 约束,
counseling_services 未列入 EntityMigrationParityTest 任何集合(= 零覆盖)。
测试基建缺陷(T1~T4,不修即恒真假绿):
- T1 守卫找不到 DDL:surefire workingDirectory = ${project.basedir}/.. 使 CWD 为仓库根,
且 backend/ 是子目录非祖先 → 改为候选后缀 + 逐级向上定位,失败大声抛异常。
- T2 全限定 @com.baomidou...TableField(exist=false) 漏解析(假漂移警报)→ 正则放宽 + 回归守卫。
- T3 OperatorEndpointPermissionCoverageTest 扫到私有辅助方法(假阳性)→ 只扫描真实端点 + 自检。
- T4 两处测试自身逻辑缺陷:陈旧内存快照比较、Instant 纳秒 vs DB 微秒精度。
迁移:新增 V039(counseling 幂等键 + 三个 CHECK 白名单显式固化),基线 38 → 39。
负向验证两轮(均已还原,grep -c NEG-VAL = 0):
- ① DDL 常量守卫:从 V039 移除 'scheduled' → 常量守卫精准变红
(证明解析的是真实 DDL 而非副本)。
- ② 行为守卫:注入「并发回退先读后写 / manager 读 followups / 状态机守卫失效」
→ 三个用例各自精准变红。
测试:新增 CounselingPhase13ATest(25)+ CounselingConstraintGuardTest(5,解析真实 DDL);
删除孤儿类及其 3 个 DTO、删除测死代码的 CounselingDataScopeTest。
全量回归:Tests run: 420, Failures: 0, Errors: 0, Skipped: 3 / BUILD SUCCESS(4:49)。
基线 391 → 420(净 +29)。admin-web vue-tsc EXIT=0;miniapp tsc EXIT=0。
Tag: phase13a-recovery-2026-09-17
Report: docs/Phase-13A-V11-Recovery-Completion-Report-2026-09-17.md
This commit is contained in:
+255
@@ -0,0 +1,255 @@
|
||||
package com.danyang.match.platform.counseling;
|
||||
|
||||
import com.danyang.match.platform.counseling.service.CounselingService;
|
||||
import org.junit.jupiter.api.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.regex.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Phase 13A:counseling 模块「代码常量 ⊆ 生产 CHECK 白名单」守卫。
|
||||
*
|
||||
* <p><b>核心纪律(Phase 13 确立的第四层测试保真度)</b>:
|
||||
* 守卫必须**直接解析 {@code db/migration/V*.sql} 原文**,绝不能在测试里抄一份白名单常量。
|
||||
* 抄副本的守卫只证明"今天抄的和昨天抄的一样",对 DDL 漂移**零反应** ——
|
||||
* Phase 13 首版就是这么写的,注入真实缺陷后依然全绿,属于**永不失效的假守卫**。
|
||||
*
|
||||
* <p>解析规则:约束扩展在本项目是既有惯例(V018/V020/V025/V038/V039),
|
||||
* 一律 {@code DROP CONSTRAINT IF EXISTS} + {@code ADD CONSTRAINT}。
|
||||
* 因此解析器**必须按文件名顺序**依次应用这两条语句,后者覆盖前者,
|
||||
* 最终得到的那份才是"最终有效"白名单。
|
||||
*
|
||||
* <p>每个守卫都配一个**解析器自检**用例:若解析失效(返回 null/空),
|
||||
* 守卫会立刻失败,而不是"因为没解析到所以断言恒真"。
|
||||
*/
|
||||
class CounselingConstraintGuardTest {
|
||||
|
||||
/**
|
||||
* 迁移目录可能出现的候选相对后缀(相对 CWD)。
|
||||
*
|
||||
* <p><b>踩坑记录(Phase 13A 实测)</b>:本类首版直接写死
|
||||
* {@code Path.of("src/main/resources/db/migration")},结果 5 个用例**全部因解析不到约束而失败**。
|
||||
* 根因是 {@code pom.xml} 里 surefire 显式配置了
|
||||
* {@code <workingDirectory>${project.basedir}/..</workingDirectory>} ——
|
||||
* 测试进程的 CWD 是**仓库根**({@code 公益婚恋服务平台/})而**不是** {@code backend/}。
|
||||
*
|
||||
* <p>更隐蔽的是:{@code backend/} 是 CWD 的**子目录**而非祖先,
|
||||
* 所以"只向上回溯找 {@code src/...}"也找不到(实测报错 CWD=/proj)。
|
||||
* 必须同时尝试 {@code backend/src/...}。
|
||||
*
|
||||
* <p>教训:守卫类测试**绝不能假定 CWD**;定位失败要**大声失败**,
|
||||
* 绝不能静默返回 null 让守卫变成"恒真假绿"。
|
||||
*/
|
||||
private static final List<String> MIGRATION_CANDIDATES = List.of(
|
||||
"src/main/resources/db/migration",
|
||||
"backend/src/main/resources/db/migration"
|
||||
);
|
||||
|
||||
/** 按文件名顺序解析出的「约束名 → 最终有效取值集合」缓存 */
|
||||
private static final Map<String, Set<String>> CACHE = new HashMap<>();
|
||||
|
||||
/**
|
||||
* 定位 {@code db/migration} 目录:从 CWD 起逐级向上,每一级都尝试
|
||||
* {@link #MIGRATION_CANDIDATES} 里的所有候选后缀。
|
||||
*/
|
||||
private static Path locateMigrationDir() {
|
||||
Path cur = Path.of("").toAbsolutePath().normalize();
|
||||
List<String> tried = new ArrayList<>();
|
||||
for (int i = 0; i < 6 && cur != null; i++) {
|
||||
for (String rel : MIGRATION_CANDIDATES) {
|
||||
Path candidate = cur.resolve(rel);
|
||||
tried.add(candidate.toString());
|
||||
if (Files.isDirectory(candidate)) return candidate;
|
||||
}
|
||||
cur = cur.getParent();
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"守卫无法定位迁移目录;CWD=" + Path.of("").toAbsolutePath()
|
||||
+ ";已尝试:\n - " + String.join("\n - ", tried)
|
||||
+ "\n守卫必须解析**真实** DDL 原文,定位失败即视为测试基建错误,不得跳过。");
|
||||
}
|
||||
|
||||
// ==================== 解析器自检 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("解析器自检: 必须真的解析到 counseling 相关约束(防止守卫因解析失效而恒真)")
|
||||
void parserMustResolveCounselingConstraints() {
|
||||
// 先证明"确实读到了迁移目录、且读到了足够多的文件"——
|
||||
// 否则"解析不到约束"到底是 DDL 真没有、还是路径没找对,无法区分。
|
||||
Path dir = locateMigrationDir();
|
||||
long fileCount;
|
||||
try (Stream<Path> s = Files.list(dir)) {
|
||||
fileCount = s.filter(p -> p.getFileName().toString().matches("V\\d+__.*\\.sql")).count();
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("列目录失败: " + dir, e);
|
||||
}
|
||||
assertTrue(fileCount >= 30,
|
||||
"解析到的迁移文件过少(" + fileCount + "),目录可能选错了: " + dir);
|
||||
|
||||
for (String c : List.of("chk_counseling_service_type", "chk_counseling_status", "chk_request_source")) {
|
||||
Set<String> vals = resolveCheckValues(c);
|
||||
assertNotNull(vals, "解析器失效:未解析到约束 " + c + "(dir=" + dir + ", files=" + fileCount + ")");
|
||||
assertFalse(vals.isEmpty(), "解析器失效:约束 " + c + " 白名单为空");
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取生产白名单;为 null 时抛出可读的失败信息,避免下游 NPE 掩盖真实原因。 */
|
||||
private static Set<String> requireWhitelist(String constraintName) {
|
||||
Set<String> allowed = resolveCheckValues(constraintName);
|
||||
assertNotNull(allowed,
|
||||
"守卫失效:未从生产迁移解析到 " + constraintName
|
||||
+ "(迁移目录=" + locateMigrationDir() + ")。"
|
||||
+ "若该约束确实不存在,说明 DDL 丢了硬约束,应作为缺陷修复而不是放宽守卫。");
|
||||
return allowed;
|
||||
}
|
||||
|
||||
// ==================== 守卫 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫: CounselingService 的 service_type 常量 ⊆ 生产 chk_counseling_service_type 白名单")
|
||||
void serviceTypeConstantsWithinProductionWhitelist() throws Exception {
|
||||
Set<String> allowed = requireWhitelist("chk_counseling_service_type");
|
||||
Set<String> codeValues = readStringSetField(CounselingService.class, "VALID_SERVICE_TYPES");
|
||||
|
||||
assertFalse(codeValues.isEmpty(), "解析不到代码侧常量,守卫失效");
|
||||
Set<String> violations = new TreeSet<>(codeValues);
|
||||
violations.removeAll(allowed);
|
||||
assertTrue(violations.isEmpty(),
|
||||
"代码写入的 service_type 不在生产 CHECK 白名单内,真实 PG 上必然失败:\n - "
|
||||
+ String.join("\n - ", violations) + "\n生产白名单: " + allowed);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫: CounselingService 的 request_source 常量 ⊆ 生产 chk_request_source 白名单")
|
||||
void requestSourceConstantsWithinProductionWhitelist() throws Exception {
|
||||
Set<String> allowed = requireWhitelist("chk_request_source");
|
||||
Set<String> codeValues = readStringSetField(CounselingService.class, "VALID_REQUEST_SOURCES");
|
||||
|
||||
assertFalse(codeValues.isEmpty(), "解析不到代码侧常量,守卫失效");
|
||||
Set<String> violations = new TreeSet<>(codeValues);
|
||||
violations.removeAll(allowed);
|
||||
assertTrue(violations.isEmpty(),
|
||||
"代码写入的 request_source 不在生产 CHECK 白名单内:\n - "
|
||||
+ String.join("\n - ", violations) + "\n生产白名单: " + allowed);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫: 状态机 ALLOWED_TRANSITIONS 的状态取值 ⊆ 生产 chk_counseling_status 白名单")
|
||||
void statusMachineWithinProductionWhitelist() throws Exception {
|
||||
Set<String> allowed = requireWhitelist("chk_counseling_status");
|
||||
|
||||
// 状态机的 key(from)与 value(to)都必须落在白名单内
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Set<String>> machine =
|
||||
(Map<String, Set<String>>) readField(CounselingService.class, "ALLOWED_TRANSITIONS");
|
||||
assertNotNull(machine, "解析不到状态机常量,守卫失效");
|
||||
assertFalse(machine.isEmpty());
|
||||
|
||||
Set<String> used = new TreeSet<>();
|
||||
machine.forEach((from, tos) -> {
|
||||
used.add(from);
|
||||
used.addAll(tos);
|
||||
});
|
||||
Set<String> violations = new TreeSet<>(used);
|
||||
violations.removeAll(allowed);
|
||||
assertTrue(violations.isEmpty(),
|
||||
"状态机使用了不在生产 CHECK 白名单内的状态值:\n - "
|
||||
+ String.join("\n - ", violations) + "\n生产白名单: " + allowed);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("守卫: 状态机必须覆盖全部合法状态,且终态不可再迁移")
|
||||
void statusMachineCoversAllStates() throws Exception {
|
||||
Set<String> allowed = requireWhitelist("chk_counseling_status");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Set<String>> machine =
|
||||
(Map<String, Set<String>>) readField(CounselingService.class, "ALLOWED_TRANSITIONS");
|
||||
|
||||
Set<String> missing = new TreeSet<>(allowed);
|
||||
missing.removeAll(machine.keySet());
|
||||
assertTrue(missing.isEmpty(), "状态机未声明这些合法状态的迁移规则:" + missing);
|
||||
|
||||
// 终态:completed / cancelled / closed 不得再有出边
|
||||
for (String terminal : List.of("completed", "cancelled", "closed")) {
|
||||
assertTrue(machine.getOrDefault(terminal, Set.of()).isEmpty(),
|
||||
terminal + " 应为终态,不得再迁移到其它状态");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 解析实现 ====================
|
||||
|
||||
/**
|
||||
* 按文件名顺序解析 db/migration/V*.sql,返回某 CHECK 约束的「最终有效」取值集合。
|
||||
*
|
||||
* 处理两类语句(本项目既有惯例):
|
||||
* <pre>
|
||||
* ALTER TABLE x DROP CONSTRAINT IF EXISTS chk_foo;
|
||||
* ALTER TABLE x ADD CONSTRAINT chk_foo CHECK (col IN ('a','b','c'));
|
||||
* </pre>
|
||||
* 后者覆盖前者。
|
||||
*/
|
||||
private static Set<String> resolveCheckValues(String constraintName) {
|
||||
if (CACHE.containsKey(constraintName)) return CACHE.get(constraintName);
|
||||
Set<String> result = null;
|
||||
try {
|
||||
Path dir = locateMigrationDir();
|
||||
List<Path> files;
|
||||
try (Stream<Path> s = Files.list(dir)) {
|
||||
files = s.filter(p -> p.getFileName().toString().matches("V\\d+__.*\\.sql"))
|
||||
.sorted(Comparator.comparing(p -> p.getFileName().toString()))
|
||||
.toList();
|
||||
}
|
||||
for (Path f : files) {
|
||||
String sql = Files.readString(f, StandardCharsets.UTF_8);
|
||||
// 归一化空白,便于跨行匹配
|
||||
String flat = sql.replaceAll("\\s+", " ");
|
||||
|
||||
// DROP CONSTRAINT IF EXISTS chk_foo
|
||||
Pattern drop = Pattern.compile(
|
||||
"DROP\\s+CONSTRAINT\\s+IF\\s+EXISTS\\s+" + Pattern.quote(constraintName),
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
if (drop.matcher(flat).find()) {
|
||||
// 记录"已被 drop",但真正的生效值由后续 ADD 决定;若之后没有 ADD,则为空
|
||||
result = null;
|
||||
}
|
||||
|
||||
// ADD CONSTRAINT chk_foo CHECK (col IN ('a','b',...))
|
||||
Pattern add = Pattern.compile(
|
||||
"ADD\\s+CONSTRAINT\\s+" + Pattern.quote(constraintName)
|
||||
+ "\\s+CHECK\\s*\\([^)]*IN\\s*\\(([^)]*)\\)",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
Matcher m = add.matcher(flat);
|
||||
if (m.find()) {
|
||||
Set<String> vals = new TreeSet<>();
|
||||
Matcher lit = Pattern.compile("'([^']*)'").matcher(m.group(1));
|
||||
while (lit.find()) vals.add(lit.group(1));
|
||||
result = vals.isEmpty() ? null : vals;
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("解析迁移文件失败: " + e.getMessage(), e);
|
||||
}
|
||||
CACHE.put(constraintName, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 反射读常量 ====================
|
||||
|
||||
private static Object readField(Class<?> type, String name) throws Exception {
|
||||
Field f = type.getDeclaredField(name);
|
||||
f.setAccessible(true);
|
||||
return f.get(null);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Set<String> readStringSetField(Class<?> type, String name) throws Exception {
|
||||
return (Set<String>) readField(type, name);
|
||||
}
|
||||
}
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
package com.danyang.match.platform.counseling;
|
||||
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
|
||||
import com.danyang.match.platform.counseling.service.CounselingServiceApp;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||
import com.danyang.match.platform.risk.service.RiskReferralService;
|
||||
import com.danyang.match.platform.shared.security.DataScopeGuard;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CounselingDataScopeTest {
|
||||
|
||||
@Mock CounselingServiceMapper counselingMapper;
|
||||
@Mock CounselingFollowupMapper followupMapper;
|
||||
@Mock OperatorMapper operatorMapper;
|
||||
@Mock NotificationService notificationService;
|
||||
@Mock UserMapper userMapper;
|
||||
@Mock RiskReferralService riskReferralService;
|
||||
@Mock AuditLogMapper auditLogMapper;
|
||||
|
||||
private CounselingServiceApp service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new CounselingServiceApp(counselingMapper, followupMapper, operatorMapper,
|
||||
notificationService, userMapper, riskReferralService, new DataScopeGuard(auditLogMapper));
|
||||
}
|
||||
|
||||
@Test
|
||||
void counselorCannotScheduleServiceAssignedToAnotherCounselor() {
|
||||
CounselingServiceRecord counseling = assignedService("service-1", "counselor-a");
|
||||
when(counselingMapper.selectById("service-1")).thenReturn(counseling);
|
||||
|
||||
assertThrows(AccessDeniedException.class, () -> service.scheduleCounseling(
|
||||
"service-1", new OperatorPrincipal("counselor-b", java.util.Set.of("counseling.service.process")), Instant.parse("2026-09-10T01:00:00Z"),
|
||||
Instant.parse("2026-09-10T02:00:00Z")));
|
||||
verify(counselingMapper, never()).updateById(any());
|
||||
verify(auditLogMapper).insert(argThat(audit ->
|
||||
"DATA_SCOPE_DENIED".equals(audit.getAction())
|
||||
&& "counselor-b".equals(audit.getOperatorId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void assignedCounselorCanScheduleOwnService() {
|
||||
CounselingServiceRecord counseling = assignedService("service-1", "counselor-a");
|
||||
when(counselingMapper.selectById("service-1")).thenReturn(counseling);
|
||||
|
||||
var result = service.scheduleCounseling("service-1", new OperatorPrincipal("counselor-a", java.util.Set.of("counseling.service.process")),
|
||||
Instant.parse("2026-09-10T01:00:00Z"), Instant.parse("2026-09-10T02:00:00Z"));
|
||||
|
||||
assertEquals("scheduled", result.getStatus());
|
||||
verify(counselingMapper).updateById(counseling);
|
||||
}
|
||||
|
||||
@Test
|
||||
void assignedListDoesNotExposeSensitiveFollowupContentByDefault() {
|
||||
CounselingServiceRecord counseling = assignedService("service-1", "counselor-a");
|
||||
when(counselingMapper.findByAssignedOperator("counselor-a")).thenReturn(List.of(counseling));
|
||||
|
||||
var result = service.getCounselorAssignedServices("counselor-a");
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertTrue(result.getFirst().getFollowups() == null || result.getFirst().getFollowups().isEmpty());
|
||||
verifyNoInteractions(followupMapper);
|
||||
}
|
||||
|
||||
private CounselingServiceRecord assignedService(String id, String operatorId) {
|
||||
CounselingServiceRecord service = new CounselingServiceRecord();
|
||||
service.setId(id);
|
||||
service.setUserId("user-1");
|
||||
service.setAssignedOperatorId(operatorId);
|
||||
service.setStatus("assigned");
|
||||
service.setCreatedAt(Instant.now());
|
||||
return service;
|
||||
}
|
||||
}
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
package com.danyang.match.platform.counseling;
|
||||
|
||||
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
|
||||
import com.danyang.match.platform.counseling.service.CounselingService;
|
||||
import org.junit.jupiter.api.*;
|
||||
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.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Phase 13A:婚恋咨询与婚前辅导 —— 真实运行时路径测试。
|
||||
*
|
||||
* <p>覆盖计划 6 项与 Gate:「申请→分配→预约→服务→完成→反馈」真实闭环 +
|
||||
* 所有隐私负向用例(INV-012 / INV-013 / INV-005)。
|
||||
*
|
||||
* <p>测试保真度(第四层,Phase 13 确立):本测试走**真实 Service 方法**与**真实落库**,
|
||||
* 且测试库 {@code schema-h2.sql} 已补齐 counseling 三表的生产 CHECK 约束
|
||||
* (V022 + V039),因此状态机越界、非法 service_type 等错误会在测试期即暴露。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class CounselingPhase13ATest {
|
||||
|
||||
@Autowired private CounselingService counselingService;
|
||||
@Autowired private CounselingServiceMapper mapper;
|
||||
|
||||
private static final String USER = "u-counsel-1";
|
||||
private static final String COMPANION = "u-companion-1";
|
||||
private static final String COUNSELOR_A = "op-counselor-a";
|
||||
private static final String COUNSELOR_B = "op-counselor-b";
|
||||
|
||||
// ==================== Gate:申请 → 分配 → 预约 → 服务 → 完成 → 反馈 真实闭环 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("Gate: 申请→分配→排期→开始→跟进→完成 全链路真实闭环")
|
||||
void fullLifecycleClosedLoop() {
|
||||
// 1. 申请
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "最近沟通上有些困惑",
|
||||
"user_self", "online", "工作日晚间", null, null);
|
||||
assertEquals("pending", svc.getStatus());
|
||||
assertEquals(USER, svc.getUserId());
|
||||
|
||||
// 2. 分配
|
||||
CounselingServiceRecord assigned = counselingService.assign(svc.getId(), COUNSELOR_A);
|
||||
assertEquals("assigned", assigned.getStatus());
|
||||
assertEquals(COUNSELOR_A, assigned.getAssignedOperatorId());
|
||||
|
||||
// 3. 排期
|
||||
Instant start = Instant.parse("2026-10-01T10:00:00Z");
|
||||
Instant end = Instant.parse("2026-10-01T11:00:00Z");
|
||||
CounselingServiceRecord scheduled = counselingService.schedule(svc.getId(), COUNSELOR_A, start, end);
|
||||
assertEquals("scheduled", scheduled.getStatus());
|
||||
assertEquals(start, scheduled.getScheduledStartAt());
|
||||
|
||||
// 4. 开始服务
|
||||
assertEquals("in_progress", counselingService.start(svc.getId(), COUNSELOR_A).getStatus());
|
||||
|
||||
// 5. 跟进(反馈)
|
||||
CounselingFollowup fu = counselingService.addFollowup(
|
||||
svc.getId(), COUNSELOR_A, "online_chat", "已完成首次沟通,情绪有所缓解", "ok", null);
|
||||
assertNotNull(fu.getId());
|
||||
|
||||
// 6. 完成
|
||||
CounselingServiceRecord done = counselingService.complete(svc.getId(), COUNSELOR_A);
|
||||
assertEquals("completed", done.getStatus());
|
||||
assertNotNull(done.getCompletedAt());
|
||||
|
||||
// 闭环可追溯:详情里能看到跟进记录
|
||||
CounselingServiceRecord detail = counselingService.getDetail(USER, "user", svc.getId());
|
||||
assertNotNull(detail.getFollowups());
|
||||
assertEquals(1, detail.getFollowups().size());
|
||||
}
|
||||
|
||||
// ==================== 计划项 2:userId 取自认证主体 + 幂等 + 取消 + 状态迁移 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("apply: userId 由入参(认证主体)决定,请求体无法伪造")
|
||||
void applyUsesAuthenticatedPrincipalUserId() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "emotional_support", "有点焦虑", "user_self", "online", null, null, null);
|
||||
// 落库的 userId 必须是传入的主体 ID
|
||||
CounselingServiceRecord inDb = mapper.findById(svc.getId()).orElseThrow();
|
||||
assertEquals(USER, inDb.getUserId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("apply: 同一幂等键重复提交只落一条记录")
|
||||
void applyIsIdempotentByKey() {
|
||||
String key = "idem-key-001";
|
||||
CounselingServiceRecord first = counselingService.apply(
|
||||
USER, "relationship_guidance", "同一份申请", "user_self", "online", null, null, key);
|
||||
CounselingServiceRecord second = counselingService.apply(
|
||||
USER, "relationship_guidance", "同一份申请", "user_self", "online", null, null, key);
|
||||
|
||||
assertEquals(first.getId(), second.getId(), "同一幂等键必须返回同一条记录");
|
||||
long count = mapper.findByUserId(USER).stream()
|
||||
.filter(s -> key.equals(s.getIdempotencyKey())).count();
|
||||
assertEquals(1, count, "幂等键只应落一条记录");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("apply: 非法 service_type 被拒绝(与生产 CHECK 白名单一致)")
|
||||
void applyRejectsInvalidServiceType() {
|
||||
assertThrows(IllegalArgumentException.class, () -> counselingService.apply(
|
||||
USER, "not_a_real_type", "x", "user_self", "online", null, null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("apply: 非法 request_source 被拒绝")
|
||||
void applyRejectsInvalidRequestSource() {
|
||||
assertThrows(IllegalArgumentException.class, () -> counselingService.apply(
|
||||
USER, "relationship_guidance", "x", "hacked_source", "online", null, null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cancel: 仅申请人本人可取消,他人取消被拒")
|
||||
void onlyOwnerCanCancel() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "待取消", "user_self", "online", null, null, null);
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> counselingService.cancel(svc.getId(), "someone-else", "越权取消"));
|
||||
|
||||
CounselingServiceRecord cancelled = counselingService.cancel(svc.getId(), USER, "不需要了");
|
||||
assertEquals("cancelled", cancelled.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("状态迁移: 已完成的服务不可再取消(非法迁移被拒)")
|
||||
void completedServiceCannotBeCancelled() {
|
||||
CounselingServiceRecord svc = seedCompleted();
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> counselingService.cancel(svc.getId(), USER, "事后反悔"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("状态迁移: 未分配的服务不可直接排期")
|
||||
void pendingServiceCannotBeScheduled() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "未分配", "user_self", "online", null, null, null);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> counselingService.schedule(
|
||||
svc.getId(), COUNSELOR_A,
|
||||
Instant.parse("2026-10-01T10:00:00Z"), Instant.parse("2026-10-01T11:00:00Z")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("状态迁移: 排期时间必须 end > start")
|
||||
void scheduleRequiresValidRange() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "时间校验", "user_self", "online", null, null, null);
|
||||
counselingService.assign(svc.getId(), COUNSELOR_A);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> counselingService.schedule(
|
||||
svc.getId(), COUNSELOR_A,
|
||||
Instant.parse("2026-10-01T11:00:00Z"), Instant.parse("2026-10-01T10:00:00Z")));
|
||||
}
|
||||
|
||||
// ==================== 计划项 3:分配并发安全 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("assign: 并发分配同一单 —— 第二个分配者被明确拒绝,不覆盖第一个")
|
||||
void concurrentAssignDoesNotSilentlyOverwrite() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "并发分配", "user_self", "online", null, null, null);
|
||||
|
||||
CounselingServiceRecord first = counselingService.assign(svc.getId(), COUNSELOR_A);
|
||||
assertEquals(COUNSELOR_A, first.getAssignedOperatorId());
|
||||
|
||||
// 第二次分配必须失败(条件更新 WHERE status='pending' 未命中)
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class,
|
||||
() -> counselingService.assign(svc.getId(), COUNSELOR_B));
|
||||
assertTrue(ex.getMessage().contains("已被分配"), "错误信息应说明冲突原因");
|
||||
|
||||
// 关键:第一个分配者的归属**未被覆盖**
|
||||
CounselingServiceRecord inDb = mapper.findById(svc.getId()).orElseThrow();
|
||||
assertEquals(COUNSELOR_A, inDb.getAssignedOperatorId(), "不得静默覆盖先到者的分派");
|
||||
}
|
||||
|
||||
// ==================== INV-012:Counselor 只能读写 Assigned Service ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-012: 非 assigned Counselor 不能排期他人服务")
|
||||
void nonAssignedCounselorCannotSchedule() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "归属校验", "user_self", "online", null, null, null);
|
||||
counselingService.assign(svc.getId(), COUNSELOR_A);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> counselingService.schedule(
|
||||
svc.getId(), COUNSELOR_B,
|
||||
Instant.parse("2026-10-01T10:00:00Z"), Instant.parse("2026-10-01T11:00:00Z")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-012: 非 assigned Counselor 不能添加跟进")
|
||||
void nonAssignedCounselorCannotAddFollowup() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "跟进归属", "user_self", "online", null, null, null);
|
||||
counselingService.assign(svc.getId(), COUNSELOR_A);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> counselingService.addFollowup(
|
||||
svc.getId(), COUNSELOR_B, "phone_call", "越权写入", null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-012: 非 assigned Counselor 不能完成他人服务")
|
||||
void nonAssignedCounselorCannotComplete() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "完成归属", "user_self", "online", null, null, null);
|
||||
counselingService.assign(svc.getId(), COUNSELOR_A);
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> counselingService.complete(svc.getId(), COUNSELOR_B));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-012: assignedTo 只返回分配给自己的服务")
|
||||
void assignedListOnlyContainsOwnServices() {
|
||||
CounselingServiceRecord mine = counselingService.apply(
|
||||
USER, "relationship_guidance", "我的", "user_self", "online", null, null, null);
|
||||
CounselingServiceRecord other = counselingService.apply(
|
||||
"u-counsel-2", "emotional_support", "别人的", "user_self", "online", null, null, null);
|
||||
counselingService.assign(mine.getId(), COUNSELOR_A);
|
||||
counselingService.assign(other.getId(), COUNSELOR_B);
|
||||
|
||||
List<CounselingServiceRecord> list = counselingService.assignedTo(COUNSELOR_A);
|
||||
assertTrue(list.stream().anyMatch(s -> mine.getId().equals(s.getId())));
|
||||
assertFalse(list.stream().anyMatch(s -> other.getId().equals(s.getId())),
|
||||
"不得返回分配给其他咨询师的服务");
|
||||
}
|
||||
|
||||
// ==================== INV-013:不得读取咨询正文 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-013: 申请者本人可见完整正文与跟进记录")
|
||||
void ownerSeesFullContent() {
|
||||
CounselingServiceRecord svc = seedInProgressWithFollowup();
|
||||
|
||||
CounselingServiceRecord detail = counselingService.getDetail(USER, "user", svc.getId());
|
||||
assertNotNull(detail);
|
||||
assertEquals("敏感正文内容", detail.getUserRequestSummary(), "本人应看到自己的申请正文");
|
||||
assertNotNull(detail.getFollowups());
|
||||
assertEquals(1, detail.getFollowups().size(), "本人应看到跟进记录");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-013: assigned Counselor 可见完整正文与跟进记录")
|
||||
void assignedCounselorSeesFullContent() {
|
||||
CounselingServiceRecord svc = seedInProgressWithFollowup();
|
||||
|
||||
CounselingServiceRecord detail = counselingService.getDetail(COUNSELOR_A, "counselor", svc.getId());
|
||||
assertNotNull(detail);
|
||||
assertEquals("敏感正文内容", detail.getUserRequestSummary());
|
||||
assertNotNull(detail.getFollowups());
|
||||
assertEquals(1, detail.getFollowups().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-013: 红娘只能看脱敏版 —— 无正文、无跟进记录")
|
||||
void matchmakerSeesSanitizedDetail() {
|
||||
CounselingServiceRecord svc = seedInProgressWithFollowup();
|
||||
|
||||
CounselingServiceRecord detail = counselingService.getDetail("op-matchmaker-1", "matchmaker", svc.getId());
|
||||
assertNotNull(detail, "红娘应能查看必要摘要(状态/类型)");
|
||||
assertNull(detail.getUserRequestSummary(), "INV-013: 红娘不得读取咨询正文");
|
||||
assertNull(detail.getFollowups(), "INV-013: 红娘不得读取跟进正文");
|
||||
// 必要字段仍可见,便于红娘判断转介状态
|
||||
assertEquals(svc.getStatus(), detail.getStatus());
|
||||
assertEquals(svc.getServiceType(), detail.getServiceType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-013: 普通 operator 只能看脱敏版")
|
||||
void plainOperatorSeesSanitizedDetail() {
|
||||
CounselingServiceRecord svc = seedInProgressWithFollowup();
|
||||
|
||||
CounselingServiceRecord detail = counselingService.getDetail("op-plain-1", "operator", svc.getId());
|
||||
assertNotNull(detail);
|
||||
assertNull(detail.getUserRequestSummary());
|
||||
assertNull(detail.getFollowups());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-013: counseling_manager 可见申请摘要但**不得**见跟进正文")
|
||||
void managerSeesSummaryButNotFollowups() {
|
||||
CounselingServiceRecord svc = seedInProgressWithFollowup();
|
||||
|
||||
CounselingServiceRecord detail = counselingService.getDetail("op-mgr-1", "counseling_manager", svc.getId());
|
||||
assertNotNull(detail);
|
||||
assertEquals("敏感正文内容", detail.getUserRequestSummary(), "经理需要摘要以判断分派");
|
||||
assertNull(detail.getFollowups(), "INV-013: 经理不得成为阅读跟进正文的绕过通道");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-013: 陌生人无权查看详情")
|
||||
void strangerGetsNothing() {
|
||||
CounselingServiceRecord svc = seedInProgressWithFollowup();
|
||||
assertNull(counselingService.getDetail("op-total-stranger", "unknown_role", svc.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-013: 列表与队列路径均不携带正文(sanitize 生效)")
|
||||
void listPathsAreSanitized() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "列表脱敏验证", "user_self", "online", null, null, null);
|
||||
|
||||
List<CounselingServiceRecord> queue = counselingService.pendingQueue();
|
||||
assertTrue(queue.stream().anyMatch(s -> svc.getId().equals(s.getId())));
|
||||
// pendingQueue 返回的实体未装配 followups;Controller 层再调 sanitize 清正文
|
||||
queue.forEach(CounselingService::sanitize);
|
||||
queue.stream().filter(s -> svc.getId().equals(s.getId())).forEach(s -> {
|
||||
assertNull(s.getUserRequestSummary(), "队列路径必须清理正文");
|
||||
assertNull(s.getFollowups());
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== INV-005:婚前辅导伴侣须本人确认 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-005: 受邀伴侣本人可确认参与婚前辅导")
|
||||
void companionCanConfirmBySelf() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "premarital_guidance", "婚前辅导申请",
|
||||
"user_self", "offline", null, COMPANION, null);
|
||||
|
||||
CounselingServiceRecord confirmed = counselingService.confirmCompanion(svc.getId(), COMPANION);
|
||||
assertNotNull(confirmed.getUserConfirmedAt(), "伴侣本人确认后应写入确认时间");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-005: 工作人员不得替伴侣确认(非受邀者确认被拒)")
|
||||
void staffCannotConfirmOnBehalfOfCompanion() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "premarital_guidance", "婚前辅导申请",
|
||||
"user_self", "offline", null, COMPANION, null);
|
||||
|
||||
// 工作人员(operator)尝试确认 → 拒绝
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> counselingService.confirmCompanion(svc.getId(), "op-staff-1"),
|
||||
"INV-005: 任何工作人员不得替用户确认");
|
||||
// 申请人自己也不能替伴侣确认
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> counselingService.confirmCompanion(svc.getId(), USER),
|
||||
"INV-005: 申请人本人亦不能替伴侣确认");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-005: 非婚前辅导服务不支持伴侣确认")
|
||||
void companionConfirmOnlyForPremarital() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "普通咨询",
|
||||
"user_self", "online", null, COMPANION, null);
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> counselingService.confirmCompanion(svc.getId(), COMPANION));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-005: 伴侣确认幂等 —— 重复确认不报错、且不改变首次确认时间")
|
||||
void companionConfirmIsIdempotent() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "premarital_guidance", "幂等确认",
|
||||
"user_self", "offline", null, COMPANION, null);
|
||||
|
||||
Instant first = counselingService.confirmCompanion(svc.getId(), COMPANION).getUserConfirmedAt();
|
||||
assertNotNull(first);
|
||||
|
||||
// 第二次确认必须命中短路分支(userConfirmedAt != null → 直接返回),
|
||||
// 不得再次 setUserConfirmedAt(now) 覆盖首次时间。
|
||||
//
|
||||
// ⚠️ 精度坑:H2/PG 的 timestamp 精度低于 Instant.now() 的纳秒。
|
||||
// 首次返回值来自内存实体(纳秒),二次返回值若来自 DB 回读则被截断到微秒,
|
||||
// 直接 assertEquals 会因 470ns 级差异误报。因此比较"截断到毫秒"的表现值,
|
||||
// 并额外断言两次返回的**时间差 < 1 秒**(真正要防的是"被覆盖成新时间")。
|
||||
Instant second = counselingService.confirmCompanion(svc.getId(), COMPANION).getUserConfirmedAt();
|
||||
assertNotNull(second);
|
||||
assertEquals(first.truncatedTo(java.time.temporal.ChronoUnit.MILLIS),
|
||||
second.truncatedTo(java.time.temporal.ChronoUnit.MILLIS),
|
||||
"重复确认不应改变已写入的确认时间");
|
||||
}
|
||||
|
||||
// ==================== 辅助 ====================
|
||||
|
||||
/**
|
||||
* 造一条「已分配 → 进行中 → 有 1 条跟进」的记录。
|
||||
*
|
||||
* <p><b>踩坑记录</b>:本方法最初直接返回 {@code apply()} 的返回值。
|
||||
* 但那个对象是**测试内存里的旧快照**(status 仍是 {@code pending}),
|
||||
* 而 {@code getDetail(...)} 是**重新查库**得到的({@code in_progress})。
|
||||
* 于是 {@code assertEquals(svc.getStatus(), detail.getStatus())} 拿"陈旧内存值"比"最新库值",
|
||||
* 必然误报 —— 属于测试自身缺陷,不是生产缺陷。
|
||||
* 现改为返回**重新查库**的实例,保证两侧同源。
|
||||
*/
|
||||
private CounselingServiceRecord seedInProgressWithFollowup() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "敏感正文内容", "user_self", "online", null, null, null);
|
||||
counselingService.assign(svc.getId(), COUNSELOR_A);
|
||||
counselingService.start(svc.getId(), COUNSELOR_A);
|
||||
counselingService.addFollowup(svc.getId(), COUNSELOR_A, "phone_call",
|
||||
"跟进正文内容(敏感)", "ok", null);
|
||||
return reload(svc.getId());
|
||||
}
|
||||
|
||||
private CounselingServiceRecord seedCompleted() {
|
||||
CounselingServiceRecord svc = counselingService.apply(
|
||||
USER, "relationship_guidance", "已完成服务", "user_self", "online", null, null, null);
|
||||
counselingService.assign(svc.getId(), COUNSELOR_A);
|
||||
counselingService.start(svc.getId(), COUNSELOR_A);
|
||||
counselingService.complete(svc.getId(), COUNSELOR_A);
|
||||
return reload(svc.getId());
|
||||
}
|
||||
|
||||
/** 以申请者身份重新查库,拿到与断言侧同源的实体快照 */
|
||||
private CounselingServiceRecord reload(String serviceId) {
|
||||
CounselingServiceRecord fresh = counselingService.getDetail(USER, "user", serviceId);
|
||||
assertNotNull(fresh, "重载失败:serviceId=" + serviceId);
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
+32
-3
@@ -59,7 +59,6 @@ class EntityMigrationParityTest {
|
||||
"appeals",
|
||||
"consent_records",
|
||||
"content_categories",
|
||||
"counseling_followups",
|
||||
"interest_dictionary",
|
||||
"match_rule_configs",
|
||||
"operator_roles",
|
||||
@@ -104,7 +103,11 @@ class EntityMigrationParityTest {
|
||||
// 已补入 schema-h2.sql,且从 TEST_SCHEMA_OPTIONAL_TABLES 白名单移除。
|
||||
"matchmaker_services",
|
||||
"matchmaker_recommendations",
|
||||
"matchmaker_followups"
|
||||
"matchmaker_followups",
|
||||
// Phase 13A:咨询两表此前 counseling_services 完全缺失、counseling_followups
|
||||
// 仅在可选白名单里(= 实际上没人测)。已补入 schema-h2.sql 并移入必需集合。
|
||||
"counseling_services",
|
||||
"counseling_followups"
|
||||
);
|
||||
/** BaseEntity 声明的公共列(对照 shared/base/BaseEntity.java)。 */
|
||||
private static final Set<String> BASE_ENTITY_COLUMNS =
|
||||
@@ -309,6 +312,27 @@ class EntityMigrationParityTest {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回归守卫:{@code @TableField(exist = false)} 的**全限定写法**也必须被排除。
|
||||
*
|
||||
* <p>Phase 13A 实测发现的解析器缺陷:旧正则只认 {@code @TableField(...)},
|
||||
* 遇到 {@code @com.baomidou.mybatisplus.annotation.TableField(exist = false)}
|
||||
* 就会把非持久化字段当成真实列,随后报出「生产缺列」的**假警报**。
|
||||
* 若有人为消警报而去生产加一个无用列,就是把测试保真度反噬成生产污染。
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("解析器自检:全限定 @TableField(exist=false) 必须被识别为非持久化字段")
|
||||
void fullyQualifiedTableFieldExistFalseMustBeExcluded() throws IOException {
|
||||
Map<String, Set<String>> cols = parseEntityColumns();
|
||||
|
||||
Set<String> cs = cols.get("counseling_services");
|
||||
assertNotNull(cs, "未识别 CounselingServiceRecord 实体(@TableName(\"counseling_services\"))");
|
||||
assertFalse(cs.contains("followups"),
|
||||
"全限定 @TableField(exist = false) 未被排除 —— 非持久化字段 followups "
|
||||
+ "被当成了 counseling_services 的真实列,会产生假漂移警报。"
|
||||
+ "请检查 exist=false 排除正则是否覆盖全限定注解写法。");
|
||||
}
|
||||
|
||||
// ==================== 解析实现 ====================
|
||||
|
||||
/** 解析 db/migration/V*.sql 的 CREATE TABLE 列定义 + ALTER TABLE ADD COLUMN。 */
|
||||
@@ -452,8 +476,13 @@ class EntityMigrationParityTest {
|
||||
|
||||
// 所有 private 字段
|
||||
Set<String> excluded = new LinkedHashSet<>();
|
||||
// ⚠️ Phase 13A 加固:注解可能写成**全限定名**
|
||||
// @com.baomidou.mybatisplus.annotation.TableField(exist = false)
|
||||
// 旧正则只匹配简单形式 @TableField(...),导致全限定写法被漏掉 →
|
||||
// 非持久化字段被误判为"生产缺列"(假警报),或反向掩盖真实漂移。
|
||||
// 这里允许注解名前任意包路径:([\w.]*TableField)。
|
||||
Matcher noPersist = Pattern.compile(
|
||||
"@TableField\\([^)]*exist\\s*=\\s*false[^)]*\\)\\s*"
|
||||
"@[\\w.]*TableField\\([^)]*exist\\s*=\\s*false[^)]*\\)\\s*"
|
||||
+ "private\\s+[^;]*?\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*;",
|
||||
Pattern.DOTALL).matcher(clean);
|
||||
while (noPersist.find()) {
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
class MigrationValidationTest {
|
||||
|
||||
// 基线守卫:新增 Flyway 迁移时必须同步递增此值
|
||||
private static final int LATEST_MIGRATION = 38;
|
||||
private static final int LATEST_MIGRATION = 39;
|
||||
|
||||
private static final String MIGRATION_DIR =
|
||||
"backend/src/main/resources/db/migration/";
|
||||
|
||||
+29
-1
@@ -52,7 +52,18 @@ class OperatorEndpointPermissionCoverageTest {
|
||||
for (Class<?> controller : CONTROLLERS) {
|
||||
boolean operatorController = paths(controller).stream().anyMatch(this::isOperatorPath);
|
||||
for (Method method : controller.getDeclaredMethods()) {
|
||||
boolean operatorMethod = paths(method).stream().anyMatch(this::isOperatorPath);
|
||||
// ⚠️ Phase 13A 修正:本测试的语义是「**每一个运营端接口**都要有显式 Permission」,
|
||||
// 因此必须先确认该方法是**真正的 HTTP 端点**(带 @RequestMapping/@GetMapping/
|
||||
// @PostMapping 等 → findMergedAnnotation(RequestMapping) 能取到路径)。
|
||||
// 修复前会扫到**私有辅助方法**:CounselingController.roleOf(OperatorPrincipal)
|
||||
// 只是把 principal 映射成角色字符串的内部工具,不是端点,也不可能加 @PreAuthorize,
|
||||
// 却因"参数里含 OperatorPrincipal"被误判为未受保护的接口(假阳性)。
|
||||
List<String> methodPaths = paths(method);
|
||||
if (methodPaths.isEmpty()) {
|
||||
continue; // 非端点(私有工具方法等)→ 不在本测试职责范围
|
||||
}
|
||||
|
||||
boolean operatorMethod = methodPaths.stream().anyMatch(this::isOperatorPath);
|
||||
boolean operatorPrincipal = Arrays.stream(method.getParameterTypes())
|
||||
.anyMatch(OperatorPrincipal.class::equals);
|
||||
if (!operatorController && !operatorMethod && !operatorPrincipal) {
|
||||
@@ -68,6 +79,23 @@ class OperatorEndpointPermissionCoverageTest {
|
||||
assertEquals(List.of(), missing, "以下运营端接口缺少显式 Permission;不能依赖 /operator/** 粗过滤器");
|
||||
}
|
||||
|
||||
/**
|
||||
* 自检:必须真的扫到了运营端接口,否则本测试可能因"一个方法都没匹配上"而恒真。
|
||||
*/
|
||||
@Test
|
||||
void guardMustActuallyInspectOperatorEndpoints() {
|
||||
long operatorEndpointCount = 0;
|
||||
for (Class<?> controller : CONTROLLERS) {
|
||||
for (Method method : controller.getDeclaredMethods()) {
|
||||
if (paths(method).stream().anyMatch(this::isOperatorPath)) {
|
||||
operatorEndpointCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
org.junit.jupiter.api.Assertions.assertTrue(operatorEndpointCount >= 10,
|
||||
"扫描到的运营端接口数量异常偏少(" + operatorEndpointCount + "),守卫可能已失效");
|
||||
}
|
||||
|
||||
private List<String> paths(java.lang.reflect.AnnotatedElement element) {
|
||||
RequestMapping mapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class);
|
||||
return mapping == null ? List.of() : Arrays.asList(mapping.path().length > 0
|
||||
|
||||
@@ -113,7 +113,27 @@ CREATE TABLE counseling_services (
|
||||
scheduled_end_at TIMESTAMP WITH TIME ZONE, consent_version VARCHAR(32), consented_at TIMESTAMP WITH TIME ZONE,
|
||||
completed_at TIMESTAMP WITH TIME ZONE, closed_reason VARCHAR(500), companion_user_id VARCHAR(36),
|
||||
created_by_operator_id VARCHAR(36), user_confirmed_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
idempotency_key VARCHAR(128),
|
||||
-- 生产约束(V022 + V039):此前测试库完全没有 CHECK → 约束漂移盲区(Phase 11/13 同族根因)
|
||||
CONSTRAINT chk_counseling_service_type CHECK (service_type IN (
|
||||
'relationship_guidance', 'emotional_support', 'premarital_guidance', 'anti_fraud_guidance'
|
||||
)),
|
||||
CONSTRAINT chk_counseling_status CHECK (status IN (
|
||||
'pending', 'assigned', 'scheduled', 'in_progress', 'completed', 'cancelled', 'closed'
|
||||
)),
|
||||
CONSTRAINT chk_request_source CHECK (request_source IN (
|
||||
'user_self', 'matchmaker_referral', 'activity_followup', 'service_station', 'operator_assisted'
|
||||
)),
|
||||
-- V039 幂等索引:H2 无 partial index,用标准 UNIQUE 表达(PG/H2 均允许多行 NULL 共存,语义等价)
|
||||
CONSTRAINT uk_counseling_idem UNIQUE (user_id, idempotency_key)
|
||||
);
|
||||
-- V022 / V039: counseling_followups 表(跟进正文属 INV-013 敏感数据)
|
||||
CREATE TABLE counseling_followups (
|
||||
id VARCHAR(36) PRIMARY KEY, counseling_id VARCHAR(36) NOT NULL, operator_id VARCHAR(36) NOT NULL,
|
||||
followup_type VARCHAR(32), content CLOB NOT NULL, result_code VARCHAR(64),
|
||||
next_followup_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
CREATE TABLE service_program_members (
|
||||
id VARCHAR(36) PRIMARY KEY, program_id VARCHAR(36), user_id VARCHAR(36), status VARCHAR(32),
|
||||
|
||||
Reference in New Issue
Block a user