Browse Source

feat(growth): 成长板块推荐文章/商品 + AI自动分类

- 数据库: articles/products 新增 growth_category 字段 (health/growth)
- 后端: GrowthRecommendationService 推荐查询 + AI分类
- 后端: GrowthController /api/growth/recommendations + /ai-classify
- 前端: growth-main 新增推荐文章+推荐商品板块
iwt 1 month ago
parent
commit
d8bd239c3b

+ 190 - 189
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -1,191 +1,192 @@
-package com.etotem.cfc.config;
-
-import com.etotem.cfc.entity.FamilyMember;
-import com.etotem.cfc.entity.User;
-import com.etotem.cfc.mapper.FamilyMemberMapper;
-import com.etotem.cfc.mapper.UserMapper;
-import io.jsonwebtoken.Claims;
-import lombok.extern.slf4j.Slf4j;
-import javax.annotation.Resource;
-import org.springframework.stereotype.Component;
-import java.util.Collections;
-import java.util.List;
-import org.springframework.util.StringUtils;
-import org.springframework.web.servlet.HandlerInterceptor;
-
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-@Slf4j
-@Component
-public class JwtInterceptor implements HandlerInterceptor {
-
-    @Resource
-    private JwtConfig jwtConfig;
-
-    @Resource
-    private UserMapper userMapper;
-
-    @Resource
-    private FamilyMemberMapper familyMemberMapper;
-
-    private static final String[] PUBLIC_PATHS = {
-        "/api/auth/send-code",
-        "/api/auth/phone-login",
-        "/api/auth/wechat-phone-login",
-        "/api/auth/silent-login",
-        "/api/auth/auto-login",
-        "/api/auth/register-with-idcard",
-        "/api/auth/wechat-login",
-        "/api/auth/verify",
-        "/api/auth/check-phone",
-        "/api/auth/direct-register",
-        "/api/auth/register-with-invite",
-        "/api/internal/",
-        "/api/product/list",
-        "/api/product/detail",
-        "/api/product/type-list",
-        "/api/mini-game/list",
-        "/api/admin-auth/send-code",
-        "/api/admin-auth/login",
-        "/api/admin-auth/login-by-password",
-        "/api/articles/list",
-        "/api/articles/detail",
-        "/api/articles/categories",
-        "/api/articles/featured",
-        "/api/articles/record-read",
-        "/api/activity/list",
-        "/api/activity/detail",
-        "/api/activity/order/notify",
-        "/api/content-sections/visible",
-        "/api/mind/alert/active",
-        "/api/mind/alert/checkin-alerts",
-        "/api/mind/traditional/mirror",
-        "/api/mind/traditional/compatibility"
-    };
-
-    // 必须登录才能访问的路径:anonymous 访问直接 401(这些接口消耗微信配额/写数据/扣积分)
-    private static final String[] LOGIN_REQUIRED_PATHS = {
-        "/api/share/qrcode"     // 生成海报小程序码:anonymous 会反复触发微信 access_token 失效("not latest")
-    };
-
-    @Override
-    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
-        // 放 OPTIONS 请求
-        if ("OPTIONS".equals(request.getMethod())) {
-            return true;
-        }
-
-        String path = request.getRequestURI();
-        for (String publicPath : PUBLIC_PATHS) {
-            if (path.equals(publicPath) || path.startsWith(publicPath)) {
-                return true;
-            }
-        }
-
-        // 获取token
-        String token = request.getHeader("Authorization");
-
-        if (!StringUtils.hasText(token)) {
-            // 尝试从参数中获取
-            token = request.getParameter("token");
-        }
-
-        boolean hasToken = StringUtils.hasText(token);
-        boolean loginRequired = false;
-        for (String requiredPath : LOGIN_REQUIRED_PATHS) {
-            if (path.equals(requiredPath) || path.startsWith(requiredPath)) {
-                loginRequired = true;
-                break;
-            }
-        }
-
-        if (!hasToken) {
-            if (loginRequired) {
-                log.debug("请求 {} 需登录但无Token,拒绝", path);
-                writeUnauthorized(response);
-                return false;
-            }
-            log.debug("请求 {} 无Token,设为匿名用户", path);
-            setAnonymousUser(request);
-            return true;
-        }
-
-        // 去掉Bearer前缀
-        if (token.startsWith("Bearer ")) {
-            token = token.substring(7);
-        }
-
-        try {
-            // 解析token
-            Claims claims = jwtConfig.parseToken(token);
-            Long userId = Long.parseLong(claims.getSubject());
-            String role = (String) claims.get("role");
-            @SuppressWarnings("unchecked")
-            List<String> roles = (List<String>) claims.get("roles");
-            if (roles == null) {
-                roles = role != null ? Collections.singletonList(role) : Collections.emptyList();
-            }
-
-            // 添加自定义请求头,供下游使用
-            response.setHeader("X-User-Id", userId.toString());
-            response.setHeader("X-User-Role", role);
-            request.setAttribute("userId", userId);
-            request.setAttribute("role", role);
-            request.setAttribute("roles", roles);
-
- // 设置familyId(查询用户所属家庭)
-            try {
-                User user = userMapper.selectById(userId);
-                Long userFamilyId = (user != null) ? user.getFamilyId() : null;
-                request.setAttribute("familyId", userFamilyId);
-            } catch (Exception e) {
-                log.warn("查询用户familyId失败: userId={}, error={}", userId, e.getMessage());
-                request.setAttribute("familyId", null);
+package com.etotem.cfc.config;
+
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import io.jsonwebtoken.Claims;
+import lombok.extern.slf4j.Slf4j;
+import javax.annotation.Resource;
+import org.springframework.stereotype.Component;
+import java.util.Collections;
+import java.util.List;
+import org.springframework.util.StringUtils;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@Slf4j
+@Component
+public class JwtInterceptor implements HandlerInterceptor {
+
+    @Resource
+    private JwtConfig jwtConfig;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    private static final String[] PUBLIC_PATHS = {
+        "/api/auth/send-code",
+        "/api/auth/phone-login",
+        "/api/auth/wechat-phone-login",
+        "/api/auth/silent-login",
+        "/api/auth/auto-login",
+        "/api/auth/register-with-idcard",
+        "/api/auth/wechat-login",
+        "/api/auth/verify",
+        "/api/auth/check-phone",
+        "/api/auth/direct-register",
+        "/api/auth/register-with-invite",
+        "/api/internal/",
+        "/api/product/list",
+        "/api/product/detail",
+        "/api/product/type-list",
+        "/api/mini-game/list",
+        "/api/admin-auth/send-code",
+        "/api/admin-auth/login",
+        "/api/admin-auth/login-by-password",
+        "/api/articles/list",
+        "/api/articles/detail",
+        "/api/articles/categories",
+        "/api/articles/featured",
+        "/api/articles/record-read",
+        "/api/growth/**",
+        "/api/activity/list",
+        "/api/activity/detail",
+        "/api/activity/order/notify",
+        "/api/content-sections/visible",
+        "/api/mind/alert/active",
+        "/api/mind/alert/checkin-alerts",
+        "/api/mind/traditional/mirror",
+        "/api/mind/traditional/compatibility"
+    };
+
+    // 必须登录才能访问的路径:anonymous 访问直接 401(这些接口消耗微信配额/写数据/扣积分)
+    private static final String[] LOGIN_REQUIRED_PATHS = {
+        "/api/share/qrcode"     // 生成海报小程序码:anonymous 会反复触发微信 access_token 失效("not latest")
+    };
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
+        // 放 OPTIONS 请求
+        if ("OPTIONS".equals(request.getMethod())) {
+            return true;
+        }
+
+        String path = request.getRequestURI();
+        for (String publicPath : PUBLIC_PATHS) {
+            if (path.equals(publicPath) || path.startsWith(publicPath)) {
+                return true;
             }
-
-            // 设置currentMemberId和familyMemberId:从family_member表查找当前用户对应的家庭成员
-            Long familyId = (Long) request.getAttribute("familyId");
-            if (familyId != null) {
-                try {
-                    FamilyMember member = familyMemberMapper.selectOne(
-                        new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<FamilyMember>()
-                            .eq(FamilyMember::getUserId, userId)
-                            .eq(FamilyMember::getFamilyId, familyId)
-                            .last("LIMIT 1")
-                    );
-                    if (member != null) {
-                        request.setAttribute("currentMemberId", member.getId());
-                        request.setAttribute("familyMemberId", member.getId());
-                    }
-                } catch (Exception e) {
-                    log.warn("查询用户对应family_member失败: userId={}, error={}", userId, e.getMessage());
-                }
-            }
-
-            return true;
-        } catch (Exception e) {
-            // Token 无效/过期一律 401(不区分 loginRequired):
-            // 避免前端带残留 token 时被静默降级为匿名,导致需登录接口返回
-            // "请先登录"(code=500) 而前端仍认为已登录。前端收到 401 后
-            // 会清理本地登录态并引导重新登录。
-            log.warn("Token解析失败, 拒绝请求: path={}, error={}", path, e.getMessage());
-            writeUnauthorized(response);
-            return false;
-        }
-    }
-
-    private void writeUnauthorized(HttpServletResponse response) throws java.io.IOException {
-        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
-        response.setContentType("application/json;charset=UTF-8");
-        response.getWriter().write("{\"code\":401,\"message\":\"请先登录\",\"data\":null}");
-    }
-
-    private void setAnonymousUser(HttpServletRequest request) {
-        request.setAttribute("userId", -1L);
-        request.setAttribute("role", "anonymous");
-        request.setAttribute("roles", Collections.singletonList("anonymous"));
-        // familyId 属性必须始终存在,避免 @RequestAttribute("familyId") 抛 ServletRequestBindingException
-        request.setAttribute("familyId", null);
-    }
+        }
+
+        // 获取token
+        String token = request.getHeader("Authorization");
+
+        if (!StringUtils.hasText(token)) {
+            // 尝试从参数中获取
+            token = request.getParameter("token");
+        }
+
+        boolean hasToken = StringUtils.hasText(token);
+        boolean loginRequired = false;
+        for (String requiredPath : LOGIN_REQUIRED_PATHS) {
+            if (path.equals(requiredPath) || path.startsWith(requiredPath)) {
+                loginRequired = true;
+                break;
+            }
+        }
+
+        if (!hasToken) {
+            if (loginRequired) {
+                log.debug("请求 {} 需登录但无Token,拒绝", path);
+                writeUnauthorized(response);
+                return false;
+            }
+            log.debug("请求 {} 无Token,设为匿名用户", path);
+            setAnonymousUser(request);
+            return true;
+        }
+
+        // 去掉Bearer前缀
+        if (token.startsWith("Bearer ")) {
+            token = token.substring(7);
+        }
+
+        try {
+            // 解析token
+            Claims claims = jwtConfig.parseToken(token);
+            Long userId = Long.parseLong(claims.getSubject());
+            String role = (String) claims.get("role");
+            @SuppressWarnings("unchecked")
+            List<String> roles = (List<String>) claims.get("roles");
+            if (roles == null) {
+                roles = role != null ? Collections.singletonList(role) : Collections.emptyList();
+            }
+
+            // 添加自定义请求头,供下游使用
+            response.setHeader("X-User-Id", userId.toString());
+            response.setHeader("X-User-Role", role);
+            request.setAttribute("userId", userId);
+            request.setAttribute("role", role);
+            request.setAttribute("roles", roles);
+
+ // 设置familyId(查询用户所属家庭)
+            try {
+                User user = userMapper.selectById(userId);
+                Long userFamilyId = (user != null) ? user.getFamilyId() : null;
+                request.setAttribute("familyId", userFamilyId);
+            } catch (Exception e) {
+                log.warn("查询用户familyId失败: userId={}, error={}", userId, e.getMessage());
+                request.setAttribute("familyId", null);
+            }
+
+            // 设置currentMemberId和familyMemberId:从family_member表查找当前用户对应的家庭成员
+            Long familyId = (Long) request.getAttribute("familyId");
+            if (familyId != null) {
+                try {
+                    FamilyMember member = familyMemberMapper.selectOne(
+                        new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<FamilyMember>()
+                            .eq(FamilyMember::getUserId, userId)
+                            .eq(FamilyMember::getFamilyId, familyId)
+                            .last("LIMIT 1")
+                    );
+                    if (member != null) {
+                        request.setAttribute("currentMemberId", member.getId());
+                        request.setAttribute("familyMemberId", member.getId());
+                    }
+                } catch (Exception e) {
+                    log.warn("查询用户对应family_member失败: userId={}, error={}", userId, e.getMessage());
+                }
+            }
+
+            return true;
+        } catch (Exception e) {
+            // Token 无效/过期一律 401(不区分 loginRequired):
+            // 避免前端带残留 token 时被静默降级为匿名,导致需登录接口返回
+            // "请先登录"(code=500) 而前端仍认为已登录。前端收到 401 后
+            // 会清理本地登录态并引导重新登录。
+            log.warn("Token解析失败, 拒绝请求: path={}, error={}", path, e.getMessage());
+            writeUnauthorized(response);
+            return false;
+        }
+    }
+
+    private void writeUnauthorized(HttpServletResponse response) throws java.io.IOException {
+        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+        response.setContentType("application/json;charset=UTF-8");
+        response.getWriter().write("{\"code\":401,\"message\":\"请先登录\",\"data\":null}");
+    }
+
+    private void setAnonymousUser(HttpServletRequest request) {
+        request.setAttribute("userId", -1L);
+        request.setAttribute("role", "anonymous");
+        request.setAttribute("roles", Collections.singletonList("anonymous"));
+        // familyId 属性必须始终存在,避免 @RequestAttribute("familyId") 抛 ServletRequestBindingException
+        request.setAttribute("familyId", null);
+    }
 }

+ 171 - 169
cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java

@@ -1,170 +1,172 @@
-package com.etotem.cfc.config;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.core.Ordered;
-import org.springframework.core.annotation.Order;
-import org.springframework.web.servlet.config.annotation.CorsRegistry;
-import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
-import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
-import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
-
-import javax.annotation.Resource;
-import javax.servlet.*;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import java.io.IOException;
-
-@Configuration
-public class WebConfig implements WebMvcConfigurer {
-
-    private static final Logger log = LoggerFactory.getLogger(WebConfig.class);
-
-    @Resource
-    private JwtInterceptor jwtInterceptor;
-
-    @Resource
-    private FamilyAccessInterceptor familyAccessInterceptor;
-
-    @Resource
-    private OperationLogInterceptor operationLogInterceptor;
-
-    @Resource
-    private RateLimitInterceptor rateLimitInterceptor;
-
-    @Value("${upload.base-dir:/data/cfc-uploads}")
-    private String uploadBaseDir;
-
-    @Value("${storage.type:local}")
-    private String storageType;
-
-    @Override
-    public void addCorsMappings(CorsRegistry registry) {
-        registry.addMapping("/**")
-                .allowedOriginPatterns("https://cfc.etotem.com.cn", "http://cfc.etotem.com.cn", "https://www.etotem.com.cn", "http://www.etotem.com.cn", "https://cf-club.iwintrue.com", "http://cf-club.iwintrue.com", "https://www.cf-club.com", "http://www.cf-club.com", "https://cfc.cf-club.com", "http://cfc.cf-club.com", "https://111.228.6.214", "http://111.228.6.214", "http://localhost:*", "http://127.0.0.1:*")
-                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
-                .allowedHeaders("*")
-                .allowCredentials(true)
-                .maxAge(3600);
-    }
-
-    /** Chrome Private Network Access: allow requests from non-secure contexts to private-network backend */
-    @Bean
-    @Order(Ordered.HIGHEST_PRECEDENCE)
-    public Filter privateNetworkAccessFilter() {
-        return new Filter() {
-            @Override
-            public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
-                    throws IOException, ServletException {
-                HttpServletRequest req = (HttpServletRequest) request;
-                HttpServletResponse resp = (HttpServletResponse) response;
-                resp.setHeader("Access-Control-Allow-Private-Network", "true");
-                chain.doFilter(request, response);
-            }
-        };
-    }
-
-    /** 诊断过滤器:记录所有请求的来源和状态码 */
-    @Bean
-    @Order(Ordered.HIGHEST_PRECEDENCE + 1)
-    public Filter diagnosticFilter() {
-        return new Filter() {
-            @Override
-            public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
-                    throws IOException, ServletException {
-                HttpServletRequest req = (HttpServletRequest) request;
-                long start = System.currentTimeMillis();
-                chain.doFilter(request, response);
-                int status = ((HttpServletResponse) response).getStatus();
-                long ms = System.currentTimeMillis() - start;
-                if (status == 403 || req.getRequestURI().contains("/api/payment")) {
-                    log.warn("[DIAG] {} {} -> {} ({}ms) origin={} ua={}",
-                            req.getMethod(), req.getRequestURI(), status, ms,
-                            req.getHeader("Origin"),
-                            req.getHeader("User-Agent"));
-                }
-            }
-        };
-    }
-
-    @Override
-    public void addInterceptors(InterceptorRegistry registry) {
-        registry.addInterceptor(jwtInterceptor)
-                .addPathPatterns("/api/**")
-                .excludePathPatterns(
-                "/api/auth/send-code",
-                "/api/auth/phone-login",
-                "/api/auth/wechat-login",
-                "/api/auth/wechat-phone-login",
-                "/api/auth/check-phone",
-                "/api/auth/direct-register",
-                "/api/auth/register-with-idcard",
-                "/api/admin-auth/send-code",
-                "/api/admin-auth/login",
-                "/api/admin-auth/login-by-password",
-                "/api/product/list",
-                "/api/product/detail",
-                "/api/product/type-list",
-                "/api/mini-game/list",
-                "/api/articles/list",
-                "/api/articles/detail",
-                "/api/articles/categories",
-                "/api/articles/featured",
-                "/api/articles/record-read",
-                "/api/articles/updated-since",
-                "/api/activity/list",
-                "/api/activity/detail",
-                "/api/ai/context",
-                "/api/config/public/**",
-                "/api/payment/wechat/native",
-                "/api/payment/wechat/create",
-                "/api/payment/wechat/status",
-                "/api/payment/wechat/oauth2-url",
-                "/api/payment/wechat/oauth2-callback"
-        );
-
-        registry.addInterceptor(operationLogInterceptor)
-                .addPathPatterns("/api/**");
-
-        registry.addInterceptor(rateLimitInterceptor)
-                .addPathPatterns("/api/**");
-
-        registry.addInterceptor(familyAccessInterceptor)
-                .addPathPatterns("/api/**")
-                .excludePathPatterns(
-                "/api/auth/send-code",
-                "/api/auth/phone-login",
-                "/api/auth/wechat-login",
-                "/api/auth/wechat-phone-login",
-                "/api/auth/check-phone",
-                "/api/auth/direct-register",
-                "/api/auth/register-with-idcard",
-                "/api/admin-auth/send-code",
-                "/api/admin-auth/login",
-                "/api/admin-auth/login-by-password",
-                "/api/product/list",
-                "/api/product/detail",
-                "/api/product/type-list",
-                "/api/mini-game/list",
-                "/api/articles/list",
-                "/api/articles/detail",
-                "/api/articles/categories",
-                "/api/articles/featured",
-                "/api/articles/record-read",
-                "/api/activity/list",
-                "/api/activity/detail"
-        );
-    }
-
-    @Override
-    public void addResourceHandlers(ResourceHandlerRegistry registry) {
-        // 仅在本地存储模式下注册静态资源映射;jdcloud 模式文件走京东云公网URL
-        if ("local".equals(storageType)) {
-            registry.addResourceHandler("/uploads/**")
-                    .addResourceLocations("file:" + uploadBaseDir + "/");
-        }
-    }
+package com.etotem.cfc.config;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import javax.annotation.Resource;
+import javax.servlet.*;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+@Configuration
+public class WebConfig implements WebMvcConfigurer {
+
+    private static final Logger log = LoggerFactory.getLogger(WebConfig.class);
+
+    @Resource
+    private JwtInterceptor jwtInterceptor;
+
+    @Resource
+    private FamilyAccessInterceptor familyAccessInterceptor;
+
+    @Resource
+    private OperationLogInterceptor operationLogInterceptor;
+
+    @Resource
+    private RateLimitInterceptor rateLimitInterceptor;
+
+    @Value("${upload.base-dir:/data/cfc-uploads}")
+    private String uploadBaseDir;
+
+    @Value("${storage.type:local}")
+    private String storageType;
+
+    @Override
+    public void addCorsMappings(CorsRegistry registry) {
+        registry.addMapping("/**")
+                .allowedOriginPatterns("https://cfc.etotem.com.cn", "http://cfc.etotem.com.cn", "https://www.etotem.com.cn", "http://www.etotem.com.cn", "https://cf-club.iwintrue.com", "http://cf-club.iwintrue.com", "https://www.cf-club.com", "http://www.cf-club.com", "https://cfc.cf-club.com", "http://cfc.cf-club.com", "https://111.228.6.214", "http://111.228.6.214", "http://localhost:*", "http://127.0.0.1:*")
+                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
+                .allowedHeaders("*")
+                .allowCredentials(true)
+                .maxAge(3600);
+    }
+
+    /** Chrome Private Network Access: allow requests from non-secure contexts to private-network backend */
+    @Bean
+    @Order(Ordered.HIGHEST_PRECEDENCE)
+    public Filter privateNetworkAccessFilter() {
+        return new Filter() {
+            @Override
+            public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+                    throws IOException, ServletException {
+                HttpServletRequest req = (HttpServletRequest) request;
+                HttpServletResponse resp = (HttpServletResponse) response;
+                resp.setHeader("Access-Control-Allow-Private-Network", "true");
+                chain.doFilter(request, response);
+            }
+        };
+    }
+
+    /** 诊断过滤器:记录所有请求的来源和状态码 */
+    @Bean
+    @Order(Ordered.HIGHEST_PRECEDENCE + 1)
+    public Filter diagnosticFilter() {
+        return new Filter() {
+            @Override
+            public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+                    throws IOException, ServletException {
+                HttpServletRequest req = (HttpServletRequest) request;
+                long start = System.currentTimeMillis();
+                chain.doFilter(request, response);
+                int status = ((HttpServletResponse) response).getStatus();
+                long ms = System.currentTimeMillis() - start;
+                if (status == 403 || req.getRequestURI().contains("/api/payment")) {
+                    log.warn("[DIAG] {} {} -> {} ({}ms) origin={} ua={}",
+                            req.getMethod(), req.getRequestURI(), status, ms,
+                            req.getHeader("Origin"),
+                            req.getHeader("User-Agent"));
+                }
+            }
+        };
+    }
+
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(jwtInterceptor)
+                .addPathPatterns("/api/**")
+                .excludePathPatterns(
+                "/api/auth/send-code",
+                "/api/auth/phone-login",
+                "/api/auth/wechat-login",
+                "/api/auth/wechat-phone-login",
+                "/api/auth/check-phone",
+                "/api/auth/direct-register",
+                "/api/auth/register-with-idcard",
+                "/api/admin-auth/send-code",
+                "/api/admin-auth/login",
+                "/api/admin-auth/login-by-password",
+                "/api/product/list",
+                "/api/product/detail",
+                "/api/product/type-list",
+                "/api/mini-game/list",
+                "/api/articles/list",
+                "/api/articles/detail",
+                "/api/articles/categories",
+                "/api/articles/featured",
+                "/api/articles/record-read",
+                "/api/growth/**",
+                "/api/articles/updated-since",
+                "/api/activity/list",
+                "/api/activity/detail",
+                "/api/ai/context",
+                "/api/config/public/**",
+                "/api/payment/wechat/native",
+                "/api/payment/wechat/create",
+                "/api/payment/wechat/status",
+                "/api/payment/wechat/oauth2-url",
+                "/api/payment/wechat/oauth2-callback"
+        );
+
+        registry.addInterceptor(operationLogInterceptor)
+                .addPathPatterns("/api/**");
+
+        registry.addInterceptor(rateLimitInterceptor)
+                .addPathPatterns("/api/**");
+
+        registry.addInterceptor(familyAccessInterceptor)
+                .addPathPatterns("/api/**")
+                .excludePathPatterns(
+                "/api/auth/send-code",
+                "/api/auth/phone-login",
+                "/api/auth/wechat-login",
+                "/api/auth/wechat-phone-login",
+                "/api/auth/check-phone",
+                "/api/auth/direct-register",
+                "/api/auth/register-with-idcard",
+                "/api/admin-auth/send-code",
+                "/api/admin-auth/login",
+                "/api/admin-auth/login-by-password",
+                "/api/product/list",
+                "/api/product/detail",
+                "/api/product/type-list",
+                "/api/mini-game/list",
+                "/api/articles/list",
+                "/api/articles/detail",
+                "/api/articles/categories",
+                "/api/articles/featured",
+                "/api/articles/record-read",
+                "/api/growth/**",
+                "/api/activity/list",
+                "/api/activity/detail"
+        );
+    }
+
+    @Override
+    public void addResourceHandlers(ResourceHandlerRegistry registry) {
+        // 仅在本地存储模式下注册静态资源映射;jdcloud 模式文件走京东云公网URL
+        if ("local".equals(storageType)) {
+            registry.addResourceHandler("/uploads/**")
+                    .addResourceLocations("file:" + uploadBaseDir + "/");
+        }
+    }
 }

+ 48 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/GrowthController.java

@@ -0,0 +1,48 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.GrowthRecommendationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "成长板块", description = "成长页面推荐内容")
+@RestController
+@RequestMapping("/api/growth")
+public class GrowthController {
+
+    @Resource
+    private GrowthRecommendationService growthRecommendationService;
+
+    @Operation(summary = "获取成长板块推荐内容")
+    @PostMapping("/recommendations")
+    public Result<Map<String, Object>> getRecommendations(
+            @RequestParam(defaultValue = "health") String category,
+            @RequestParam(defaultValue = "3") int limit) {
+        List<Map<String, Object>> articles = growthRecommendationService.getRecommendedArticles(category, limit);
+        List<Map<String, Object>> products = growthRecommendationService.getRecommendedProducts(category, limit);
+        Map<String, Object> data = new HashMap<>();
+        data.put("articles", articles);
+        data.put("products", products);
+        return Result.success(data);
+    }
+
+    @Operation(summary = "AI 自动分类文章(批量)")
+    @PostMapping("/articles/ai-classify")
+    public Result<Map<String, Object>> autoClassifyArticles(
+            @RequestParam(defaultValue = "20") int batchSize) {
+        return Result.success(growthRecommendationService.autoClassifyArticles(batchSize));
+    }
+
+    @Operation(summary = "AI 自动分类商品(批量)")
+    @PostMapping("/products/ai-classify")
+    public Result<Map<String, Object>> autoClassifyProducts(
+            @RequestParam(defaultValue = "20") int batchSize) {
+        return Result.success(growthRecommendationService.autoClassifyProducts(batchSize));
+    }
+}

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java

@@ -36,6 +36,7 @@ public class Article implements Serializable {
     private String visibility;
     private String visibleTo;
     private String status;
+    private String growthCategory;
     /** 审核状态:approved(已审核) / pending(待审核) / rejected(已驳回) */
     private String auditStatus;
     /** 驳回原因 */

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java

@@ -32,6 +32,7 @@ public class Product implements Serializable {
     private Integer minStockAlert;
     private Integer salesCount;
     private String productType;
+    private String growthCategory;
     private Long vendorId;
     private String vendorName;
     private String status;

+ 144 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GrowthRecommendationService.java

@@ -0,0 +1,144 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class GrowthRecommendationService {
+
+    @Resource
+    private ArticleMapper articleMapper;
+
+    @Resource
+    private ProductMapper productMapper;
+
+    @Resource
+    private AIService aiService;
+
+    /** 获取成长板块推荐文章(按 growth_category 过滤,取精选) */
+    public List<Map<String, Object>> getRecommendedArticles(String growthCategory, int limit) {
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .eq(Article::getIsFeatured, 1)
+                .eq(growthCategory != null, Article::getGrowthCategory, growthCategory)
+                .isNotNull(growthCategory != null, Article::getGrowthCategory)
+                .orderByDesc(Article::getPublishedAt)
+                .last("LIMIT " + limit);
+        List<Article> articles = articleMapper.selectList(wrapper);
+        return articles.stream().map(a -> {
+            Map<String, Object> m = new HashMap<>();
+            m.put("id", a.getId());
+            m.put("title", a.getTitle());
+            m.put("summary", a.getSummary());
+            m.put("coverImage", a.getCoverImage());
+            m.put("category", a.getCategory());
+            m.put("growthCategory", a.getGrowthCategory());
+            m.put("publishedAt", a.getPublishedAt());
+            return m;
+        }).collect(Collectors.toList());
+    }
+
+    /** 获取成长板块推荐商品(按 growth_category 过滤,取在售) */
+    public List<Map<String, Object>> getRecommendedProducts(String growthCategory, int limit) {
+        LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
+                .eq(Product::getStatus, "on_shelf")
+                .eq(growthCategory != null, Product::getGrowthCategory, growthCategory)
+                .isNotNull(growthCategory != null, Product::getGrowthCategory)
+                .orderByAsc(Product::getSortOrder)
+                .orderByDesc(Product::getCreatedAt)
+                .last("LIMIT " + limit);
+        List<Product> products = productMapper.selectList(wrapper);
+        return products.stream().map(p -> {
+            Map<String, Object> m = new HashMap<>();
+            m.put("id", p.getId());
+            m.put("name", p.getName());
+            m.put("description", p.getDescription());
+            m.put("intro", p.getIntro());
+            m.put("coverImage", p.getCoverImage());
+            m.put("price", p.getPrice());
+            m.put("productType", p.getProductType());
+            m.put("growthCategory", p.getGrowthCategory());
+            return m;
+        }).collect(Collectors.toList());
+    }
+
+    /**
+     * AI 自动分类:根据标题+摘要/简介,返回 health 或 growth
+     */
+    public String classifyGrowthCategory(String type, String title, String summary) {
+        String prompt = String.format(
+            "请将以下内容分类为「health」(健康观察类:检测、报告、指标、风险、科普)或「growth」(成长干预类:饮食建议、运动指导、习惯养成、食谱、商品推荐、行动指南)。\n\n" +
+            "类型:%s\n标题:%s\n简介:%s\n\n只返回 health 或 growth,不要解释。",
+            type, title, summary != null ? summary : ""
+        );
+        try {
+            Map<String, Object> result = aiService.sendMessage(prompt, "0", null, null);
+            String answer = (String) result.get("answer");
+            if (answer != null) {
+                String lower = answer.trim().toLowerCase();
+                if (lower.contains("growth")) return "growth";
+                if (lower.contains("health")) return "health";
+            }
+        } catch (Exception e) {
+            log.warn("AI分类失败: {}", e.getMessage());
+        }
+        return null;
+    }
+
+    /** 批量自动分类所有未分类文章 */
+    public Map<String, Object> autoClassifyArticles(int batchSize) {
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .isNull(Article::getGrowthCategory)
+                .eq(Article::getStatus, "published")
+                .orderByDesc(Article::getCreatedAt)
+                .last("LIMIT " + batchSize);
+        List<Article> articles = articleMapper.selectList(wrapper);
+        int classified = 0;
+        for (Article article : articles) {
+            String category = classifyGrowthCategory("article", article.getTitle(), article.getSummary());
+            if (category != null) {
+                article.setGrowthCategory(category);
+                articleMapper.updateById(article);
+                classified++;
+            }
+        }
+        Map<String, Object> result = new HashMap<>();
+        result.put("total", articles.size());
+        result.put("classified", classified);
+        return result;
+    }
+
+    /** 批量自动分类所有未分类商品 */
+    public Map<String, Object> autoClassifyProducts(int batchSize) {
+        LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
+                .isNull(Product::getGrowthCategory)
+                .eq(Product::getStatus, "on_shelf")
+                .orderByDesc(Product::getCreatedAt)
+                .last("LIMIT " + batchSize);
+        List<Product> products = productMapper.selectList(wrapper);
+        int classified = 0;
+        for (Product product : products) {
+            String text = product.getIntro() != null ? product.getIntro() : product.getDescription();
+            String category = classifyGrowthCategory("product", product.getName(), text);
+            if (category != null) {
+                product.setGrowthCategory(category);
+                productMapper.updateById(product);
+                classified++;
+            }
+        }
+        Map<String, Object> result = new HashMap<>();
+        result.put("total", products.size());
+        result.put("classified", classified);
+        return result;
+    }
+}