Просмотр исходного кода

fix(auth): /api/growth/record|plan 强制登录,修复拦截器被 WebConfig 排除致 userId 缺失 500

根因:/api/growth/** 同时在 WebConfig 的 jwtInterceptor excludePathPatterns
与 JwtInterceptor.PUBLIC_PATHS 中,拦截器对 growth 整族不执行/匿名放行,
GrowthRecordController.record/** 与 GrowthPlanController.plan/** 的
@RequestAttribute("userId") 永不注入,无 token 一律 500
"Missing request attribute userId"(线上小程序 /api/growth/record/list)。

修复:
- WebConfig 移除 jwtInterceptor 对 /api/growth/** 的 exclude
- JwtInterceptor.PUBLIC_PATHS 改为精确公开子端点(recommendations/ai-classify/external-sync)
- LOGIN_REQUIRED_PATHS 增加 /api/growth/record/ 与 /api/growth/plan/(无 token 401,
  客户端 api.js 401→静默续期→重放自动恢复)

测试:GrowthAuthContractTest(端到端 401)+ WebConfigGrowthExcludeRemovedTest(注册链,无 DB)
Xiaogang Liao 1 день назад
Родитель
Сommit
1d8bd52674

+ 14 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -71,7 +71,12 @@ private static final String[] PUBLIC_PATHS = {
         "/api/articles/related",
         "/api/articles/related-by-product",
         "/api/content/related-items",
-         "/api/growth/**",
+         // 成长板块:record/**、plan/** 为成员私有数据(见 LOGIN_REQUIRED_PATHS,必须登录);
+         // 以下为公开子端点(无 userId 依赖),匿名直通时不注入 userId 也不拦截:
+         "/api/growth/recommendations",            // 成长板块推荐内容(公开)
+         "/api/growth/articles/ai-classify",       // 运营/批处理文章分类(公开)
+         "/api/growth/products/ai-classify",       // 运营/批处理商品分类(公开)
+         "/api/growth/external/sync",              // 外部数据同步(公开)
          "/api/activity/list",
          "/api/activity/detail",
          "/api/activity/order/notify",
@@ -117,6 +122,14 @@ private static final String[] PUBLIC_PATHS = {
         "/api/tasks/",                      // 任务:家庭私有数据(今日/历史/审核/完成)
         "/api/energy/",                     // 五维能量:用户私有数据(sandbox/logs/grant/score-history...)
         "/api/wishes/",                     // 心愿:家庭私有数据
+        // 修复(2026-11-12):/api/growth/** 此前同时在 WebConfig 的 jwtInterceptor excludePathPatterns
+        // 与 PUBLIC_PATHS 中,拦截器对 growth 整族不执行/匿名放行,导致 GrowthRecordController.record/** 与
+        // GrowthPlanController.plan/** 的 @RequestAttribute("userId") 永不注入,无 token 一律
+        // 500 "Missing request attribute 'userId'"(线上小程序 /api/growth/record/list 报错)。
+        // 现从 WebConfig exclude 移除并在此强制登录:无 token 直接 401(客户端 api.js 401→静默续期→重放恢复),
+        // 有 token 正常注入 userId。公开子端点(recommendations/ai-classify/external-sync)见 PUBLIC_PATHS。
+        "/api/growth/record/",              // 成长记录:创建/列表/详情/上传/删除,成员私有数据,必须登录
+        "/api/growth/plan/",                // 成长方案:创建/列表/审核,成员私有数据,必须登录
         "/api/user/info",                   // 用户资料:个人私有
         "/api/user/family-members",         // 家庭成员列表:家庭私有
         "/api/user/children/list",          // 子女列表:家庭私有

+ 0 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java

@@ -122,7 +122,6 @@ public class WebConfig implements WebMvcConfigurer {
                 "/api/articles/related-products",
                 "/api/articles/related",
                 "/api/articles/related-by-product",
-                "/api/growth/**",
                 "/api/articles/updated-since",
                 "/api/activity/list",
                 "/api/activity/detail",

+ 103 - 0
cfc-backend/src/test/java/com/etotem/cfc/config/WebConfigGrowthExcludeRemovedTest.java

@@ -0,0 +1,103 @@
+package com.etotem.cfc.config;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.web.servlet.HandlerInterceptor;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.handler.MappedInterceptor;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * WebConfig 注册链契约(回归 2026-11-12):/api/growth/** 不得再出现在
+ * jwtInterceptor 的 excludePathPatterns 中 —— 否则拦截器对 growth 整族不执行,
+ * GrowthRecordController.record/** 的 @RequestAttribute("userId") 永不注入,
+ * 无 token(甚至带 token)一律 500 "Missing request attribute 'userId'"。
+ *
+ * <p>纯单元(无 DB/Spring 上下文),任何环境可跑;与端到端
+ * {@link com.etotem.cfc.controller.growth.GrowthAuthContractTest} 互补。
+ */
+class WebConfigGrowthExcludeRemovedTest {
+
+    @Mock
+    private JwtInterceptor jwtInterceptor;
+
+    @Mock
+    private FamilyAccessInterceptor familyAccessInterceptor;
+
+    @Mock
+    private OperationLogInterceptor operationLogInterceptor;
+
+    @Mock
+    private RateLimitInterceptor rateLimitInterceptor;
+
+    @InjectMocks
+    private WebConfig webConfig;
+
+    private List<MappedInterceptor> mapped;
+
+    @BeforeEach
+    @SuppressWarnings("unchecked")
+    void setUp() throws Exception {
+        MockitoAnnotations.openMocks(this);
+        InterceptorRegistry registry = new InterceptorRegistry();
+        webConfig.addInterceptors(registry);
+        Method getInterceptors = InterceptorRegistry.class.getDeclaredMethod("getInterceptors");
+        getInterceptors.setAccessible(true);
+        List<Object> raw = (List<Object>) getInterceptors.invoke(registry);
+        assertNotNull(raw, "注册列表不应为空");
+        mapped = new ArrayList<>();
+        for (Object o : raw) {
+            mapped.add((MappedInterceptor) o);
+        }
+    }
+
+    private MappedInterceptor findMapped(HandlerInterceptor ic) {
+        return mapped.stream()
+                .filter(m -> m.getInterceptor() == ic)
+                .findFirst()
+                .orElseThrow(() -> new AssertionError("拦截器未注册: " + ic));
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<String> excludePatterns(MappedInterceptor mi) throws Exception {
+        Field f = MappedInterceptor.class.getDeclaredField("excludePatterns");
+        f.setAccessible(true);
+        Object[] arr = (Object[]) f.get(mi);
+        List<String> res = new ArrayList<>();
+        if (arr != null) {
+            for (Object pa : arr) {
+                Method g = pa.getClass().getMethod("getPatternString");
+                g.setAccessible(true); // 类包私有(org.springframework.web.servlet.handler),方法虽 public 亦需放行
+                res.add((String) g.invoke(pa));
+            }
+        }
+        return res;
+    }
+
+    @Test
+    void jwtInterceptor_不得再将growth_排除出拦截链() throws Exception {
+        MappedInterceptor m = findMapped(jwtInterceptor);
+        List<String> excludes = excludePatterns(m);
+        assertFalse(excludes.contains("/api/growth/**"),
+                "jwtInterceptor excludes 不得包含 /api/growth/**(否则 growth 无 userId 注入,恒 500)。当前=" + excludes);
+        assertTrue(Arrays.asList(m.getPathPatterns()).contains("/api/**"), "jwtInterceptor 应覆盖 /api/**");
+    }
+
+    @Test
+    void familyAccessInterceptor_保持原有growth_排除不变() throws Exception {
+        MappedInterceptor m = findMapped(familyAccessInterceptor);
+        assertTrue(excludePatterns(m).contains("/api/growth/**"),
+                "familyAccessInterceptor excludes 应保持 /api/growth/**(本次只动 jwtInterceptor)");
+    }
+}

+ 44 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/growth/GrowthAuthContractTest.java

@@ -0,0 +1,44 @@
+package com.etotem.cfc.controller.growth;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.web.servlet.MockMvc;
+
+/**
+ * 成长板块鉴权契约(回归):/api/growth/record/** 与 /api/growth/plan/** 为成员私有数据,
+ * 匿名(无 token)访问必须由 JwtInterceptor 直接返回 HTTP 401(writeUnauthorized 写 401 + {"code":401}),
+ * 而非匿名(userId=-1 / userId 缺失)放行到 controller 后走"业务 500"口径漂移。
+ *
+ * <p>回归根因:/api/growth/** 此前同时存在于 WebConfig 的 jwtInterceptor excludePathPatterns
+ * (拦截器对 growth 整族从不执行 → @RequestAttribute("userId") 永不注入)与
+ * JwtInterceptor.PUBLIC_PATHS(匿名放行)中,导致 GET /api/growth/record/list 带不带 token 一律
+ * 500 "Missing request attribute 'userId'"。本测试走完整 WebConfig→JwtInterceptor 注册链,
+ * 能同时捕捉两处缺陷(若 WebConfig 仍排除 growth,请求将穿透到 controller 返回 500 而非 401)。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+class GrowthAuthContractTest {
+
+    @Autowired
+    private MockMvc mockMvc;
+
+    @Test
+    void 匿名访问成长记录列表_应401() throws Exception {
+        mockMvc.perform(post("/api/growth/record/list"))
+                .andExpect(status().isUnauthorized())
+                .andExpect(jsonPath("$.code").value(401));
+    }
+
+    @Test
+    void 匿名访问成长方案列表_应401() throws Exception {
+        mockMvc.perform(post("/api/growth/plan/list"))
+                .andExpect(status().isUnauthorized())
+                .andExpect(jsonPath("$.code").value(401));
+    }
+}