feat: 初始化短剧服务端(管理后台 API + 客户端 API)

Spring Boot 3.2 / MyBatis-Plus / PostgreSQL,7 个 Maven 模块;含认证鉴权、内容与媒资管理、操作日志,以及客户端 /app/** 接口。
This commit is contained in:
Vicky committed 2026-10-14 18:02:47 +08:00
commit a4aa749cc6
152 files changed
+8027

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.reelshort</groupId>
<artifactId>reelshort</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>reelshort-framework</artifactId>
<name>reelshort-framework</name>
<description>技术框架:安全、鉴权、持久层、Web 配置</description>
<dependencies>
<dependency>
<groupId>com.reelshort</groupId>
<artifactId>reelshort-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,18 @@
package com.reelshort.framework.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL));
return interceptor;
}
}
@@ -0,0 +1,58 @@
package com.reelshort.framework.log;
import com.reelshort.framework.security.LoginUser;
import com.reelshort.framework.security.SecurityUtils;
import com.reelshort.framework.web.WebUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* 越权访问留痕。
* 方法级鉴权(@PreAuthorize)抛出的异常走全局异常处理,过滤器级的走安全入口,
* 两处都调用这里,避免逻辑重复。
*/
@Component
@RequiredArgsConstructor
public class AccessDeniedRecorder {
private final ObjectProvider<OperationLogSink> sinkProvider;
public void record(HttpServletRequest request) {
OperationLogSink sink = sinkProvider.getIfAvailable();
if (sink == null) {
return;
}
LoginUser loginUser = null;
try {
loginUser = SecurityUtils.getLoginUser();
} catch (Exception ignored) {
// 未登录时没有登录态
}
sink.save(OperationLogEntry.builder()
.userId(loginUser == null ? 0L : loginUser.userId())
.username(loginUser == null ? "" : loginUser.username())
.module("安全")
.action("越权访问被拒绝")
.requestMethod(request == null ? "" : request.getMethod())
.requestUrl(request == null ? "" : request.getRequestURI())
.requestParams(request == null || request.getQueryString() == null
? "" : request.getQueryString())
.responseBody("{\"code\":403,\"message\":\"无权限\"}")
.status("FAIL")
.errorMsg("无权限")
.durationMs(0)
.ip(request == null ? "" : WebUtils.getClientIp(request))
.build());
}
/** 从当前线程取出请求,供全局异常处理使用。 */
public void recordCurrentRequest() {
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) {
record(attributes.getRequest());
}
}
}
@@ -0,0 +1,21 @@
package com.reelshort.framework.log;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 标注在需要留痕的接口方法上。
* 请求参数、请求体、响应体、操作人、耗时、IP 由 {@link OperationLogAspect} 自动采集。
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
/** 所属模块,如「内容管理」 */
String module() default "";
/** 动作名称,如「新增轮播图」 */
String action() default "";
}
@@ -0,0 +1,151 @@
package com.reelshort.framework.log;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.reelshort.framework.security.LoginUser;
import com.reelshort.framework.security.SecurityUtils;
import com.reelshort.framework.web.WebUtils;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import org.springframework.validation.BindingResult;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 自动记录后台写操作:谁、什么时候、调了哪个接口、传了什么、返回了什么、耗时多少。
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class OperationLogAspect {
private static final int MAX_TEXT = 4000;
private static final String TRUNCATED = "\n...(已截断)";
/** 密码、密钥、令牌等不落库 */
private static final Pattern SENSITIVE_FIELDS = Pattern.compile(
"(?i)(\"(?:password|oldPassword|newPassword|confirmPassword|secret|token|accessToken|refreshToken)\"\\s*:\\s*)\"[^\"]*\"");
private final ObjectMapper objectMapper;
private final ObjectProvider<OperationLogSink> sinkProvider;
@Around("@annotation(operationLog)")
public Object around(ProceedingJoinPoint point, OperationLog operationLog) throws Throwable {
long started = System.currentTimeMillis();
Object result = null;
Throwable error = null;
try {
result = point.proceed();
return result;
} catch (Throwable ex) {
error = ex;
throw ex;
} finally {
try {
save(point, operationLog, result, error, (int) (System.currentTimeMillis() - started));
} catch (Exception ex) {
log.warn("写操作日志失败: {}", operationLog.action(), ex);
}
}
}
private void save(ProceedingJoinPoint point, OperationLog annotation, Object result,
Throwable error, int durationMs) {
OperationLogSink sink = sinkProvider.getIfAvailable();
if (sink == null) {
return;
}
HttpServletRequest request = currentRequest();
LoginUser loginUser = currentUser();
sink.save(OperationLogEntry.builder()
.userId(loginUser == null ? 0L : loginUser.userId())
.username(loginUser == null ? "" : loginUser.username())
.module(annotation.module())
.action(annotation.action())
.requestMethod(request == null ? "" : request.getMethod())
.requestUrl(request == null ? "" : request.getRequestURI())
.requestParams(request == null ? "" : truncate(request.getQueryString()))
.requestBody(body(point))
.responseBody(error != null ? "" : json(result))
.status(error == null ? "SUCCESS" : "FAIL")
.errorMsg(error == null ? "" : truncate(error.getMessage()))
.durationMs(durationMs)
.ip(request == null ? "" : WebUtils.getClientIp(request))
.build());
}
/** 把方法入参整理成 JSON,文件只记名称和大小。 */
private String body(ProceedingJoinPoint point) {
Object[] args = point.getArgs();
String[] names = ((MethodSignature) point.getSignature()).getParameterNames();
Map<String, Object> payload = new LinkedHashMap<>();
for (int index = 0; index < args.length; index++) {
Object arg = args[index];
if (arg == null || arg instanceof ServletRequest || arg instanceof ServletResponse
|| arg instanceof BindingResult) {
continue;
}
String name = names != null && index < names.length ? names[index] : "arg" + index;
if (arg instanceof MultipartFile file) {
payload.put(name, "%s (%d bytes)".formatted(file.getOriginalFilename(), file.getSize()));
} else {
payload.put(name, arg);
}
}
if (payload.isEmpty()) {
return "";
}
// 只有一个入参时直接序列化该参数,日志更易读
return payload.size() == 1 ? json(payload.values().iterator().next()) : json(payload);
}
private String json(Object value) {
if (value == null) {
return "";
}
try {
String text = objectMapper.writeValueAsString(value);
text = SENSITIVE_FIELDS.matcher(text).replaceAll("$1\"***\"");
return truncate(text);
} catch (Exception ex) {
return String.valueOf(value);
}
}
private String truncate(String text) {
if (text == null) {
return "";
}
return text.length() <= MAX_TEXT ? text : text.substring(0, MAX_TEXT) + TRUNCATED;
}
private HttpServletRequest currentRequest() {
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) {
return attributes.getRequest();
}
return null;
}
private LoginUser currentUser() {
try {
return SecurityUtils.getLoginUser();
} catch (Exception ex) {
// 登录等公开接口没有登录态
return null;
}
}
}
@@ -0,0 +1,25 @@
package com.reelshort.framework.log;
import lombok.Builder;
import lombok.Data;
/** 一条待落库的操作记录。 */
@Data
@Builder
public class OperationLogEntry {
private Long userId;
private String username;
private String module;
private String action;
private String requestMethod;
private String requestUrl;
private String requestParams;
private String requestBody;
private String responseBody;
/** SUCCESS / FAIL */
private String status;
private String errorMsg;
private Integer durationMs;
private String ip;
}
@@ -0,0 +1,9 @@
package com.reelshort.framework.log;
/**
* 操作日志落库的扩展点,由业务模块实现(框架层不依赖具体存储)。
*/
public interface OperationLogSink {
void save(OperationLogEntry entry);
}
@@ -0,0 +1,51 @@
package com.reelshort.framework.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
/**
* 解析 Authorization: Bearer <token>,写入 SecurityContext。
*/
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String HEADER = "Authorization";
private static final String PREFIX = "Bearer ";
private final JwtTokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader(HEADER);
if (StringUtils.hasText(header) && header.startsWith(PREFIX)) {
String token = header.substring(PREFIX.length()).trim();
try {
LoginUser loginUser = tokenProvider.parseAdminToken(token);
List<SimpleGrantedAuthority> authorities = loginUser.permissions() == null
? List.of()
: loginUser.permissions().stream().map(SimpleGrantedAuthority::new).toList();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(loginUser, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception ignored) {
// token 无效时不抛出,交给后续的鉴权入口统一返回 401
SecurityContextHolder.clearContext();
}
}
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,17 @@
package com.reelshort.framework.security;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "reelshort.jwt")
public class JwtProperties {
/** 管理端签名密钥,长度必须 >= 32 字节。生产环境通过环境变量注入。 */
private String adminSecret = "reelshort-admin-dev-secret-change-me-in-production-32b";
/** 管理端 token 有效期(小时)。 */
private int adminTtlHours = 12;
}
@@ -0,0 +1,67 @@
package com.reelshort.framework.security;
import com.reelshort.common.exception.ErrorCode;
import com.reelshort.common.exception.ServiceException;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Date;
import java.util.List;
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private static final String CLAIM_USER_ID = "userId";
private static final String CLAIM_ROLE_CODE = "roleCode";
private static final String CLAIM_PERMISSIONS = "permissions";
private final JwtProperties properties;
public String createAdminToken(LoginUser loginUser) {
Date now = new Date();
Date expiration = new Date(now.getTime() + Duration.ofHours(properties.getAdminTtlHours()).toMillis());
return Jwts.builder()
.subject(loginUser.username())
.claim(CLAIM_USER_ID, loginUser.userId())
.claim(CLAIM_ROLE_CODE, loginUser.roleCode())
.claim(CLAIM_PERMISSIONS, loginUser.permissions())
.issuedAt(now)
.expiration(expiration)
.signWith(adminKey())
.compact();
}
@SuppressWarnings("unchecked")
public LoginUser parseAdminToken(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(adminKey())
.build()
.parseSignedClaims(token)
.getPayload();
Number userId = claims.get(CLAIM_USER_ID, Number.class);
return new LoginUser(
userId == null ? null : userId.longValue(),
claims.getSubject(),
claims.get(CLAIM_ROLE_CODE, String.class),
(List<String>) claims.get(CLAIM_PERMISSIONS, List.class));
} catch (Exception ex) {
throw new ServiceException(ErrorCode.UNAUTHORIZED);
}
}
private SecretKey adminKey() {
byte[] bytes = properties.getAdminSecret().getBytes(StandardCharsets.UTF_8);
if (bytes.length < 32) {
throw new IllegalStateException("reelshort.jwt.admin-secret 长度必须不少于 32 字节");
}
return Keys.hmacShaKeyFor(bytes);
}
}
@@ -0,0 +1,13 @@
package com.reelshort.framework.security;
import java.util.List;
/**
* 登录态载体,由 JWT 解析得到,随 SecurityContext 传递。
*/
public record LoginUser(Long userId, String username, String roleCode, List<String> permissions) {
public boolean hasPermission(String permission) {
return permissions != null && permissions.contains(permission);
}
}
@@ -0,0 +1,50 @@
package com.reelshort.framework.security;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 供 {@code @PreAuthorize("@ss.hasPermission('drama:create')")} 使用的权限判断。
* 优先按用户实时查询权限(角色的权限改了立即生效),查不到时退回 JWT 中的权限列表。
*/
@Component("ss")
@RequiredArgsConstructor
public class PermissionChecker {
private final ObjectProvider<PermissionProvider> permissionProvider;
public boolean hasPermission(String permission) {
return permissions().contains(permission);
}
public boolean hasAnyPermission(String... permissions) {
Set<String> granted = permissions();
for (String permission : permissions) {
if (granted.contains(permission)) {
return true;
}
}
return false;
}
private Set<String> permissions() {
LoginUser loginUser = SecurityUtils.getLoginUser();
PermissionProvider provider = permissionProvider.getIfAvailable();
if (provider != null && loginUser.userId() != null) {
try {
List<String> codes = provider.listPermissionsByUserId(loginUser.userId());
if (codes != null) {
return new HashSet<>(codes);
}
} catch (Exception ignored) {
// 查询失败时退回 JWT 中的权限,避免因为权限服务异常导致全部请求失败
}
}
return loginUser.permissions() == null ? Set.of() : new HashSet<>(loginUser.permissions());
}
}
@@ -0,0 +1,12 @@
package com.reelshort.framework.security;
import java.util.List;
/**
* 权限来源的扩展点:由业务模块实现,用于按用户实时查询权限码。
* 没有实现时,权限判断退回到 JWT 中携带的权限列表。
*/
public interface PermissionProvider {
List<String> listPermissionsByUserId(Long userId);
}
@@ -0,0 +1,49 @@
package com.reelshort.framework.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.reelshort.common.exception.ErrorCode;
import com.reelshort.common.pojo.Result;
import com.reelshort.framework.log.AccessDeniedRecorder;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* 未登录与无权限时,也返回统一响应体,保证前端拦截器能识别 401 并登出。
*/
@Component
@RequiredArgsConstructor
public class RestAuthEntryPoint implements AuthenticationEntryPoint, AccessDeniedHandler {
private final ObjectMapper objectMapper;
private final AccessDeniedRecorder accessDeniedRecorder;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
write(response, Result.error(ErrorCode.UNAUTHORIZED));
}
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
accessDeniedRecorder.record(request);
write(response, Result.error(ErrorCode.FORBIDDEN));
}
private void write(HttpServletResponse response, Result<?> result) throws IOException {
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().write(objectMapper.writeValueAsString(result));
}
}
@@ -0,0 +1,80 @@
package com.reelshort.framework.security;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
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 org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private static final String[] PUBLIC_PATHS = {
"/admin/auth/login",
/* 客户端接口:浏览与播放暂不要求登录,登录体系(手机号/邮箱)在 C 端鉴权落地后收紧 */
"/app/**",
/* 本地媒资:图片与视频用 <img>/<video> 直接访问,无法带请求头 */
"/files/**",
"/doc.html",
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**",
"/actuator/health"
};
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final RestAuthEntryPoint restAuthEntryPoint;
@Value("${reelshort.cors.allowed-origins:http://localhost:3005}")
private String allowedOrigins;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(registry -> registry
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers(PUBLIC_PATHS).permitAll()
.anyRequest().authenticated())
.exceptionHandling(handling -> handling
.authenticationEntryPoint(restAuthEntryPoint)
.accessDeniedHandler(restAuthEntryPoint))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.stream(allowedOrigins.split(",")).map(String::trim).toList());
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@@ -0,0 +1,28 @@
package com.reelshort.framework.security;
import com.reelshort.common.exception.ErrorCode;
import com.reelshort.common.exception.ServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
public final class SecurityUtils {
private SecurityUtils() {
}
public static LoginUser getLoginUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof LoginUser loginUser) {
return loginUser;
}
throw new ServiceException(ErrorCode.UNAUTHORIZED);
}
public static Long getUserId() {
return getLoginUser().userId();
}
public static String getUsername() {
return getLoginUser().username();
}
}
@@ -0,0 +1,84 @@
package com.reelshort.framework.web;
import com.reelshort.common.exception.ErrorCode;
import com.reelshort.common.exception.ServiceException;
import com.reelshort.common.pojo.Result;
import com.reelshort.framework.log.AccessDeniedRecorder;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import lombok.RequiredArgsConstructor;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.apache.catalina.connector.ClientAbortException;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
/**
* 全局异常处理:所有异常都转成统一响应体,前端拦截器只需判断 code。
*/
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private final AccessDeniedRecorder accessDeniedRecorder;
@ExceptionHandler(ServiceException.class)
public Result<Void> handleServiceException(ServiceException ex) {
log.warn("业务异常: code={}, message={}", ex.getCode(), ex.getMessage());
return Result.error(ex.getCode(), ex.getMessage());
}
/** 方法级权限校验失败:统一返回 403,前端据此提示无权限。 */
@ExceptionHandler(AccessDeniedException.class)
public Result<Void> handleAccessDenied(AccessDeniedException ex) {
log.warn("无权限访问: {}", ex.getMessage());
accessDeniedRecorder.recordCurrentRequest();
return Result.error(ErrorCode.FORBIDDEN);
}
@ExceptionHandler({MethodArgumentNotValidException.class, BindException.class})
public Result<Void> handleBindException(BindException ex) {
FieldError fieldError = ex.getBindingResult().getFieldError();
String message = fieldError == null ? ErrorCode.BAD_REQUEST.getMessage() : fieldError.getDefaultMessage();
return Result.error(ErrorCode.BAD_REQUEST.getCode(), message);
}
@ExceptionHandler(ConstraintViolationException.class)
public Result<Void> handleConstraintViolation(ConstraintViolationException ex) {
return Result.error(ErrorCode.BAD_REQUEST.getCode(), ex.getMessage());
}
@ExceptionHandler({MissingServletRequestParameterException.class, HttpMessageNotReadableException.class})
public Result<Void> handleBadRequest(Exception ex) {
return Result.error(ErrorCode.BAD_REQUEST.getCode(), ErrorCode.BAD_REQUEST.getMessage());
}
/** 静态资源或未匹配的路径:按 404 返回,不要吞成 500。 */
@ExceptionHandler(NoResourceFoundException.class)
public Result<Void> handleNoResourceFound(NoResourceFoundException ex) {
log.warn("资源不存在: {}", ex.getResourcePath());
return Result.error(ErrorCode.NOT_FOUND);
}
/**
* 客户端中断(例如视频边下边播时用户划走、关闭页面)是正常现象,
* 不该记成系统异常,日志降级为 debug。
*/
@ExceptionHandler({ClientAbortException.class, AsyncRequestNotUsableException.class})
public void handleClientAbort(Exception ex) {
log.debug("客户端中断连接: {}", ex.getMessage());
}
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception ex) {
log.error("系统异常", ex);
return Result.error(ErrorCode.INTERNAL_ERROR);
}
}
@@ -0,0 +1,40 @@
package com.reelshort.framework.web;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 慢请求告警:客户端接口的响应时间目标是 50ms,超过阈值的请求打 WARN,
* 方便在内容量增长、索引失效或缓存缺失时第一时间发现。
*/
@Slf4j
@Component
public class SlowRequestLogger extends OncePerRequestFilter {
private static final long SLOW_THRESHOLD_MS = 50;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
long started = System.currentTimeMillis();
try {
filterChain.doFilter(request, response);
} finally {
// 只关注客户端接口,管理端后台页面不做这个约束
if (request.getRequestURI().startsWith("/app/")) {
long cost = System.currentTimeMillis() - started;
if (cost > SLOW_THRESHOLD_MS) {
log.warn("慢请求 {} {} 耗时 {}ms(目标 {}ms)",
request.getMethod(), request.getRequestURI(), cost, SLOW_THRESHOLD_MS);
}
}
}
}
}
@@ -0,0 +1,25 @@
package com.reelshort.framework.web;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.util.StringUtils;
public final class WebUtils {
private static final String UNKNOWN = "unknown";
private WebUtils() {
}
/** 取真实客户端 IP,兼容 Nginx 反向代理。 */
public static String getClientIp(HttpServletRequest request) {
String[] headers = {"X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP"};
for (String header : headers) {
String value = request.getHeader(header);
if (StringUtils.hasText(value) && !UNKNOWN.equalsIgnoreCase(value)) {
int comma = value.indexOf(',');
return comma > 0 ? value.substring(0, comma).trim() : value.trim();
}
}
return request.getRemoteAddr();
}
}