跳转至

PlayEdu 源码阅读笔记

个人学习记录。PlayEdu = 线上培训系统,Java + MySQL + React,前后端分离。后端 Spring Boot 3.3.4 / Java17 / MyBatis-Plus / Sa-Token(JWT)。 本文从「部署」切入(第一个小节),后续再补认证、权限、业务等。重要的多写、带图,次要的简记;优点标 ⭐、坑标 ⚠️。


一、部署架构(一键 Compose + 多阶段构建 + 内嵌迁移)

0. 总览:两容器,app 容器内 nginx + java 共存

一条命令起全套:docker-compose up -d,只有两个服务。

docker-compose
 ├── playedu  (一个镜像,容器内同时跑 nginx + java)
 │     nginx → 托 3 个前端静态站 + 把 /api 反代给同容器的 java
 │     java  → Spring Boot,监听 9898,启动时自动建表(迁移)
 └── mysql    (定制 mysql:8.1 + my.cnf + 命名卷 mysql-data 持久化)
端口(宿主→容器) 用途
9900 → 9900 后台 admin(默认 admin@playedu.xyz / playedu
9800 → 9800 PC 网页前台
9801 → 9801 H5 移动端
9700 → 9898 API(java 直连口)
23307 → 3306 MySQL

⚠️ 单容器塞 nginx + java 是"体验优先"的反生产实践CMD nginx; sleep 15; java -jar,无进程守护(nginx 挂了无人知)、sleep 15 傻等 MySQL。一键 demo 很香,生产应拆成独立容器。


1. Dockerfile 多阶段构建(核心花样)⭐

一个 Dockerfile 多个 FROM,每个 FROM = 一个独立、用完即弃的构建车间;只有最后一个 stage 成为最终镜像,前面的全丢弃。

阶段 基础镜像 干什么
node-builder node:20 分别 build 三个 React 前端 admin/pc/h5 → dist
java-builder temurin:17 mvnw package 打出 playedu-api.jar
base(最终镜像) temurin:17(定制版含 nginx) 把 jar + 三份 dist + nginx.conf 拢一起

解决的问题:构建期要的(node/pnpm/maven/源码/node_modules/.m2)又大又只在编译时用;运行期只要产物(jar + dist)。多阶段让你用"胖"镜像编译、只把产物倒进"瘦"镜像——构建工具/源码全留在被丢弃的车间里。

合并靠 COPY --from=<stage>(最终 stage 从前面 stage 文件系统隔空拣文件):

FROM temurin:17 AS base
COPY --from=java-builder /app/.../playedu-api.jar /app/api/app.jar
COPY --from=node-builder /app/admin/dist /app/admin
COPY --from=node-builder /app/pc/dist    /app/pc
COPY --from=node-builder /app/h5/dist    /app/h5
COPY docker/nginx/conf/nginx.conf /etc/nginx/...   #  --from = 从构建上下文(宿主)

注:temurin:17 本身不含 nginx,但 CMD 跑 nginx → 说明这个定制 base 镜像预装了 nginx。最终 stage 的"运行底座"决定运行时有哪些环境(这里 JRE + nginx),产物只是 COPY 进去。


2. 依赖层缓存优化(先依赖、后源码)⭐⭐

最值得学的一招。 Docker 镜像分层,某层输入没变就复用缓存、且任一层失效会级联冲掉其后所有层。利用"依赖清单很少变、源码常变",把两者拆成不同层:

后端版(Dockerfile):

COPY mvnw .mvn pom.xml + 6个模块pom        #  只拷依赖清单
RUN  mvnw -B -DskipTests dependency:go-offline   #  下载全部依赖  这层被缓存
COPY playedu-api /app                       #  现在才拷源码
RUN  mvnw -B -Dmaven.test.skip=true package #  编译(依赖已在本地,飞快)

  • dependency:go-offline = 把构建所需依赖预下载到本地 .m2,不编译代码
  • 关键:第②步只依赖 pom。改 .java 源码 → ① 没变 → ② 缓存命中、几百 MB 依赖一个都不重下;只有改 pom 才触发重下。
  • ⚠️ 注意 Docker 对 RUN 的缓存只看命令字符串 + 上游层不感知命令读了哪些文件——②会因 pom 变化而重跑,靠的是"COPY pom 那层在前面、级联失效",不是 Docker"懂"它依赖 pom。缓存优化的本质是编排指令顺序

前端版(同思路):先 COPY package.json + pnpm-lockpnpm i,再 COPY 源码 build。一个 Maven 版、一个 pnpm 版,套路一样。


3. Maven Wrapper + 阿里云源 ⭐

mvnw / .mvn/Maven Wrapper:项目自带"装 Maven 的脚本",不依赖机器预装 mvn,版本锁定 + 零预装(CI/Docker 友好)。

文件 作用
mvnw/mvnw.cmd 包装脚本
.mvn/wrapper/maven-wrapper.properties distributionUrl=下哪个 Maven(3.8.6,从白书自己的 download 站)
.mvn/maven.config Maven 自动读取,内容当命令行参数追加
.mvn/settings.xml 阿里云镜像配在这

阿里云源生效链路.mvn/maven.config 里写了 -s .mvn/settings.xml -T 1C → Maven 每次自动加上 -s 指向项目内 settings.xml → 里面 <mirror><mirrorOf>central</mirrorOf> 把 central 改走 maven.aliyun.com放项目里是为了让干净的构建环境(尤其 Docker,没 ~/.m2/settings.xml)也能用上镜像。

⚠️ 别混淆两层"阿里云":

加速对象 用的阿里云 配在哪
Docker 基础镜像 registry.cn-hangzhou.aliyuncs.com Dockerfile 的 FROM
Maven jar 依赖 maven.aliyun.com/repository/public .mvn/settings.xml

4. 前后端如何整合(nginx 反代)⭐

一个容器内 nginx 同时托三个前端静态站 + 把 /api 反代给同容器的 java。 三个 server 块结构相同(docker/nginx/conf/nginx.conf):

server {
    listen 9900;                 # admin(pc=9800, h5=9801 同理)
    root   /app/admin;           # 指向该前端的 dist
    location /api/ {
        proxy_pass http://127.0.0.1:9898/;   # ← /api 反代给同容器 java
    }
    location ~* ^/(?![api].*) {
        try_files $uri /index.html;          # ← SPA 前端路由兜底
    }
}

整合的两个关键点:

  1. 前端用相对路径 /api/ 调接口——构建时 VITE_APP_URL=/api/(Dockerfile 里三个前端都这么 build)。前端不写死后端域名/端口,直接发 /api/xxx
  2. nginx 同源反代——前端站和 /api 在同一个 nginx server 下,浏览器看来同源(无跨域),nginx 把 /api/ 转给 127.0.0.1:9898 的 java。

请求流向:

浏览器 → http://宿主:9900/api/xxx
        → nginx(9900) 的 location /api/  → proxy_pass 127.0.0.1:9898 (java)
        → Spring Boot 处理 → 原路返回
静态资源 / 前端路由 → try_files → 命中 dist 文件 或 回退 index.html(SPA)

好处:前端零硬编码后端地址、天然同源无跨域、一个容器自洽。代价:nginx 和 java 绑死在一个容器(见总览的反生产实践)。


5. 环境变量如何影响 web 服务(双层默认值链路)⭐

全配置走环境变量 + 默认值,所以零配置可起。 一个变量从宿主到 Spring 配置,经过两层 ${VAR:-默认}

宿主 .env / PLAYEDU_* 环境变量
   │  ① compose.yml:  DB_HOST=${PLAYEDU_DB_HOST:-mysql}      ← 第一层默认(给容器)
容器内环境变量  DB_HOST=mysql
   │  ② application.yml:  ${DB_HOST:127.0.0.1}                ← 第二层默认(给本地dev)
Spring 实际配置  spring.datasource.url = jdbc:mysql://mysql:3306/playedu
HikariCP 建连 → MyBatis-Plus → 整个 web 服务跑起来

两层默认值各管一个场景(巧妙):

  • compose 的 ${PLAYEDU_DB_HOST:-mysql} → 容器里 DB_HOST 总会被设成 mysql(或宿主覆盖值);
  • yml 的 ${DB_HOST:127.0.0.1} → 只在脱离 compose 跑(本地 IDE 调试、没设 DB_HOST)时才用 127.0.0.1
  • 即:compose 默认值服务于容器部署,yml 默认值服务于本地开发,互不打架。

关键变量 → 影响什么application.yml / compose.yml):

环境变量 application.yml 落点 影响
DB_HOST/PORT/NAME/USER/PASS spring.datasource.url/username/password 连哪个库
DB_POOL_* spring.datasource.hikari.* 连接池大小/超时
SERVER_PORT server.port(默认 9898) java 监听端口(要和 nginx 反代目标一致)
SA_TOKEN_IS_CONCURRENT sa-token.is-concurrent(默认 false 是否允许同账号多端登录(false=后登顶替前登)
SA_TOKEN_JWT_SECRET_KEY sa-token.jwt-secret-key(默认 playeduxyz JWT 签名密钥(安全命门,见后续认证小节)
DEMO_MODE playedu.demo.enabled 演示模式:删除只删 DB、跳过 S3 真删
SPRING_PROFILES_ACTIVE profile(默认 dev) 激活哪套配置

⚠️ 默认值即危险默认:不设环境变量直接起,就用上了空 DB 密码、SA_TOKEN_JWT_SECRET_KEY=playeduxyz、DB root 默认 playeduxyz——部署方不改环境变量 = 裸奔。


6. SQL 初始化(⚠️ 太难看:代码内嵌迁移)

没有任何 .sql 文件、没走 MySQL entrypoint、不是 Flyway。手搓的 Laravel 式迁移,由 MigrationCheck.java(一个 CommandLineRunner@Order(10))在应用启动时执行:

public static final List<Map<String,String>> TABLE_SQL = {...30+张表的DDL硬编码成Java文本块...};
public void run(String... args) {
    tables    = jdbcTemplate.queryForList("show tables", ...);   // 现有表
    migrations = migrationService.all();                          // 已跑过的迁移名
    for (每条 TABLE_SQL) {
        if (已在 migrations )        continue;                  // 跑过→跳过
        if (表已存在但无记录)  store(name);                       // 补记录
        else { jdbcTemplate.execute(sql); store(name); }         // 建表+记录
    }
}
  • 用一张 migrations 表按 name 去重做幂等——就是 Laravel migration 的 Java 翻版(团队 PHP/Laravel 背景)。
  • 这串起了 sleep 15:java 一启动就跑迁移连 MySQL 建表,MySQL 没 ready 就建不出来 → 所以 CMD 要 sleep 15 等它。迁移机制正是 sleep 的根因。

⚠️ 坏味道("难看"在哪)

  1. DDL 塞进 880 行 Java 文本块——难 diff、无校验和、无回滚、无版本顺序强校验;改一条已应用迁移的 SQL 不会重跑也不报警(静默漂移,比 Flyway 弱)。
  2. catch 所有异常只 log——迁移失败不阻断启动,应用带病运行,后续查不存在的表才报错。
  3. COMMENT 大量复制粘贴错migrations 表注释写成"课程章节表"、course_chapters 写"管理员权限表"、users 记录注释"分类表"……复制改 SQL 没改注释。
  4. 几乎没种子数据:这里只建表 + 一条往 migrations 补历史记录;默认管理员/权限/配置的种子在别处。

对照 mars-admin:那边是外置 .sql 文件(还把明文凭证写进了开源 SQL)。PlayEdu 是代码内嵌迁移自动建表——部署方零手动执行 SQL,但 DDL 进 Java、改动难追踪。两种思路各有取舍。


7. 日志轮转(x-logging 锚点复用)⭐

x-logging: &default-logging       # x- = compose 扩展字段(被忽略,放可复用片段); & = YAML 锚点
  driver: "json-file"
  options: { max-size: "10m", max-file: "10" }   # 日志轮转:每容器最多~100MB
# 各服务: logging: *default-logging               # * = 别名,引用锚点(一处定义多处复用)
  • 作用:防 json-file 日志无限增长撑爆磁盘(生产易漏的一条)。
  • 注意:json-file 抓的是容器主进程的 stdout/stderrdocker logs 读的就是它),不抓应用写进容器文件系统的日志文件。云原生提倡日志打 stdout 正是为了能被统一采集。

部署速查(⭐ 学到的 / ⚠️ 坑)

⭐ 值得学的"新花样"

  1. 多阶段构建:胖镜像编译、COPY --from 把产物倒进瘦镜像。
  2. 依赖层缓存:先 COPY 依赖清单单独装依赖、再 COPY 源码——本质是编排指令顺序利用 Docker 层缓存(前端 pnpm / 后端 maven 同理)。
  3. Maven Wrapper + 项目内阿里云 settings.xml:干净环境也能用镜像源。
  4. 前后端整合:前端 VITE_APP_URL=/api/ 相对路径 + nginx 同源反代到 java,零跨域零硬编码。
  5. 双层默认值环境变量:compose 默认服务容器、yml 默认服务本地 dev,零配置可起。
  6. x-logging 锚点 + 日志轮转防撑爆磁盘。

⚠️ 坑 / 反模式

  1. 单容器塞 nginx+java、sleep 15 等 MySQL、缺 healthcheck(应拆容器 + depends_on: condition: service_healthy)。
  2. SQL 初始化:DDL 硬编码进 Java、异常只 log 不阻断、COMMENT 复制粘贴错、无回滚/校验和。
  3. 危险默认值进仓:空 DB 密码、SA_TOKEN_JWT_SECRET_KEY=playeduxyz、DB root playeduxyz

二、认证鉴权(Sa-Token JWT + 自实现 RBAC)

0. 总览:Sa-Token 管登录态,权限自己算

PlayEdu 用了 Sa-Token + JWT,但没有像 mars-admin 那样完整走 StpInterface + @SaCheckPermission。它主要把 Sa-Token 当成登录态 / Token 工具

  • StpUtil.login(...):签发 token;
  • StpUtil.isLogin():判断是否登录;
  • StpUtil.getLoginId():取当前登录 id;
  • StpUtil.getExtra("prv"):从 JWT 里取账号类型。

后台权限是项目自己实现的:

@BackendPermission(slug)
BackendPermissionAspect
BackendBus.adminUserPermissions(adminId)
admin_user_role / admin_role_permission / admin_permissions

所以整体分三层:

第一层:ApiInterceptor
  全局 IP 限流,不是权限。

第二层:AdminInterceptor / FrontInterceptor
  认证身份:有没有登录、token 是管理员还是学员、账号是否可用。

第三层:
  后台:@BackendPermission + BackendPermissionAspect + BackendBus
  门户:没有通用 RBAC,主要靠业务代码做课程/部门隔离。

1. 登录:多账号体系把 AuthService 逼出来了

后台管理员登录链路:

/backend/v1/auth/login
校验邮箱、密码、是否禁用
BackendAuthService.loginUsingId(adminId, loginUrl)
AuthServiceImpl.loginUsingId(...)
StpUtil.login(...)
返回 token

登录时会往 JWT extra 里塞:

url: 登录地址
prv: 身份类型,区分 admin / user
exp: 过期时间戳

prv 是关键。PlayEdu 不是只判断“有没有登录”,还要判断“这个 token 是不是当前接口需要的账号类型”:

后台接口:token.extra.prv == JWT_PRV_ADMIN_USER
门户接口:token.extra.prv == JWT_PRV_USER

更别扭的是后台管理员 id 还做了偏移:

token loginId = 100000000 + adminId
业务 adminId = token loginId - 100000000

原因是项目没有使用 Sa-Token 原生多账号体系,而是两套账号共用全局 StpUtil。后台管理员和门户学员的 id 都可能从 1 开始,如果都直接 StpUtil.login(1),语义上会撞在同一个登录空间里。

于是项目用了双保险:

1. prv 区分 admin / user
2. adminId + 100000000 避免 loginId 撞车

这能跑,但确实难看。AuthService 这一层也基本是被这个设计逼出来的:统一封装 StpUtil.logincheck、真实 id 还原,避免业务代码到处手写 prv 和 id 偏移。


2. 后台请求鉴权流程

/backend/v1/course/index 为例:

请求 /backend/v1/course/index
ApiInterceptor
   ├─ 判断限流白名单
   ├─ 按 IP 统计访问次数
   └─ 超限返回 429
AdminInterceptor
   ├─ 读取系统配置,写入 BCtx
   ├─ 判断后台免登录白名单
   ├─ authService.check()
   │    ├─ StpUtil.isLogin()
   │    └─ token.extra.prv == admin
   ├─ StpUtil.getLoginId() - 100000000 得到真实 adminId
   ├─ 查询 AdminUser
   ├─ 判断管理员是否禁用
   ├─ 查询当前管理员权限,写入 BCtx
   └─ 放行
BackendPermissionAspect
   ├─ 读取 Controller 方法上的 @BackendPermission.slug
   ├─ 再次查询当前管理员权限
   ├─ 判断权限 map 是否包含 slug
   ├─ 没有权限:返回 403
   └─ 有权限:继续执行 Controller
Controller / Service / Mapper
afterCompletion
   └─ BCtx.remove()

后台不是“登录即有权限”。登录只说明 token 合法,真正的功能权限靠接口上的 @BackendPermission


3. 后台权限模型:固定权限字典 + 可配置角色

权限计算在 BackendBus.adminUserPermissions(adminId)

根据 adminId 查询角色 ids
如果没有角色,返回空权限
查询超级管理员角色 super-role
如果当前管理员拥有 super-role,返回所有权限
否则根据 roleIds 查询 permissionIds
再把 permissionIds 转成 permission slug map
  • 管理员(用户)管理;
  • 角色管理;
  • 管理员分配角色;
  • 角色绑定权限;
  • 前端按钮 / 菜单按权限隐藏;
  • 后端接口按 @BackendPermission 强校验。

只是菜单本身不是后端动态资源。菜单在前端写死,每个菜单挂一个权限 slug,然后根据当前管理员权限过滤展示。权限 slug 由代码定义,启动时同步到 admin_permissions 表。

这个设计问题不算大,属于够用型后台权限。代价是新增菜单/权限时要改代码并同步权限数据;如果是线上补权限,也可以直接插/改库处理。它不像 mars-admin 那种“菜单表 + 角色菜单 + 动态路由”的平台型权限系统,但对 PlayEdu 这种菜单相对固定的业务后台够用。


4. 门户用户:登录态 + 业务权限

门户 /api/v1/**FrontInterceptor

判断门户免登录白名单
authService.check()
   ├─ StpUtil.isLogin()
   └─ token.extra.prv == user
查询 User
判断是否锁定
写入 FCtx
放行 Controller

门户没有类似 @BackendPermission 的通用 RBAC,主要就是:

登录 / 未登录
账号正常 / 被锁定

更细的权限散在业务代码里,例如部门课程隔离:

课程限制部门
取当前用户所属部门及父级链
判断是否命中课程开放部门
决定能不能看课、播放课时、记录学习进度

也就是说门户侧是“业务资源权限”,不是后台那种“功能按钮权限”。


5. 多账号体系更好的做法

方案一:用 Sa-Token 原生多账号体系。

给后台和门户各自定义 StpLogic

public class StpAdminUtil {
    public static final StpLogic stpLogic = new StpLogic("admin");
}

public class StpUserUtil {
    public static final StpLogic stpLogic = new StpLogic("user");
}

这样身份天然是:

(loginType, loginId)

(admin, 1)
(user, 1)

两边 id 重叠也没关系,不需要 adminId + 100000000,也不需要额外用 prv 再判断一遍账号类型。

如果再进一步接入 Sa-Token RBAC,可以实现:

StpInterface.getPermissionList(loginId, loginType)

后台 loginType=admin 时返回权限 slug,门户 loginType=user 时返回空列表或只保留业务判断。

方案二:不用 Sa-Token 原生,也至少显式建模主体。

如果还想自己封装,也不要用数字偏移。可以把登录主体建模成:

subjectType: admin / user
subjectId: 真实 id

或者 loginId 直接用字符串:

admin:1
user:1

这比 100000001 清楚,也不会把业务 id 和认证 id 混在一起。


6. 优缺点

  1. Sa-Token 用得浅:没有用好原生多账号、StpInterface、权限缓存、@SaCheckPermission
  2. 多账号设计难看:prv + adminId 偏移 能跑,但不如 (loginType, loginId) 清楚。
  3. 权限查询重复:AdminInterceptor 算一遍权限写入 BCtxBackendPermissionAspect 又重新算一遍。
  4. 一次请求查库偏多:算权限时会查角色、超管角色、角色权限、权限详情等表,普通带权限接口轻松七八次库,甚至更多。
  5. 权限缺少缓存:角色权限低频变更,适合缓存到 SaSession / Redis / 本地缓存,变更时主动失效。
  6. RBAC 和数据权限混在一起:admin_permissions 同时放功能权限和数据字段权限,角色绑定时也是一起勾选;所谓数据权限主要是字段脱敏,不是独立的数据范围模型。

三、源码补充:模块、SQL、XML、事件

1. 模块拆分:拆了,但边界不清

后端拆成了 playedu-api / playedu-common / playedu-course / playedu-resource / playedu-system,但这个拆分不算特别清晰。

最别扭的是 commonsystem:直觉上系统级能力应该在 system,但很多系统基础能力其实混在 common

  • 认证服务:AuthServiceBackendAuthServiceFrontendAuthService
  • 上下文:BCtxFCtx
  • 系统常量 / 权限常量 / 配置常量;
  • 管理员、角色、权限、用户等领域对象;
  • 配置读取、用户服务、管理员服务等 service。

system 反而主要放启动检查、内嵌迁移、权限初始化、配置初始化、日志切面。整体更像按生成器习惯和历史演进拆出来的模块,不是严格的领域分层。


3. Spring Event:主要做关联维护

Event 主要用来做“主业务完成后的关联维护”,不是复杂的领域事件系统。

大致分四类:

  1. 登录记录:管理员登录后更新登录 IP、时间、次数;学员登录/登出后写 user_login_records
  2. 课程结构维护:新增/删除课时后重算课程 class_hour;删除章节后清理课时。
  3. 删除清理:删除课程后清理部门关联、分类关联、附件、学习记录;删除用户后清理部门关系、学习记录、学习时长、登录记录;删除部门/分类后解绑关系。
  4. 学习统计:课时完成后重算课程学习进度;学习心跳后写学习时长统计和明细。

它的好处是 Controller 没那么臃肿,删除/统计这类副作用被拆到 listener 里。

但它更多是代码形态解耦,不是可靠异步任务:大部分监听器只是同步 @EventListener,只有学员登录/登出用了 @Async。如果主操作和监听器不在同一个清晰事务边界内,某个清理步骤失败,就可能出现主表已删、部分关联没清完的半清理状态。


4. 学习进度追踪(冷门但通用:心跳 + 上报 + 锁 + 事件)

线上学习 / 视频观看进度 / 在线时长这类业务,这套机制可参考(HourController.java):

  • 三个前端接口/play 拿视频预签名 URL;/record 上报已观看时长(duration >= totalDuration 判完成);/ping 心跳(前端定时打)累计在线时长。
  • 防并发record / ping 各按 record:{userId} / ping:{userId} 加锁。
  • 心跳算时长:用 UserLastLearnTimeCache(内存)算两次心跳间隔,>10.5s 视为断开、补 10s。
  • 解耦:完成 / 时长用 ApplicationEvent 异步聚合(UserCourseHourFinishedEvent / UserLearnCourseUpdateEvent),不阻塞热路径。

⚠️ 为什么说不是最佳实践: 1. 进度静默丢失(硬伤)if (!tryLock) return JsonResponse.success();HourController.java:133)——拿不到锁就直接返回成功、根本不记录。多标签页 / 快速重复请求 / 多实例(锁是单 JVM)下,进度被悄悄丢弃,前端却收到 success。对"进度追踪"这种核心功能是硬伤。 2. 心跳 / 锁 / last-learn-time 全在进程内内存——多实例下时长统计错乱、锁失效(同 三.6#5 单点实现)。 3. 正解:拿不到锁应重试 / 合并而非丢弃报成功;进度上报用幂等 upsert + 唯一索引或 Redis 原子操作;心跳状态放 Redis 才能多实例一致。

5. 其它优缺点速记

⭐ 值得学

  1. S3/MinIO 资源访问方向是对的:资源桶要求私有,访问走预签名 URL,不直接公开对象。
  2. 敏感配置有打码处理SecretKey、LDAP 密码这类 is_private 配置,后台读取时显示 ********,保存时跳过未变动的掩码值。
  3. SQL 排序白名单sort_field 没有直接拼进 SQL,而是 XML <choose> 分支映射到固定字段。

⚠️ 坑 / 反模式

  1. 密码算法旧:管理员和学员密码都是 MD5(password + salt),有 salt 但仍是快哈希;更合适的是 BCrypt / Argon2 / PBKDF2。
  2. 单点实现较多:内存缓存、内存锁、内存限流、定时 LDAP 同步都偏单实例思路;规模化部署时要替换成 Redis / DB 锁 / 分布式任务调度。
  3. 视频防盗链偏弱:私有桶 + 预签名方向对(⭐#1),但预签名 URL 有效期长达 3 小时、无分段 token / HLS 加密 / referer 校验——窗口内链接可被自由转发、直接下载原始视频(真正的转码加密 / 防快进在企业版)。改进:有效期缩到分钟级 + HLS 切片每段签名。
  4. Mapper XML大部分是生成器空壳:项目用了 MyBatis-Plus,普通 CRUD 本来可以不用 XML。源码里 mapper/*.xml 很多,但大部分只有 resultMap / Base_Column_List,没有真正的 SQL,阅读时会制造噪声