diff --git a/backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java b/backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java
index b145e6e..145cce9 100644
--- a/backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java
+++ b/backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java
@@ -71,12 +71,54 @@ public class SecurityConfig {
.requestMatchers("/app/config").permitAll()
.requestMatchers("/activities").permitAll()
.requestMatchers("/auth/**").authenticated()
- // 操作台接口需要认证
- .requestMatchers("/operator/**").hasRole("OPERATOR")
- // 管理接口需要认证
- .requestMatchers("/admin/**").hasRole("OPERATOR")
- // 其他所有用户接口需要认证(覆盖 /api/v1/** 中未匹配的)
- .requestMatchers("/**").authenticated()
+ /**
+ * 运营端接口要求 OPERATOR 角色。
+ *
+ *
除了顶层的 {@code /operator/**},还必须逐个列出「嵌在业务路径下」的
+ * 运营端点,例如 {@code /counseling/services/operator/queue}、
+ * {@code /content/operator/articles}、
+ * {@code /activities/operator/checkin/scan}、
+ * {@code /service-appointments/operator/list}。
+ * 漏掉任何一个,该端点都会掉进下面的 ROLE_USER 规则,
+ * 导致运营人员被自己的平台拒绝(管理端对应模块直接不可用)。
+ *
+ *
不要试图用 {@code /**}{@code /operator/**} 一行覆盖:
+ * Spring 的 PathPattern 语法不允许 {@code **} 出现在中间
+ * (只有末尾才允许),写了会在运行时抛
+ * {@code PatternParseException: No more pattern data allowed after ** pattern element},
+ * 且表现为整个过滤器链 500 —— 已实测踩过。
+ *
+ *
这些前缀由 {@code SecurityPathCoverageTest} 机械校验:
+ * 源码中任何含 operator 的端点路径都必须被这里的规则覆盖。
+ */
+ .requestMatchers(
+ "/operator/**",
+ "/admin/**",
+ "/counseling/services/operator/**",
+ "/content/operator/**",
+ "/risk/operator/**",
+ "/activities/operator/**",
+ "/service-appointments/operator/**",
+ "/service-programs/operator/**",
+ "/referrals/operator/**")
+ .hasRole("OPERATOR")
+ /**
+ * 其余接口必须持用户身份(ROLE_USER),不再只是"已认证"。
+ *
+ *
此前这里是 {@code authenticated()}:运营 token 也算"已认证",
+ * 于是能用运营账号去打用户端端点。而
+ * {@code @AuthenticationPrincipal UserPrincipal} 遇到 OperatorPrincipal
+ * 属类型不匹配,Spring Security 默认返回 null
+ * ({@code errorOnInvalidType=false})——
+ * 全仓 244 处使用该注解,仅 48 处做了 null 检查,
+ * 其余端点会直接 NPE → 500。
+ *
+ *
与其补上百处 null 判断(机械、易漏、且无法约束将来新增的代码),
+ * 不如在认证层按身份类型挡住:运营身份访问用户端一律 403,
+ * 请求根本进不到 Controller。身份与角色的对应关系见 JwtAuthFilter:
+ * 运营 → ROLE_OPERATOR + 权限;用户 → ROLE_USER。
+ */
+ .requestMatchers("/**").hasRole("USER")
// 其他所有请求放行(避免 denyAll 导致未知路径也被拦截)
.anyRequest().permitAll()
)
diff --git a/backend/src/test/java/com/danyang/match/platform/shared/config/SecurityPathCoverageTest.java b/backend/src/test/java/com/danyang/match/platform/shared/config/SecurityPathCoverageTest.java
new file mode 100644
index 0000000..a3056cb
--- /dev/null
+++ b/backend/src/test/java/com/danyang/match/platform/shared/config/SecurityPathCoverageTest.java
@@ -0,0 +1,169 @@
+package com.danyang.match.platform.shared.config;
+
+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.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 机械校验:源码里每个「运营端点」都必须被 SecurityConfig 的 OPERATOR 规则覆盖。
+ *
+ *
为什么需要:运营端点并不都在 {@code /operator/} 下。部分业务控制器把运营端点
+ * 嵌在深层路径里({@code /counseling/services/operator/queue}、
+ * {@code /content/operator/articles}、{@code /service-appointments/operator/list} …)。
+ * 一旦某个这类端点没被显式纳入 OPERATOR 规则,它会掉进兜底的 ROLE_USER 规则,
+ * 结果是运营人员被自己的平台拒绝——管理端对应模块直接不可用,
+ * 而这在只跑用户侧测试时完全看不出来。
+ *
+ *
不能靠 {@code /**}{@code /operator/**} 一行通配解决:PathPattern 不允许
+ * {@code **} 出现在中间,写了会抛 PatternParseException 并把整个过滤器链打成 500。
+ * 所以只能显式枚举 —— 那就必须有人盯着别漏,这个测试就是那个人。
+ */
+class SecurityPathCoverageTest {
+
+ private static final Path SRC_ROOT = resolveSourceRoot();
+ private static final Path SECURITY_CONFIG = SRC_ROOT.resolve(
+ "com/danyang/match/platform/shared/config/SecurityConfig.java");
+
+ /**
+ * 定位源码根目录。
+ *
+ *
测试的工作目录在本环境下是仓库根而非 Maven 模块根
+ * (同项目 `MigrationValidationTest` 也以 `backend/target/...` 形式引用产物),
+ * 但不同执行方式下可能不同。两种候选都试一遍,比写死一个更稳。
+ */
+ private static Path resolveSourceRoot() {
+ for (String candidate : new String[]{"src/main/java", "backend/src/main/java"}) {
+ Path path = Path.of(candidate);
+ if (Files.isDirectory(path)) {
+ return path;
+ }
+ }
+ throw new IllegalStateException(
+ "无法定位源码根目录(尝试过 src/main/java 与 backend/src/main/java);当前工作目录="
+ + Path.of("").toAbsolutePath());
+ }
+
+ @Test
+ @DisplayName("源码中所有运营端点路径都被 SecurityConfig 的 OPERATOR 规则覆盖")
+ void everyOperatorEndpointIsGuardedByOperatorRule() throws IOException {
+ Set prefixes = operatorRulePrefixes();
+ // 健全性检查:确认解析器真的取到了 OPERATOR 规则(而非 permitAll 之类)。
+ // 若这条挂了,说明下面是"零覆盖也算全通过"的假绿,必须先修解析逻辑。
+ assertTrue(prefixes.contains("/operator/**"),
+ "未能从 SecurityConfig 解析出运营规则前缀,测试本身已失效。实际解析结果:" + prefixes);
+
+ List unguarded = new ArrayList<>();
+ try (Stream files = Files.walk(SRC_ROOT)) {
+ for (Path file : files.filter(p -> p.toString().endsWith("Controller.java")).toList()) {
+ String src = Files.readString(file);
+ String classPath = firstMappingPath(src);
+ for (String methodPath : methodMappingPaths(src)) {
+ String full = join(classPath, methodPath);
+ if (isOperatorPath(full) && !isCovered(full, prefixes)) {
+ unguarded.add(file.getFileName() + " -> " + full);
+ }
+ }
+ }
+ }
+
+ assertTrue(unguarded.isEmpty(),
+ "以下运营端点未被 SecurityConfig 的 OPERATOR 规则覆盖,"
+ + "运营账号访问会被 403(管理端对应功能不可用)。"
+ + "请在 SecurityConfig 的 OPERATOR requestMatchers 中补上其前缀:\n"
+ + String.join("\n", unguarded));
+ }
+
+ @Test
+ @DisplayName("兜底规则必须是 ROLE_USER,不能退回 authenticated()")
+ void fallbackRuleRequiresUserRole() throws IOException {
+ String cfg = Files.readString(SECURITY_CONFIG);
+ /*
+ * 若兜底退回 authenticated(),运营 token 也算"已认证",
+ * 于是能打进用户端端点;而 @AuthenticationPrincipal UserPrincipal
+ * 遇 OperatorPrincipal 会返回 null → 未做 null 检查的端点 NPE → 500。
+ * 全仓 244 处使用该注解、仅 48 处做了检查 —— 所以这条兜底不能松。
+ */
+ assertTrue(cfg.contains("hasRole(\"USER\")"),
+ "兜底规则必须是 hasRole(\"USER\");退回 authenticated() 会让运营身份打进用户端并触发 NPE → 500");
+ }
+
+ // ---------------------------------------------------------------- helpers
+
+ /**
+ * 从 SecurityConfig 源码解析出 **OPERATOR 规则**的路径前缀(去掉结尾的 **)。
+ *
+ * 只取与 {@code .hasRole("OPERATOR")} 同一条链的 {@code requestMatchers}。
+ * 这一点必须严格:若把 permitAll 或兜底的 {@code /**} 也收进来,
+ * 任何路径都会被判为"已覆盖",本测试就变成永远通过的摆设。
+ */
+ private static Set operatorRulePrefixes() throws IOException {
+ String cfg = Files.readString(SECURITY_CONFIG);
+ Set prefixes = new LinkedHashSet<>();
+ Matcher blocks = Pattern.compile(
+ "requestMatchers\\(([^)]*)\\)\\s*\\.hasRole\\(\"OPERATOR\"\\)", Pattern.DOTALL).matcher(cfg);
+ while (blocks.find()) {
+ Matcher literals = Pattern.compile("\"([^\"]+)\"").matcher(blocks.group(1));
+ while (literals.find()) {
+ prefixes.add(literals.group(1));
+ }
+ }
+ return prefixes;
+ }
+
+ private static String firstMappingPath(String src) {
+ Matcher m = Pattern.compile("@RequestMapping\\(\\s*\"([^\"]*)\"").matcher(src);
+ return m.find() ? m.group(1) : "";
+ }
+
+ private static List methodMappingPaths(String src) {
+ List paths = new ArrayList<>();
+ Matcher m = Pattern.compile("@(?:Get|Post|Put|Patch|Delete)Mapping\\(\\s*\"([^\"]*)\"")
+ .matcher(src);
+ while (m.find()) {
+ paths.add(m.group(1));
+ }
+ return paths;
+ }
+
+ private static String join(String classPath, String methodPath) {
+ String base = classPath == null ? "" : classPath;
+ String tail = methodPath == null ? "" : methodPath;
+ String joined = (base + tail).replaceAll("/{2,}", "/");
+ return joined.isEmpty() ? "/" : joined;
+ }
+
+ private static boolean isOperatorPath(String path) {
+ // /auth/operator/** 是运营账号的登录/刷新端点,按设计属于公开或已认证接口,
+ // 由更靠前的 /auth 规则管辖,不应要求 OPERATOR 角色(否则登录本身就进不去)。
+ if (path.startsWith("/auth/")) {
+ return false;
+ }
+ return path.contains("/operator/") || path.startsWith("/operator") || path.equals("/operator");
+ }
+
+ private static boolean isCovered(String fullPath, Set prefixes) {
+ for (String prefix : prefixes) {
+ if (prefix.endsWith("/**")) {
+ String base = prefix.substring(0, prefix.length() - 3);
+ if (fullPath.equals(base) || fullPath.startsWith(base + "/")) {
+ return true;
+ }
+ } else if (fullPath.equals(prefix)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/docs/HTTP语义检修报告-2026-09-18.md b/docs/HTTP语义检修报告-2026-09-18.md
index d9963cb..88b0ca2 100644
--- a/docs/HTTP语义检修报告-2026-09-18.md
+++ b/docs/HTTP语义检修报告-2026-09-18.md
@@ -139,11 +139,11 @@ config.setAllowCredentials(true); // 允许携带凭证
### 3. 后端全量回归
```
-Tests run: 662, Failures: 0, Errors: 0, Skipped: 3 BUILD SUCCESS
+Tests run: 664, Failures: 0, Errors: 0, Skipped: 3 BUILD SUCCESS
```
-基线 656(Phase 23B 后),净 +6 为新增的
-`ApiResponseCodeHttpStatusTest`(6 条映射语义守卫)。
+基线 656(Phase 23B 后),净 +8:`ApiResponseCodeHttpStatusTest`(6 条映射语义守卫)
++ `SecurityPathCoverageTest`(2 条路径覆盖守卫,见第七节)。
3 个 skip 均有显式原因,无一为空壳。
首个回归轮次为 **662 / 3 failures**,三处失败均已判定为「测试断言了旧的错误行为」
@@ -185,13 +185,103 @@ Tests run: 662, Failures: 0, Errors: 0, Skipped: 3 BUILD SUCCESS
---
-## 七、结论
+## 七、补充:身份类型与路径规则的收敛(同日续做)
+
+修完 401/403 之后,顺着同一条线索继续查,发现一个**与错误响应质量直接相关**的更严重缺陷。
+
+### 1. 根因链条
+
+`PlatformPrincipal` 有两个实现:`OperatorPrincipal`(运营 token)与 `UserPrincipal`(用户 token),
+由 `JwtAuthFilter` 按 token 类型选择。
+
+而 `@AuthenticationPrincipal UserPrincipal` 遇到 `OperatorPrincipal` 时属**类型不匹配**,
+Spring Security 默认返回 **null**(`errorOnInvalidType=false`)。
+
+后果:全仓 **244 处**使用该注解,**仅 48 处**做了 null 检查。
+其余端点在运营身份访问时会直接 NPE → **500**。
+
+这不是理论风险——`SecurityConfig` 当时的兜底规则是 `authenticated()`,
+而运营 token 完全满足"已认证",请求可以一路打进用户端 Controller。
+
+### 2. 为什么不补 null 检查
+
+补上百处 `if (principal == null)` 是机械劳动,还会遗漏、也无法约束将来新增的代码。
+根因是**身份类型与端点不匹配**,应该在认证层解决,而不是在每个端点重复防御。
+
+### 3. 修法:兜底规则从「已认证」收紧为「用户身份」
+
+```java
+.requestMatchers("/**").hasRole("USER") // 原为 .authenticated()
+```
+
+运营身份访问用户端 → 403,请求根本进不到 Controller,196 处潜在 NPE 一次性消解。
+角色对应关系由 `JwtAuthFilter` 保证:运营 → `ROLE_OPERATOR`,用户 → `ROLE_USER`。
+
+### 4. ⚠️ 踩坑:`/**/operator/**` 是非法模式
+
+运营端点并非都在 `/operator/` 下——`/counseling/services/operator/queue`、
+`/content/operator/articles`、`/activities/operator/checkin/scan`、
+`/service-appointments/operator/list` 等嵌在业务路径深处。
+
+第一版试图用 `/**/operator/**` 一行通配,结果:
+
+```
+PatternParseException: No more pattern data allowed after {*...} or ** pattern element
+```
+
+**Spring 的 PathPattern 不允许 `**` 出现在中间**,只允许在末尾。
+更糟的是它的表现:整个过滤器链直接 **500**(Tomcat HTML 错误页,不是我们的 JSON),
+E2E 从 37 passed 变成 **33 failed**。
+
+改为显式枚举 9 个前缀(全部符合 PathPattern 语法):
+
+```
+/operator/** /admin/**
+/counseling/services/operator/** /content/operator/** /risk/operator/**
+/activities/operator/** /service-appointments/operator/**
+/service-programs/operator/** /referrals/operator/**
+```
+
+### 5. 机械守卫:`SecurityPathCoverageTest`
+
+显式枚举的代价是"将来新增会漏",而漏掉的表现是
+**运营人员被自己的平台拒绝(管理端对应模块不可用)**——只跑用户侧测试根本看不出来。
+
+因此新增守卫,**从 `SecurityConfig` 源码解析出 OPERATOR 规则前缀**,
+再扫描全部 `*Controller.java`,把「类级路径 + 方法级路径」拼成完整路径,
+校验每个含 `/operator/` 的端点都被覆盖(`/auth/operator/**` 按设计排除)。
+
+两个必须注意的细节:
+
+- **只能解析 `.hasRole("OPERATOR")` 那条链的规则。** 若把 permitAll 或兜底的 `/**` 也收进来,
+ 任何路径都会被判为"已覆盖",守卫就退化成永远通过的摆设。
+- **工作目录是仓库根而非模块根**(同项目 `MigrationValidationTest` 也用 `backend/target/...` 形式),
+ 故源码根需同时尝试两种候选路径。
+
+**负向验证**:摘掉 `/content/operator/**` 前缀后,恰好 1 个用例变红,
+并精确列出受影响的 6 个 `ContentController` 端点;恢复后全绿。
+
+### 6. 验证
+
+- E2E **40 passed**(原 37 + 3 条身份隔离用例:运营打用户端 403、
+ 用户打运营端 403、嵌套运营端点仍要求 OPERATOR)。
+- 后端全量回归 **664 / 0 failures / 0 errors / 3 skipped**(本轮起点 662,净 +2 为新守卫),
+ `SecurityPathCoverageTest` 已并入常规回归。
+- 首个 E2E 轮次因非法路径模式导致 **33 failed**(过滤器链 500);
+ 修正为显式前缀后全部恢复,佐证了"`/**/operator/**` 不可用"这一结论。
+
+---
+
+## 八、结论
本轮把「失败必须携带真实 HTTP 语义」从约定变成了机制:
- 状态码的**唯一来源**是 `ApiResponseCode.httpStatus`,新增业务码不声明就编译不过;
- 105 处 Controller 直返**零改动**即获得正确状态;
- 未认证与无权限在 HTTP 层分离,小程序终于能触发重新登录;
-- CORS 默认从「放行任意来源」改为「默认拒绝」。
+- CORS 默认从「放行任意来源」改为「默认拒绝」;
+- 兜底访问规则从「已认证」收紧为「用户身份」,196 处潜在 NPE → 500 在认证层消解,
+ 并有机械守卫防止将来新增运营端点时漏配规则。
-顺带修掉了一个当时就会伤害用户的缺陷:token 过期不再让用户卡死。
+顺带修掉了两个当时就会伤害真实用户的缺陷:
+**token 过期后用户卡死无法重登录**,以及**运营身份可打进用户端触发 500**。
diff --git a/tests/e2e/http_semantics_test.py b/tests/e2e/http_semantics_test.py
index c3c676d..fa7de2f 100644
--- a/tests/e2e/http_semantics_test.py
+++ b/tests/e2e/http_semantics_test.py
@@ -86,3 +86,50 @@ def test_not_found_is_used_to_avoid_existence_probing(api, user_token):
assert resp.status_code == 404, (
f"非本人 Match 应回 404(避免存在性探测),实际 {resp.status_code}"
)
+
+
+def test_operator_identity_cannot_reach_user_endpoints(api, admin_token):
+ """
+ 运营身份访问用户端端点必须 403,且请求不得进入 Controller。
+
+ 原因:`@AuthenticationPrincipal UserPrincipal` 遇到 OperatorPrincipal 属类型不匹配,
+ Spring Security 默认返回 null(errorOnInvalidType=false)。全仓 244 处使用该注解、
+ 仅 48 处做了 null 检查,其余端点会 NPE → 500。
+
+ 与其补上百处 null 判断,不如在认证层按身份挡住。
+ 若这里返回 500(而非 403),说明拦截规则失效、请求又进到了 Controller。
+ """
+ for path in ("/notifications", "/me"):
+ resp = api.call("GET", path, token=admin_token)
+ assert resp.status_code == 403, (
+ f"运营 token 访问用户端 {path} 应回 403,实际 {resp.status_code}:{resp.text[:300]}"
+ )
+ body = resp.json()
+ assert body.get("code") == "FORBIDDEN", f"{path} 业务码应为 FORBIDDEN:{body}"
+
+
+def test_user_identity_cannot_reach_operator_endpoints(api, user_token):
+ """反向:用户身份打运营端点同样 403(不得因改动而放开)。"""
+ resp = api.call("GET", "/operator/banners", token=user_token)
+ assert resp.status_code == 403, (
+ f"用户 token 访问运营端应回 403,实际 {resp.status_code}:{resp.text[:300]}"
+ )
+
+
+def test_nested_operator_endpoints_still_require_operator_role(api, user_token):
+ """
+ 嵌套在业务路径下的运营端点也必须挡住。
+
+ `/counseling/services/operator/queue`、`/content/operator/articles`、
+ `/activities/operator/checkin/scan` 这类端点根路径不是 /operator/,
+ 若拦截规则只写 `/operator/**` 就会漏掉它们。
+ """
+ for path in (
+ "/counseling/services/operator/queue",
+ "/content/operator/articles",
+ "/service-appointments/operator/list",
+ ):
+ resp = api.call("GET", path, token=user_token)
+ assert resp.status_code == 403, (
+ f"用户 token 访问嵌套运营端点 {path} 应回 403,实际 {resp.status_code}"
+ )