package com.xuekairui.user.config; import com.fasterxml.jackson.databind.ObjectMapper; import com.xuekairui.common.ErrorCode; import com.xuekairui.common.Result; import com.xuekairui.user.security.JwtAuthenticationFilter; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import java.nio.charset.StandardCharsets; /** * Spring Security 配置 */ @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final Environment environment; /** 公开接口白名单 */ private static final String[] PUBLIC_URLS = { "/api/auth/sms/send", "/api/auth/sms/login", "/api/admin/auth/login", // 管理员登录(公开) "/api/auth/password/login", // 密码登录 "/api/auth/refresh", // 刷新Token(无需认证) "/api/auth/wechat/**", // 微信登录相关 "/api/invite/validate", // 验证邀请码(注册前) "/api/invite/{code}", // 邀请链接入口(公开) "/api/invite/page/**", // 邀请落地页(公开) "/api/invite/click/**", // 邀请链接点击追踪(公开) "/api/invite/resolve-link", // 解析邀请链接/邀请码(公开) "/invite/**", // 浏览器直接访问邀请链接(公开) "/api/platform-config/**", // 平台配置查询(公开) "/api/search/suggestions", // 搜索建议(公开) "/api/payment/callback/**", // 支付回调(公开,供微信/支付宝调用) "/api/payment/anxin/notify/**", // 安心付通知(公开,供支付宝调用) "/api/payment/agreement/notify/**", // 签约回调通知(公开,供微信调用) "/api/payment/alipay/**", // 支付宝开放能力回调(公开) "/api/payment/plans", // 支付方案列表(公开) "/api/membership/levels", // 会员等级列表(公开) "/api/membership/levels/*", // 会员等级详情(公开) "/error", // 错误页面 "/actuator/health" // 健康检查 }; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { // dev 环境启用后端 CORS 兜底(DevCorsConfig 提供配置源),生产环境由 Nginx 统一处理 boolean isDev = environment.acceptsProfiles(org.springframework.core.env.Profiles.of("dev")); if (isDev) { http.cors(cors -> {}); // 使用默认,自动发现 CorsConfigurationSource Bean } else { http.cors(AbstractHttpConfigurer::disable); } http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth // OPTIONS 预检请求直接放行(CORS 由 Nginx 处理,此处仅作兜底避免 401) .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // 管理员登录必须在 /api/admin/** 鉴权之前,否则会被拦截返回 401 .requestMatchers("/api/admin/auth/login").permitAll() // 超级管理员专属:角色赋权 .requestMatchers("/api/admin/users/**").hasRole("SUPER_ADMIN") // 运营后台接口 .requestMatchers("/api/admin/**").hasAnyRole("ADMIN", "SUPER_ADMIN") // 散落在用户路径中的管理员操作(已统一到 /api/admin/** 下) .requestMatchers("/api/coupon/issue").hasAnyRole("ADMIN", "SUPER_ADMIN") // 用户端邀请接口需登录(必须在 /api/invite/{code} 公开匹配之前) .requestMatchers("/api/invite/code", "/api/invite/stats", "/api/invite/rewards", "/api/invite/extra-quota", "/api/invite/bind").authenticated() .requestMatchers(PUBLIC_URLS).permitAll() .anyRequest().authenticated() ) .exceptionHandling(ex -> ex .authenticationEntryPoint((request, response, authException) -> { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding(StandardCharsets.UTF_8.name()); Result result = Result.error(ErrorCode.UNAUTHORIZED); response.getWriter().write(new ObjectMapper().writeValueAsString(result)); }) .accessDeniedHandler((request, response, accessDeniedException) -> { response.setStatus(HttpStatus.FORBIDDEN.value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding(StandardCharsets.UTF_8.name()); Result result = Result.error(ErrorCode.FORBIDDEN); response.getWriter().write(new ObjectMapper().writeValueAsString(result)); }) ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }