refactor: 后端标识统一为 FruitShorts,并落地 05 离线下载
为什么: 1) 产品已定名 FruitShorts,但后端仍是 reelshort 系列标识 —— Java 包 com.reelshort、 模块名与 jar 名 reelshort-*、spring.application.name、配置前缀 reelshort.*, 这些会出现在启动日志、命令行和 IDE 里,与产品名不一致。本次整体替换为 fruitshorts, 数据库连接名上一轮已改名,保持不变。 2) 第一阶段下一批第 1 步(05 离线下载)要求把下载授权(设备绑定/有效期/吊销)、 下载任务与断点续传、配额与过期清理、仅 Wi-Fi 规则做成完整接口,本提交一并落地。 3) 顺带修复:schema-phase1.sql 里 playback_ticket / order_event 仍按普通表定义唯一索引, 表分区化之后重跑脚本必定报错(分区表的唯一索引必须包含分区键)。改为在本脚本里就 按分区表定义,并验证脚本可重复执行。
This commit is contained in:
1 parent
a4aa749cc6
commit
b3179fc836
251 files changed
+7261
-767
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.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>fruitshorts-framework</artifactId>
|
||||
<name>fruitshorts-framework</name>
|
||||
<description>技术框架:安全、鉴权、持久层、Web 配置</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-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.fruitshorts.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;
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.fruitshorts.framework.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/** 打开定时任务:目前用于分区维护与后续的对账、计数聚合。 */
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
public class SchedulingConfig {
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.fruitshorts.framework.log;
|
||||
|
||||
import com.fruitshorts.framework.security.LoginUser;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.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.fruitshorts.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.fruitshorts.framework.log;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fruitshorts.framework.security.LoginUser;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.fruitshorts.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;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.fruitshorts.framework.log;
|
||||
|
||||
/**
|
||||
* 操作日志落库的扩展点,由业务模块实现(框架层不依赖具体存储)。
|
||||
*/
|
||||
public interface OperationLogSink {
|
||||
|
||||
void save(OperationLogEntry entry);
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package com.fruitshorts.framework.partition;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 分区自动维护。
|
||||
*
|
||||
* <p>声明式分区不会自己长出来:只建 default 分区的话,数据全堆在 default 里,
|
||||
* 分区就失去意义,而且往后拆分代价很高。所以启动时和每天凌晨各跑一次:
|
||||
* <ol>
|
||||
* <li>确保当前月与未来 N 个月的分区存在;</li>
|
||||
* <li>按配置清理过期分区(默认关闭,只建不删)。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>维护函数在 sql/partition-maintenance.sql,这里只负责按时调用。
|
||||
* 函数不存在(例如环境还没执行脚本)时只告警不阻断启动。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PartitionMaintainer {
|
||||
|
||||
/** 分区表与各自的保留月数;保留月数 <= 0 表示不清理。 */
|
||||
private static final List<PartitionedTable> TABLES = List.of(
|
||||
new PartitionedTable("watch_history", 24),
|
||||
new PartitionedTable("playback_ticket", 6),
|
||||
new PartitionedTable("order_event", 36));
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PartitionProperties properties;
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void onStartup() {
|
||||
if (properties.isEnabled()) {
|
||||
maintain();
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(cron = "${fruitshorts.partition.cron:0 17 3 * * *}")
|
||||
public void scheduled() {
|
||||
if (properties.isEnabled()) {
|
||||
maintain();
|
||||
}
|
||||
}
|
||||
|
||||
public void maintain() {
|
||||
for (PartitionedTable table : TABLES) {
|
||||
try {
|
||||
Integer created = jdbcTemplate.queryForObject(
|
||||
"select ensure_monthly_partitions(?, current_date, ?)",
|
||||
Integer.class, table.name(), properties.getMonthsAhead() + 1);
|
||||
if (created != null && created > 0) {
|
||||
log.info("分区维护:{} 新建 {} 个分区", table.name(), created);
|
||||
}
|
||||
if (properties.isRetentionEnabled() && table.retentionMonths() > 0) {
|
||||
Integer dropped = jdbcTemplate.queryForObject(
|
||||
"select drop_partitions_before(?, (current_date - make_interval(months => ?))::date)",
|
||||
Integer.class, table.name(), table.retentionMonths());
|
||||
if (dropped != null && dropped > 0) {
|
||||
log.warn("分区维护:{} 清理了 {} 个过期分区", table.name(), dropped);
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
// 表还没建或函数没装:不阻断启动,但要说清楚怎么办
|
||||
log.warn("分区维护跳过 {}:{}(确认已执行 sql/partition-maintenance.sql)",
|
||||
table.name(), ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record PartitionedTable(String name, int retentionMonths) {
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.fruitshorts.framework.partition;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** 分区维护配置。 */
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "fruitshorts.partition")
|
||||
public class PartitionProperties {
|
||||
|
||||
/** 是否启用自动维护。关掉之后需要人工执行 sql/partition-maintenance.sql。 */
|
||||
private boolean enabled = true;
|
||||
|
||||
/** 预建未来几个月的分区。默认 3 个月,跨月时即使服务停了一周也不会漏。 */
|
||||
private int monthsAhead = 3;
|
||||
|
||||
/**
|
||||
* 是否自动清理过期分区。
|
||||
* 默认关闭:删数据是不可逆操作,第一阶段先只建不删,等保留策略与合规确认后再打开。
|
||||
*/
|
||||
private boolean retentionEnabled = false;
|
||||
|
||||
/** 每日维护时间(cron)。默认凌晨 3:17,避开印尼晚高峰。 */
|
||||
private String cron = "0 17 3 * * *";
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.fruitshorts.framework.security;
|
||||
|
||||
/**
|
||||
* C 端账号状态校验端口。
|
||||
*
|
||||
* <p>access token 是无状态的,签发出去以后无法收回。封禁、注销、改密这类操作
|
||||
* 如果只吊销 refresh token,用户最长还能继续用 2 小时,这不满足"处置立即生效"的要求。
|
||||
* 所以每个带 C 端身份的请求都要确认一次账号是否仍然可用。
|
||||
*
|
||||
* <p>框架只依赖这个接口,实现放在用户模块;接入 Redis 后在这里加一层缓存,
|
||||
* 把每次请求的库查询降为内存命中。
|
||||
*/
|
||||
public interface AppUserStatusChecker {
|
||||
|
||||
/** 返回 true 表示账号可继续访问。 */
|
||||
boolean isActive(Long userId);
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.fruitshorts.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.beans.factory.ObjectProvider;
|
||||
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;
|
||||
private final ObjectProvider<AppUserStatusChecker> statusCheckerProvider;
|
||||
|
||||
@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 {
|
||||
// typ 决定用哪把密钥验签:管理端 token 无法冒充客户端身份,反之亦然
|
||||
LoginUser loginUser = tokenProvider.parse(token);
|
||||
if (isInactiveAppUser(loginUser)) {
|
||||
// 账号已被封禁/注销:当作未登录处理,后续 401 会引导客户端重新登录
|
||||
SecurityContextHolder.clearContext();
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
private boolean isInactiveAppUser(LoginUser loginUser) {
|
||||
if (!SecurityUtils.isAppUser(loginUser)) {
|
||||
return false;
|
||||
}
|
||||
AppUserStatusChecker checker = statusCheckerProvider.getIfAvailable();
|
||||
return checker != null && !checker.isActive(loginUser.userId());
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.fruitshorts.framework.security;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "fruitshorts.jwt")
|
||||
public class JwtProperties {
|
||||
|
||||
/** 管理端签名密钥,长度必须 >= 32 字节。生产环境通过环境变量注入。 */
|
||||
private String adminSecret = "fruitshorts-admin-dev-secret-change-me-in-production-32b";
|
||||
|
||||
/** 管理端 token 有效期(小时)。 */
|
||||
private int adminTtlHours = 12;
|
||||
|
||||
/**
|
||||
* 客户端签名密钥。与管理端物理隔离:管理端 token 拿到客户端接口上无法通过验签,
|
||||
* 反之亦然。生产环境必须通过环境变量注入,不要沿用默认值。
|
||||
*/
|
||||
private String appSecret = "fruitshorts-app-dev-secret-change-me-in-production-32bytes";
|
||||
|
||||
/** 客户端 access token 有效期(分钟)。 */
|
||||
private int appAccessTtlMinutes = 120;
|
||||
|
||||
/** 客户端 refresh token 有效期(天)。 */
|
||||
private int appRefreshTtlDays = 30;
|
||||
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
package com.fruitshorts.framework.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.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.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
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 static final String CLAIM_TOKEN_TYPE = "typ";
|
||||
private static final String CLAIM_DEVICE_ID = "did";
|
||||
|
||||
public static final String TYPE_ADMIN = "admin";
|
||||
public static final String TYPE_APP = "app";
|
||||
/** C 端登录态统一挂在登录载体上,权限为空,避免误入后台鉴权链路。 */
|
||||
public static final String APP_ROLE_CODE = "APP_USER";
|
||||
|
||||
private final JwtProperties properties;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
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())
|
||||
.claim(CLAIM_TOKEN_TYPE, TYPE_ADMIN)
|
||||
.issuedAt(now)
|
||||
.expiration(expiration)
|
||||
.signWith(adminKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
/** 客户端 access token:只带用户身份与设备,不带任何后台权限。 */
|
||||
public String createAppToken(Long userId, String uid, String deviceId) {
|
||||
Date now = new Date();
|
||||
Date expiration = new Date(now.getTime()
|
||||
+ Duration.ofMinutes(properties.getAppAccessTtlMinutes()).toMillis());
|
||||
return Jwts.builder()
|
||||
.subject(uid)
|
||||
.claim(CLAIM_USER_ID, userId)
|
||||
.claim(CLAIM_ROLE_CODE, APP_ROLE_CODE)
|
||||
.claim(CLAIM_PERMISSIONS, List.of())
|
||||
.claim(CLAIM_TOKEN_TYPE, TYPE_APP)
|
||||
.claim(CLAIM_DEVICE_ID, deviceId == null ? "" : deviceId)
|
||||
.issuedAt(now)
|
||||
.expiration(expiration)
|
||||
.signWith(appKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public LoginUser parseAppToken(String token) {
|
||||
try {
|
||||
Claims claims = Jwts.parser()
|
||||
.verifyWith(appKey())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
Number userId = claims.get(CLAIM_USER_ID, Number.class);
|
||||
return new LoginUser(
|
||||
userId == null ? null : userId.longValue(),
|
||||
claims.getSubject(),
|
||||
APP_ROLE_CODE,
|
||||
(List<String>) claims.get(CLAIM_PERMISSIONS, List.class));
|
||||
} catch (Exception ex) {
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 token 自带的 typ 选择验签密钥。读 typ 时不验签,只用于选路。 */
|
||||
public LoginUser parse(String token) {
|
||||
return TYPE_APP.equals(peekType(token)) ? parseAppToken(token) : parseAdminToken(token);
|
||||
}
|
||||
|
||||
public String deviceIdOf(String token) {
|
||||
try {
|
||||
Claims claims = Jwts.parser()
|
||||
.verifyWith(appKey())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
String deviceId = claims.get(CLAIM_DEVICE_ID, String.class);
|
||||
return deviceId == null ? "" : deviceId;
|
||||
} catch (Exception ex) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private String peekType(String token) {
|
||||
try {
|
||||
String[] parts = token.split("\\.");
|
||||
if (parts.length < 2) {
|
||||
return TYPE_ADMIN;
|
||||
}
|
||||
byte[] payload = Base64.getUrlDecoder().decode(parts[1]);
|
||||
JsonNode node = objectMapper.readTree(payload);
|
||||
JsonNode type = node.get(CLAIM_TOKEN_TYPE);
|
||||
return type == null ? TYPE_ADMIN : type.asText();
|
||||
} catch (Exception ex) {
|
||||
return TYPE_ADMIN;
|
||||
}
|
||||
}
|
||||
|
||||
/** refresh token 是不透明随机串,只在库里存哈希,这里不参与 JWT。 */
|
||||
public Duration appRefreshTtl() {
|
||||
return Duration.ofDays(properties.getAppRefreshTtlDays());
|
||||
}
|
||||
|
||||
public long accessTtlSeconds() {
|
||||
return Duration.ofMinutes(properties.getAppAccessTtlMinutes()).toSeconds();
|
||||
}
|
||||
|
||||
@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() {
|
||||
return keyOf(properties.getAdminSecret(), "fruitshorts.jwt.admin-secret");
|
||||
}
|
||||
|
||||
private SecretKey appKey() {
|
||||
return keyOf(properties.getAppSecret(), "fruitshorts.jwt.app-secret");
|
||||
}
|
||||
|
||||
private SecretKey keyOf(String secret, String property) {
|
||||
byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length < 32) {
|
||||
throw new IllegalStateException(property + " 长度必须不少于 32 字节");
|
||||
}
|
||||
return Keys.hmacShaKeyFor(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.fruitshorts.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.fruitshorts.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.fruitshorts.framework.security;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 权限来源的扩展点:由业务模块实现,用于按用户实时查询权限码。
|
||||
* 没有实现时,权限判断退回到 JWT 中携带的权限列表。
|
||||
*/
|
||||
public interface PermissionProvider {
|
||||
|
||||
List<String> listPermissionsByUserId(Long userId);
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.fruitshorts.framework.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.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));
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.fruitshorts.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",
|
||||
/*
|
||||
* 客户端接口按「默认需要登录」收敛:只有下面列出的浏览类接口允许匿名。
|
||||
* 新增接口如果忘了加白名单,会返回 401 而不是悄悄裸奔。
|
||||
*/
|
||||
"/app/auth/**",
|
||||
"/app/home/**",
|
||||
"/app/discover/**",
|
||||
"/app/categories/**",
|
||||
"/app/banners/**",
|
||||
"/app/dramas/**",
|
||||
"/app/episodes/*/play",
|
||||
"/app/config/**",
|
||||
/* 媒资:封面与视频用 <img>/<video> 直接访问,无法带请求头。
|
||||
片源的防盗链由 fruitshorts.media.signature-required 控制。 */
|
||||
"/files/**",
|
||||
"/doc.html",
|
||||
"/swagger-ui/**",
|
||||
"/swagger-ui.html",
|
||||
"/v3/api-docs/**",
|
||||
"/actuator/health"
|
||||
};
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
private final RestAuthEntryPoint restAuthEntryPoint;
|
||||
|
||||
@Value("${fruitshorts.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();
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.fruitshorts.framework.security;
|
||||
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.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();
|
||||
}
|
||||
|
||||
/** 允许匿名访问的接口用它取当前用户,未登录返回 null。 */
|
||||
public static LoginUser getLoginUserOrNull() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && authentication.getPrincipal() instanceof LoginUser loginUser) {
|
||||
return loginUser;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Long getUserIdOrNull() {
|
||||
LoginUser loginUser = getLoginUserOrNull();
|
||||
return loginUser == null ? null : loginUser.userId();
|
||||
}
|
||||
|
||||
/** 真机上未登录用户也会带一个设备标识,用于免费集限流与风控。 */
|
||||
public static boolean isAppUser(LoginUser loginUser) {
|
||||
return loginUser != null && "APP_USER".equals(loginUser.roleCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端接口取当前登录用户。
|
||||
* 管理端 token 打到客户端接口时直接拒绝:两个体系的用户 ID 不在同一张表,混用没有意义。
|
||||
*/
|
||||
public static Long getAppUserId() {
|
||||
LoginUser loginUser = getLoginUserOrNull();
|
||||
if (!isAppUser(loginUser)) {
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED, "请先登录");
|
||||
}
|
||||
return loginUser.userId();
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.fruitshorts.framework.web;
|
||||
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.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);
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.fruitshorts.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.fruitshorts.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