feat: 初始化短剧服务端(管理后台 API + 客户端 API)
Spring Boot 3.2 / MyBatis-Plus / PostgreSQL,7 个 Maven 模块;含认证鉴权、内容与媒资管理、操作日志,以及客户端 /app/** 接口。
This commit is contained in:
commit
a4aa749cc6
152 files changed
+8027
No files matched your search
@@ -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>
|
||||
+18
@@ -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;
|
||||
}
|
||||
}
|
||||
+58
@@ -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 "";
|
||||
}
|
||||
+151
@@ -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);
|
||||
}
|
||||
+51
@@ -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;
|
||||
}
|
||||
+67
@@ -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);
|
||||
}
|
||||
}
|
||||
+50
@@ -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());
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.reelshort.framework.security;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 权限来源的扩展点:由业务模块实现,用于按用户实时查询权限码。
|
||||
* 没有实现时,权限判断退回到 JWT 中携带的权限列表。
|
||||
*/
|
||||
public interface PermissionProvider {
|
||||
|
||||
List<String> listPermissionsByUserId(Long userId);
|
||||
}
|
||||
+49
@@ -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));
|
||||
}
|
||||
}
|
||||
+80
@@ -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();
|
||||
}
|
||||
}
|
||||
+84
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user