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:
Vicky committed 2026-10-15 11:44:26 +08:00
1 parent a4aa749cc6
commit b3179fc836
251 files changed
+7261 -767

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.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>
@@ -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;
}
}
@@ -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 {
}
@@ -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 "";
}
@@ -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;
}
}
}
@@ -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;
}
@@ -0,0 +1,9 @@
package com.fruitshorts.framework.log;
/**
* 操作日志落库的扩展点,由业务模块实现(框架层不依赖具体存储)。
*/
public interface OperationLogSink {
void save(OperationLogEntry entry);
}
@@ -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) {
}
}
@@ -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 * * *";
}
@@ -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);
}
@@ -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());
}
}
@@ -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;
}
@@ -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);
}
}
@@ -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());
}
}
@@ -0,0 +1,12 @@
package com.fruitshorts.framework.security;
import java.util.List;
/**
* 权限来源的扩展点:由业务模块实现,用于按用户实时查询权限码。
* 没有实现时,权限判断退回到 JWT 中携带的权限列表。
*/
public interface PermissionProvider {
List<String> listPermissionsByUserId(Long userId);
}
@@ -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));
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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();
}
}