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

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

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
# 构建产物
target/
# IDE / 编辑器
.idea/
*.iml
.vscode/
# 系统文件
.DS_Store
# 日志
*.log
logs/
# 运行期上传的媒资(图片、视频,几百 MB)不入库,只保留目录
data/uploads/*
!data/uploads/.gitkeep
+320
View File
@@ -0,0 +1,320 @@
# ReelShort 短剧服务(后端)
短剧服务的服务端,Spring Boot + PostgreSQL,目前实现管理后台接口(`/admin/**`)。
完整架构设计与迭代计划见 [docs/architecture-design.md](docs/architecture-design.md)。
| 项 | 版本 |
| --- | --- |
| 运行时 | JDK 21(LTS),编译目标 Java 17 |
| 框架 | Spring Boot 3.2.5、Spring Security 6、MyBatis-Plus 3.5.7 |
| 数据库 | PostgreSQL 18 |
| 构建 | Maven 3.10 多模块 |
## 目录结构
```
java/
├── reelshort-common/ 统一响应、分页、错误码、业务异常
├── reelshort-framework/ JWT 鉴权、权限校验、全局异常、MyBatis-Plus、CORS
├── reelshort-module-system/ 管理员、角色、权限、菜单、角色分类、配置、操作日志
├── reelshort-module-content/ 短剧、剧集、题材、审核发布
├── reelshort-admin-api/ 启动模块与 /admin/** 控制器
├── sql/ schema.sql 建表、data.sql 初始化数据
└── docs/ 架构设计文档
```
## 开发环境
本机环境由 Homebrew 统一管理(版本以本机实际安装为准):
| 组件 | 版本 | 说明 |
| --- | --- | --- |
| Homebrew | 7.0.9 | 远端指向清华镜像,见文末常见问题 |
| JDK | OpenJDK 21.0.12.1 | `brew install openjdk@21`,`JAVA_HOME` 已写入 `~/.zshrc` |
| Maven | 3.10.0 | `brew install maven`,命令为 `/opt/homebrew/bin/mvn` |
| PostgreSQL | 18.6 | `brew install postgresql@18`,`brew services start postgresql@18` 启动 |
> `postgresql@18` 是 keg-only,命令行工具在 `/opt/homebrew/opt/postgresql@18/bin`,
> 用之前把它加到 PATH:`export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH"`。
```sh
java -version # openjdk 21.0.12.1
mvn -v # Maven 3.10.0 / Java 21.0.12.1
```
说明:
- 工程编译目标为 Java 17(`maven.compiler.release=17`),在 JDK 21 上构建与运行。
- 父 POM 显式声明了注解处理器(`<proc>full</proc>` + `annotationProcessorPaths`):
JDK 23 起不再默认扫描 classpath 上的处理器,缺这段配置 Lombok 会失效。
- 旧的 JDK 18 仍留在 `/Library/Java/JavaVirtualMachines/jdk-18.0.2.jdk`,删除需要 sudo,
不影响使用(`java_home` 与 `JAVA_HOME` 都已指向 21)。
## 快速开始
服务有两个入口,可以只启动其中一个:
| 服务 | 模块 | 端口 | 面向 |
| --- | --- | --- | --- |
| 管理端 | `reelshort-admin-api` | 8080 | Vue 后台 |
| 客户端 | `reelshort-app-api` | 8081 | Flutter 客户端 |
### 1. 初始化数据库
```sh
export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH"
cd /Users/jianghaifeng/Desktop/ReelShort/FruitServer
psql -h 127.0.0.1 -U reelshort -d reelshort -f sql/schema.sql
psql -h 127.0.0.1 -U reelshort -d reelshort -f sql/data.sql
```
数据库名 `reelshort`,角色 `reelshort`(密码 `reelshort`),两个脚本都可重复执行。
首次搭建环境时先建角色与库:
```sh
psql -d postgres -c "CREATE ROLE reelshort LOGIN PASSWORD 'reelshort';"
psql -d postgres -c "CREATE DATABASE reelshort OWNER reelshort ENCODING 'UTF8';"
```
连接信息在
`reelshort-admin-api/src/main/resources/application.yml`,可用环境变量
`DB_USER` / `DB_PASSWORD` / `JWT_ADMIN_SECRET` 覆盖。
### 关于 PostgreSQL 的两点说明
- 自增列用 `GENERATED BY DEFAULT AS IDENTITY`;`sql/data.sql` 里用了显式 ID,
脚本末尾会把序列 `setval` 到当前最大值,新增数据不会主键冲突。
- PostgreSQL 没有 `ON UPDATE CURRENT_TIMESTAMP`,`updated_at` 由 `set_updated_at()`
触发器统一维护(见 `sql/schema.sql` 末尾),实体侧仍不参与更新该字段。
- 轮播图的展示位置列名叫 `slot`(`position` 在 PostgreSQL 里是关键字),
实体用 `@TableField("slot")` 映射,对外接口字段仍是 `position`,前端无需改动。
### 2. 构建
```sh
cd /Users/jianghaifeng/Desktop/ReelShort/FruitServer
mvn -DskipTests package
```
### 3. 启动
```sh
cd /Users/jianghaifeng/Desktop/ReelShort/FruitServer
java -jar reelshort-admin-api/target/reelshort-admin-api-1.0.0-SNAPSHOT.jar
java -jar reelshort-app-api/target/reelshort-app-api-1.0.0-SNAPSHOT.jar
```
启动后:服务地址 `http://localhost:8080`,接口文档 `http://localhost:8080/doc.html`。
## 默认账号
密码均为 `admin123`。
| 账号 | 角色 | 权限范围 |
| --- | --- | --- |
| admin | 超级管理员 | 全部 14 项权限 |
| content.editor01 | 内容运营 | 短剧、剧集的增删改与封面上传 |
| content.reviewer01 | 审核专员 | 查看内容、审核发布、数据报表 |
| data.analyst01 | 数据分析师 | 查看短剧列表与数据报表 |
## 接口一览
### 认证 `/admin/auth`
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/login` | 登录,返回 token 与权限码 |
| GET | `/menus` | 当前用户菜单路由树(前端动态路由) |
| GET | `/profile` | 当前登录人信息 |
| POST | `/logout` | 登出(JWT 无状态,前端清除 token 即可) |
| POST | `/password` | 修改自己的密码 |
### 后台管理
| 模块 | 接口 |
| --- | --- |
| 管理员 | `GET /admin/admin-users/page`、`POST /admin/admin-users`、`PUT\|DELETE /admin/admin-users/{id}`、`PUT /{id}/status`、`PUT /{id}/password` |
| 角色 | `GET /admin/roles/page`、`GET /admin/roles/list`、`POST /admin/roles`、`PUT\|DELETE /admin/roles/{id}`、`GET\|PUT /admin/roles/{id}/permissions` |
| 角色分类 | `GET\|POST /admin/role-categories`、`PUT\|DELETE /admin/role-categories/{id}` |
| 权限点 | `GET /admin/permissions`(按分组返回,供权限配置页使用) |
| 菜单 | `GET /admin/menus/tree`、`POST /admin/menus`、`PUT\|DELETE /admin/menus/{id}` |
| 系统配置 | `GET\|PUT /admin/configs` |
| 操作日志 | `GET /admin/operation-logs/page`(按操作人、模块、动作、结果、时间范围筛选)、`GET /admin/operation-logs/{id}` |
| 字典 | `GET /admin/dicts/drama-category`、`GET /admin/dicts/content-status` |
### 内容管理
| 模块 | 接口 |
| --- | --- |
| 短剧 | `GET /admin/dramas/page`、`GET /admin/dramas/{id}`、`POST /admin/dramas`、`PUT\|DELETE /admin/dramas/{id}` |
| 审核发布 | `POST /admin/dramas/{id}/submit`、`/approve`、`/reject`、`/publish`、`/offline` |
| 审核记录 | `GET /admin/dramas/{id}/review-records` |
| 剧集 | `GET /admin/episodes?dramaId=`、`POST /admin/episodes`、`PUT\|DELETE /admin/episodes/{id}`、`PUT /admin/episodes/sort` |
| 轮播图 | `GET /admin/banners/page`、`POST /admin/banners`、`PUT\|DELETE /admin/banners/{id}`、`PUT /admin/banners/{id}/status`、`PUT /admin/banners/sort` |
| 短剧类型 | `GET\|POST /admin/drama-categories`、`PUT\|DELETE /admin/drama-categories/{id}`(权限沿用 `drama:*`) |
### 媒资 `/admin/media`
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/upload/init` | 建立分片会话,返回 `uploadId`、`chunkSize`、`totalChunks` |
| POST | `/upload/chunk` | 上传单个分片(multipart:`uploadId`、`index`、`file`) |
| POST | `/upload/complete` | 合并分片,返回媒资(含可访问的 `url`) |
| GET | `/{id}` | 查询媒资 |
## 媒资存储(本地)
当前没有对象存储,文件直接落在本机磁盘:
| 项 | 默认值 | 说明 |
| --- | --- | --- |
| 存储目录 | `./data/uploads` | 配置项 `reelshort.media.root-dir`,按 `业务类型/年月/uuid.ext` 分目录 |
| 访问前缀 | `/files/**` | 配置项 `reelshort.media.public-prefix`,映射为静态资源 |
| 分片大小 | 5MB | `reelshort.media.chunk-size`,单文件上限 2GB |
| 请求上限 | 20MB | `spring.servlet.multipart`,只需容纳一个分片 |
要点:
- `/files/**` 在 `SecurityConfig` 里是公开路径,因为 `<img>` / `<video>` 无法携带请求头。
- 上传权限:封面用 `cover:upload`,视频用 `episode:upload`。
- `data/` 是运行时产物,不需要提交到版本库。
- 换成 OSS/S3 时只需替换 `MediaStorageService`(分片落盘、合并、生成访问地址三处),
`MediaService` 与前端接口都不用动。
## 统一约定
- 响应体 `{ code, message, data }`,成功 `code=0`。
- 分页请求 `pageIndex`(从 1 开始)、`pageSize`,响应 `{ total, list }`。
- 鉴权头 `Authorization: Bearer <token>`;未登录 `code=401`,无权限 `code=403`。
- 错误码见架构文档附录 A;业务错误如 `100000` 账号密码错误、`200003` 状态不允许该操作。
## 权限模型
## 客户端接口 `/app/**`
面向 Flutter 客户端,只暴露已上线内容,不含审核状态、操作日志等管理端字段。
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/app/home/feed` | 首页聚合:轮播(HOME_TOP)+ 分区(热门 / 新剧 / 各分类) |
| GET | `/app/discover/feed` | 发现页聚合:轮播(DISCOVER)+ 分类分区 |
| GET | `/app/categories` | 分类列表 |
| GET | `/app/dramas/{id}` | 详情,含剧集列表,一次请求即可渲染详情页 |
| GET | `/app/episodes/{id}/play` | 播放票据 |
约定:
- 只返回 `status = published` 的短剧与剧集,未上线按不存在处理。
- 图片与视频返回相对路径(`/files/...`),客户端拼上服务地址即可。
- 播放票据三种结果:`FREE`(返回 `playUrl`)、`COIN_REQUIRED`(返回 `unlockCoin`)、
`NO_MEDIA`(已上线但还没传视频)。
- `/app/**` 目前在安全配置里放行(浏览无需登录);手机号/邮箱登录与权益体系接入后再收紧。
性能(本机实测,预热后 30 次采样,目标 50ms):
| 接口 | 平均 | P95 | 最大 | 响应体 |
| --- | --- | --- | --- | --- |
| `/app/home/feed` | 13.6ms | 20.3ms | 23.4ms | 3.9KB |
| `/app/discover/feed` | 7.3ms | 8.1ms | 8.8ms | 1.7KB |
| `/app/categories` | 2.0ms | 2.1ms | 3.4ms | 0.2KB |
| `/app/dramas/{id}` | 2.8ms | 3.1ms | 3.5ms | 0.7KB |
| `/app/episodes/{id}/play` | 2.2ms | 2.6ms | 3.3ms | 0.2KB |
`SlowRequestLogger` 会对超过 50ms 的 `/app/**` 请求打 WARN 日志,
内容量增长或索引失效时能第一时间发现;首页聚合是查询最多的一档(分类 + 热门 + 新剧 + 各分类列表),
如果将来分类变多,优先给它加缓存。
## 权限模型
- 权限码随 JWT 下发(如 `drama:create`、`banner:list`),控制器用
`@PreAuthorize("@ss.hasPermission('drama:create')")` 校验。
- 菜单树由权限码派生:角色拥有哪些权限,就可见哪些菜单。
- 内容状态机:`draft → reviewing → approved → published → offline`。
草稿不能直接发布,已上线的短剧需先下线才能删除;每次流转写入审核记录与操作日志。
- 鉴权实时读库(`PermissionChecker` 通过 `PermissionProvider` 查当前用户的权限),
后台改了角色权限或菜单,**前端刷新一下即可生效,不用重新登录**。
前端在每次进入应用时会重新拉取 `GET /admin/auth/menus` 与 `GET /admin/auth/profile`。
## 操作日志
后台写操作由切面自动记录,**不需要在每个接口里手写**,在控制器方法上加注解即可:
```java
@OperationLog(module = "内容管理", action = "新增短剧类型")
public Result<Long> create(@Valid @RequestBody DramaCategorySaveReqVO req) { ... }
```
每次调用记录:操作人、操作时间、模块、动作、请求方式与地址、请求参数、请求体、
响应体、成功/失败、错误信息、耗时、IP。
约定:
- 只记写操作(POST/PUT/DELETE),GET 查询不入库,避免刷屏。
- 请求体与响应体超过 4000 字符会截断;密码、密钥、token 等字段自动替换成 `***`。
- 业务异常记 `FAIL` 并带错误信息;`@PreAuthorize` 拦下的越权访问由
`AccessDeniedRecorder` 单独记一条(模块=安全,动作=越权访问被拒绝)。
- 日志写入失败不影响业务(切面内兜底 try/catch)。
- 查看入口:后台管理 → 操作日志,权限码 `log:list`,默认只给超级管理员。
## 前端对接
管理后台 `../ReelShortWeb` 已完成全部对接,本地演示数据已删除:
| 页面 | 使用接口 |
| --- | --- |
| 登录 / 左侧菜单 | `POST /admin/auth/login`、`GET /admin/auth/menus` |
| 工作台 | `GET /admin/dramas/page`(统计与最近更新) |
| 短剧管理 | `/admin/dramas/**`、`/admin/dicts/drama-category`、审核发布动作 |
| 剧集管理 | `/admin/episodes/**`、`PUT /admin/episodes/sort` |
| 角色权限配置 | `/admin/roles/**`、`GET /admin/permissions`、`GET /admin/role-categories` |
| 管理员用户列表 | `/admin/admin-users/**` |
| 菜单管理 | `GET /admin/menus/tree` + 增删改 |
| 角色分类 | `/admin/role-categories/**` |
| 系统设置 | `GET\|PUT /admin/configs` |
前端侧改动:
1. `.env.base` 关闭 mock(`VITE_USE_MOCK=false`);
2. `vite.config.mts` 增加 `/admin` 代理到 `http://127.0.0.1:8080`;
3. `src/api/admin/*.ts` 封装全部管理端接口;
4. 请求头自动携带 `Authorization: Bearer <token>`;
5. `src/stores/rbac.js` 的 `can(code)` 改用登录返回的权限码,
原 `src/data/`、`src/permissions/` 下的演示数据已删除;
6. 表格状态列改用接口返回的 `status` / `statusText`,
大数值用 `src/utils/format.js` 的 `formatCount` 格式化。
## 尚未实现
视频转码(HLS 多码率)、C 端接口(`/app/**`)、金币与会员、单集解锁、支付与对账、
数据看板统计、Redis 缓存与限流。详见架构设计文档第 11 章。
## 常见问题
**编译报「找不到符号 getXxx / 无法推断类型参数」**
Lombok 注解处理器没跑起来。确认是在 JDK 21 下构建(`mvn -v` 应显示 Java 21),
父 POM 已声明 `annotationProcessorPaths`,若仍报错先 `mvn clean`。
**8080 端口被占用**
```sh
lsof -tiTCP:8080 -sTCP:LISTEN | xargs kill
```
**brew 连不上 GitHub**
本机访问 `github.com` 不通,brew 仓库已改用清华镜像:
```sh
git -C /opt/homebrew remote set-url origin https://mirrors.tuna.tsinghua.edu.cn/git/homebrew/brew.git
```
Bottle 走 USTC 镜像(环境变量 `HOMEBREW_BOTTLE_DOMAIN`)。
要换回官方源,把 `origin` 改回 `https://github.com/Homebrew/brew` 即可。
**临时切换 JDK**
```sh
export JAVA_HOME=$(/usr/libexec/java_home -v 21)
```
Homebrew 的 Maven 在 `JAVA_HOME` 未设置时会回退到它自带的 JDK(当前为 27),
父 POM 的注解处理器配置保证这种情况下也能编译通过。
+1
View File
@@ -0,0 +1 @@
+878
View File
@@ -0,0 +1,878 @@
# ReelShort 短剧服务 · 后端架构设计
> 技术栈:JDK 21(LTS,编译目标 Java 17)+ Spring Boot 3.2 + PostgreSQL 18 + Redis
>
> 实现说明:项目已从 MySQL 切换到 PostgreSQL。本文档第 5 章的 DDL 仍按 MySQL 语法书写,
> 实际建表语句见 `sql/schema.sql`(PostgreSQL 版),两者的字段与索引一一对应;
> 主要差异是自增列用 IDENTITY、`updated_at` 用触发器维护、索引独立创建。
> 面向两端:管理后台(ReelShortWeb / Vue3)、客户端(ReelShort / Flutter)
> 版本:v1(2026-10-14)
---
## 1. 背景与目标
### 1.1 现状
| 端 | 目录 | 技术 | 状态 |
| --- | --- | --- | --- |
| 管理后台 | `ReelShort/ReelShortWeb` | Vue3 + Vite + element-plus-admin | 页面完成,数据为前端 mock |
| 客户端 | `ReelShort/ReelShort` | Flutter | 页面完成,数据为本地演示数据 |
| 服务端 | `FruitServer` | Spring Boot 3 + MyBatis-Plus + PostgreSQL | 已实现 |
两端的领域模型已经落在代码里,后端要按它们已有的字段和交互来设计,而不是重新发明一套:
- 后台:短剧(`title / genre / status / episodes / views / updatedAt / description / cover`)、剧集(`dramaId / number / title / duration / status / views`)、角色、角色分类、权限点、菜单、管理员、系统设置。
- 客户端:短剧目录、剧集列表(`isFree` 决定能否播放)、收藏、点赞、评论、观看历史、金币、会员。
- 后台现在靠 mock 提供登录(`POST /mock/user/login`)与菜单路由树(`GET /mock/role/list`);统一响应壳是 `{ code: 0, message, data }`,鉴权头是 `Authorization`,`code === 401` 触发登出。后端必须能一比一替换掉这两个 mock。
### 1.2 设计目标
1. 一套服务同时支撑 B 端(管理后台)与 C 端(客户端),共用内容域数据。
2. 先做模块化单体,边界清晰,规模上来后可按模块拆分,不做超前微服务化。
3. 内容生产链路(上传 → 转码 → 审核 → 发布)与播放链路(鉴权 → 签名地址 → 防盗链)是两条主线,一次设计到位。
4. 变现能力(金币 / 会员 / 单集解锁 / 充值订单)从第一天就纳入领域模型,避免后期改表。
---
## 2. 总体架构
```
┌──────────────────┐ ┌──────────────────┐
│ Flutter 客户端 │ │ Vue 管理后台 │
└────────┬─────────┘ └────────┬─────────┘
│ /app/** │ /admin/**
└────────────┬──────────────┘
▼
┌───────────────┐
│ Nginx / SLB │ 路由、TLS、限流、静态资源
└───────┬───────┘
▼
┌─────────────────────────────┐
│ Spring Boot 应用(单体多模块)│
│ admin-api / app-api / job │
└───┬───────┬────────┬────────┘
│ │ │
┌─────▼──┐ ┌──▼───┐ ┌──▼─────────┐
│ PgSQL │ │Redis │ │ 对象存储+CDN │
└────────┘ └──────┘ └─────┬──────┘
│
┌────────▼────────┐
│ 转码 Worker(FFmpeg)│ 消费队列,产出 HLS/MP4
└─────────────────┘
```
分层原则:
- 接入层:`admin-api` 与 `app-api` 是两个独立入口,各自独立的鉴权过滤器、限流策略、参数校验,互不污染。
- 领域层:按业务域切模块(system / content / media / member / interaction / trade / stat),模块之间只通过 Service 接口调用,禁止跨模块直连 Mapper。
- 基础设施层:`framework` 收敛所有技术细节(安全、缓存、MyBatis、日志、OSS SDK),业务模块不直接依赖第三方 SDK。
---
## 3. 技术选型
| 领域 | 选型 | 说明 |
| --- | --- | --- |
| 语言/运行时 | JDK 21(LTS) | 编译目标 Java 17;Spring Boot 3 要求 17+ |
| 框架 | Spring Boot 3.2.x + Spring MVC | 前后端分离,纯 REST |
| 持久层 | MyBatis-Plus 3.5.x | 后台 CRUD 密集,代码生成 + 分页插件省事 |
| 数据库 | PostgreSQL 18(UTF8) | 逻辑删除 + 乐观锁字段统一规范;DDL 见 sql/schema.sql |
| 缓存 | Redis 7 | 登录态、验证码、热点目录、播放签名、分布式锁 |
| 鉴权 | Spring Security + JWT(jjwt) | B 端与 C 端共用签发逻辑,各自独立过滤器 |
| 对象存储 | 阿里云 OSS / S3 兼容(本地用 MinIO) | 视频与封面,分片直传 |
| 转码 | FFmpeg + 异步队列 | 产出 HLS(多码率)+ MP4 兜底 + 首帧封面 |
| 消息队列 | RabbitMQ(起步) | 转码任务、订单超时、统计埋点;量级大再换 Kafka |
| 接口文档 | springdoc-openapi | 自动生成 OpenAPI 3 |
| 定时任务 | Spring Scheduling / XXL-Job | 定时发布、日报聚合、支付对账 |
| 构建 | Maven 多模块 | 见第 4 节 |
---
## 4. 工程结构(Maven 多模块)
```
java/
├── pom.xml # 聚合父 POM,统一依赖版本
├── reelshort-common/ # 统一响应/异常/常量/枚举/工具
├── reelshort-framework/ # 安全、MyBatis、Redis、日志、限流、OSS、MQ 客户端
├── reelshort-module-system/ # 管理员、角色、权限、菜单、配置、操作日志
├── reelshort-module-content/ # 短剧、剧集、分类标签、审核发布
├── reelshort-module-media/ # 媒资上传、转码、播放地址签名
├── reelshort-module-member/ # C 端用户、登录、设备、资料
├── reelshort-module-interaction/ # 收藏、点赞、评论、观看历史
├── reelshort-module-trade/ # 金币、流水、解锁、订单、VIP
├── reelshort-module-stat/ # 数据看板、日聚合
├── reelshort-admin-api/ # B 端接口,/admin/**
├── reelshort-app-api/ # C 端接口,/app/**
└── reelshort-job/ # 定时任务与异步消费者
```
起步阶段若不想铺这么开,可先合并为 `reelshort-common` + `reelshort-server`(含 system/content/media)+ `reelshort-app-api`,但包结构仍按域划分(`com.reelshort.content.*`),后续拆模块零成本。
模块内部结构保持一致:
```
com.reelshort.content
├── controller/ # 仅 admin-api / app-api 暴露
├── service/ # 接口 + impl
├── dal/
│ ├── entity/ # 数据库实体
│ ├── mapper/ # MyBatis-Plus Mapper
│ └── redis/ # 该域的缓存操作
├── convert/ # entity <-> VO/DTO 转换(MapStruct)
└── api/ # 暴露给其它模块的 Service 接口
```
命名约定:
- 包名 `com.reelshort.<module>.<layer>`
- 实体 `Drama`、`DramaEpisode`、`AppUser`
- 请求对象 `XxxCreateReqVO` / `XxxUpdateReqVO` / `XxxPageReqVO`,响应对象 `XxxRespVO`
- 表名 `<域前缀>_<业务>`,如 `drama`、`drama_episode`、`sys_role`、`app_user`、`stat_drama_daily`
---
## 5. 领域模型与数据库设计
### 5.1 设计约定
- 主键统一 `bigint unsigned`;对外可选用雪花 ID,不暴露自增趋势。
- 所有表带 `created_at / updated_at`,业务表带 `deleted tinyint default 0`(逻辑删除)。
- 金额一律用 `int` 分或 `bigint` 微单位,禁用 float;金币用 `bigint`。
- 状态字段存英文码,中文只用于前端展示,不把界面文案写进数据库。后台现有文案映射:
| 前端展示 | 存储码 |
| --- | --- |
| 草稿 | `draft` |
| 审核中 / 待审核 | `reviewing` |
| 待发布 | `approved` |
| 已上线 | `published` |
| 已下线 | `offline` |
### 5.2 系统域(B 端权限)
```sql
CREATE TABLE sys_admin_user (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) NOT NULL COMMENT '登录名',
password VARCHAR(100) NOT NULL COMMENT 'BCrypt 哈希',
real_name VARCHAR(64) NOT NULL DEFAULT '',
mobile VARCHAR(20) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
avatar VARCHAR(255) NOT NULL DEFAULT '',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
ip_whitelist VARCHAR(255) NOT NULL DEFAULT '' COMMENT '空=不限',
two_factor TINYINT NOT NULL DEFAULT 0 COMMENT '1已开启 0未开启',
last_login_at DATETIME(3) NULL,
last_login_ip VARCHAR(64) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT NOT NULL DEFAULT 0,
UNIQUE KEY uk_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE sys_role_category (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(64) NOT NULL,
description VARCHAR(255) NOT NULL DEFAULT '',
sort INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT NOT NULL DEFAULT 0,
UNIQUE KEY uk_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE sys_role (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(64) NOT NULL,
code VARCHAR(64) NOT NULL COMMENT '如 super_admin / content_operator',
category_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
level INT NOT NULL DEFAULT 9 COMMENT '越小权限越高',
description VARCHAR(255) NOT NULL DEFAULT '',
status TINYINT NOT NULL DEFAULT 1,
is_builtin TINYINT NOT NULL DEFAULT 0 COMMENT '内置角色不可删',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT NOT NULL DEFAULT 0,
UNIQUE KEY uk_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE sys_admin_user_role (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
role_id BIGINT UNSIGNED NOT NULL,
UNIQUE KEY uk_user_role (user_id, role_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE sys_permission (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
code VARCHAR(128) NOT NULL COMMENT '如 drama:create',
name VARCHAR(64) NOT NULL COMMENT '如 创建短剧',
group_name VARCHAR(64) NOT NULL COMMENT '如 短剧管理',
type VARCHAR(16) NOT NULL DEFAULT 'BUTTON' COMMENT 'MENU/BUTTON',
sort INT NOT NULL DEFAULT 0,
UNIQUE KEY uk_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE sys_menu (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
title VARCHAR(64) NOT NULL,
route_path VARCHAR(128) NOT NULL DEFAULT '',
route_name VARCHAR(64) NOT NULL DEFAULT '',
component VARCHAR(255) NOT NULL DEFAULT '' COMMENT '如 views/contentManagement/DramaManagementView',
icon VARCHAR(64) NOT NULL DEFAULT '',
permission_code VARCHAR(128) NOT NULL DEFAULT '',
type VARCHAR(16) NOT NULL DEFAULT 'MENU' COMMENT 'CATALOG/MENU/BUTTON',
sort INT NOT NULL DEFAULT 0,
visible TINYINT NOT NULL DEFAULT 1,
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE sys_role_permission (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
role_id BIGINT UNSIGNED NOT NULL,
permission_id BIGINT UNSIGNED NOT NULL,
UNIQUE KEY uk_role_perm (role_id, permission_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE sys_role_menu (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
role_id BIGINT UNSIGNED NOT NULL,
menu_id BIGINT UNSIGNED NOT NULL,
UNIQUE KEY uk_role_menu (role_id, menu_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE sys_config (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
config_key VARCHAR(128) NOT NULL,
config_value VARCHAR(1024) NOT NULL DEFAULT '',
remark VARCHAR(255) NOT NULL DEFAULT '',
UNIQUE KEY uk_key (config_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE sys_operation_log (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
admin_user_id BIGINT UNSIGNED NOT NULL,
username VARCHAR(64) NOT NULL DEFAULT '',
module VARCHAR(64) NOT NULL DEFAULT '',
action VARCHAR(64) NOT NULL DEFAULT '',
target_type VARCHAR(64) NOT NULL DEFAULT '',
target_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
detail JSON NULL,
ip VARCHAR(64) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
KEY idx_admin_time (admin_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 5.3 内容域
```sql
CREATE TABLE drama_category (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(64) NOT NULL,
sort INT NOT NULL DEFAULT 0,
status TINYINT NOT NULL DEFAULT 1,
UNIQUE KEY uk_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE drama (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
code VARCHAR(64) NOT NULL DEFAULT '' COMMENT '对外编号',
title VARCHAR(128) NOT NULL,
subtitle VARCHAR(128) NOT NULL DEFAULT '',
category_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
genre VARCHAR(64) NOT NULL DEFAULT '' COMMENT '展示用题材',
synopsis VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '简介,对应前端 description',
cover_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '竖版封面',
horizontal_cover_url VARCHAR(512) NOT NULL DEFAULT '',
language VARCHAR(16) NOT NULL DEFAULT 'zh-CN',
total_episodes INT NOT NULL DEFAULT 0 COMMENT '冗余,由剧集同步',
free_episodes INT NOT NULL DEFAULT 3 COMMENT '前 N 集免费',
unlock_coin INT NOT NULL DEFAULT 0 COMMENT '默认单集解锁金币',
status VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT 'draft/reviewing/approved/published/offline',
publish_at DATETIME(3) NULL COMMENT '定时上线时间',
published_at DATETIME(3) NULL,
view_count BIGINT NOT NULL DEFAULT 0 COMMENT '展示口径播放量',
play_count BIGINT NOT NULL DEFAULT 0,
favorite_count BIGINT NOT NULL DEFAULT 0,
like_count BIGINT NOT NULL DEFAULT 0,
sort INT NOT NULL DEFAULT 0,
create_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT NOT NULL DEFAULT 0,
KEY idx_status_publish (status, publish_at),
KEY idx_category (category_id),
KEY idx_updated (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE drama_episode (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
drama_id BIGINT UNSIGNED NOT NULL,
ep_no INT NOT NULL COMMENT '第几集,对应前端 number',
title VARCHAR(128) NOT NULL DEFAULT '',
duration_sec INT NOT NULL DEFAULT 0 COMMENT '前端展示 mm:ss,存秒',
cover_url VARCHAR(512) NOT NULL DEFAULT '',
video_asset_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '指向 media_asset',
is_free TINYINT NOT NULL DEFAULT 0,
unlock_coin INT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT 'draft/approved/published/offline',
release_at DATETIME(3) NULL COMMENT '预约/定时发布',
play_count BIGINT NOT NULL DEFAULT 0,
sort INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT NOT NULL DEFAULT 0,
UNIQUE KEY uk_drama_ep (drama_id, ep_no),
KEY idx_drama_status (drama_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE drama_review_record (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
biz_type VARCHAR(16) NOT NULL COMMENT 'DRAMA/EPISODE',
biz_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(16) NOT NULL COMMENT 'SUBMIT/APPROVE/REJECT/PUBLISH/OFFLINE',
reason VARCHAR(500) NOT NULL DEFAULT '',
operator_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
operator_name VARCHAR(64) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
KEY idx_biz (biz_type, biz_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 5.4 媒资域
```sql
CREATE TABLE media_asset (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
biz_type VARCHAR(16) NOT NULL COMMENT 'EPISODE/DRAMA_COVER/AVATAR',
file_name VARCHAR(255) NOT NULL DEFAULT '',
object_key VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'OSS 原始文件 key',
source_url VARCHAR(512) NOT NULL DEFAULT '',
size_bytes BIGINT NOT NULL DEFAULT 0,
md5 VARCHAR(64) NOT NULL DEFAULT '',
duration_sec INT NOT NULL DEFAULT 0,
width INT NOT NULL DEFAULT 0,
height INT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'uploading'
COMMENT 'uploading/uploaded/transcoding/ready/failed',
hls_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'm3u8 播放地址',
mp4_url VARCHAR(512) NOT NULL DEFAULT '',
poster_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '首帧封面',
create_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT NOT NULL DEFAULT 0,
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE transcode_task (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
asset_id BIGINT UNSIGNED NOT NULL,
spec VARCHAR(32) NOT NULL DEFAULT '720p' COMMENT '360p/540p/720p/1080p',
status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/running/success/failed',
progress INT NOT NULL DEFAULT 0,
error_msg VARCHAR(500) NOT NULL DEFAULT '',
started_at DATETIME(3) NULL,
finished_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
KEY idx_asset (asset_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 5.5 会员与互动域(C 端)
```sql
CREATE TABLE app_user (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
nickname VARCHAR(64) NOT NULL DEFAULT '',
avatar VARCHAR(512) NOT NULL DEFAULT '',
mobile VARCHAR(20) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
password VARCHAR(100) NOT NULL DEFAULT '' COMMENT '可空,优先验证码登录',
gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
birthday DATE NULL,
register_channel VARCHAR(32) NOT NULL DEFAULT 'app',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0封禁',
vip_expire_at DATETIME(3) NULL COMMENT '冗余,便于查询',
last_login_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT NOT NULL DEFAULT 0,
UNIQUE KEY uk_mobile (mobile)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE user_watch_history (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
drama_id BIGINT UNSIGNED NOT NULL,
episode_id BIGINT UNSIGNED NOT NULL,
position_sec INT NOT NULL DEFAULT 0,
duration_sec INT NOT NULL DEFAULT 0,
finished TINYINT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_user_episode (user_id, episode_id),
KEY idx_user_time (user_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE user_favorite (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
drama_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_user_drama (user_id, drama_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE user_like (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
drama_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_user_drama (user_id, drama_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE drama_comment (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
drama_id BIGINT UNSIGNED NOT NULL,
episode_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
user_id BIGINT UNSIGNED NOT NULL,
parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
content VARCHAR(500) NOT NULL,
like_count INT NOT NULL DEFAULT 0,
status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0隐藏',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
deleted TINYINT NOT NULL DEFAULT 0,
KEY idx_drama_time (drama_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 5.6 交易域(金币 / 会员 / 解锁)
```sql
CREATE TABLE user_coin_account (
user_id BIGINT UNSIGNED PRIMARY KEY,
balance BIGINT NOT NULL DEFAULT 0,
total_recharge BIGINT NOT NULL DEFAULT 0,
total_consume BIGINT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE coin_transaction (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
type VARCHAR(16) NOT NULL COMMENT 'RECHARGE/UNLOCK/REFUND/GIFT/ADJUST',
amount BIGINT NOT NULL COMMENT '正数入账,负数出账',
balance_after BIGINT NOT NULL,
biz_type VARCHAR(32) NOT NULL DEFAULT '',
biz_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
idempotent_key VARCHAR(128) NOT NULL DEFAULT '',
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_idempotent (idempotent_key),
KEY idx_user_time (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE episode_unlock (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
drama_id BIGINT UNSIGNED NOT NULL,
episode_id BIGINT UNSIGNED NOT NULL,
unlock_type VARCHAR(16) NOT NULL COMMENT 'COIN/VIP',
coin_cost BIGINT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_user_episode (user_id, episode_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE vip_plan (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(64) NOT NULL,
days INT NOT NULL,
price_cent INT NOT NULL,
original_price_cent INT NOT NULL DEFAULT 0,
status TINYINT NOT NULL DEFAULT 1,
sort INT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE app_order (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
biz_type VARCHAR(16) NOT NULL COMMENT 'RECHARGE/VIP',
product_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
subject VARCHAR(128) NOT NULL DEFAULT '',
amount_cent INT NOT NULL,
coin_amount BIGINT NOT NULL DEFAULT 0,
channel VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'alipay/wechat/apple/stripe',
channel_trade_no VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'CREATED' COMMENT 'CREATED/PAID/CLOSED/REFUNDED',
paid_at DATETIME(3) NULL,
expire_at DATETIME(3) NULL COMMENT '超时关单时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_order_no (order_no),
UNIQUE KEY uk_channel_trade (channel, channel_trade_no),
KEY idx_user_status (user_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE vip_membership (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
plan_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
start_at DATETIME(3) NOT NULL,
expire_at DATETIME(3) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
KEY idx_user (user_id, expire_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 5.7 统计域
```sql
CREATE TABLE stat_drama_daily (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
stat_date DATE NOT NULL,
drama_id BIGINT UNSIGNED NOT NULL,
play_count BIGINT NOT NULL DEFAULT 0,
uv BIGINT NOT NULL DEFAULT 0,
new_favorite BIGINT NOT NULL DEFAULT 0,
unlock_count BIGINT NOT NULL DEFAULT 0,
coin_income BIGINT NOT NULL DEFAULT 0,
vip_income_cent BIGINT NOT NULL DEFAULT 0,
UNIQUE KEY uk_date_drama (stat_date, drama_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE stat_episode_daily (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
stat_date DATE NOT NULL,
episode_id BIGINT UNSIGNED NOT NULL,
drama_id BIGINT UNSIGNED NOT NULL,
play_count BIGINT NOT NULL DEFAULT 0,
uv BIGINT NOT NULL DEFAULT 0,
avg_watch_sec INT NOT NULL DEFAULT 0,
UNIQUE KEY uk_date_episode (stat_date, episode_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
---
## 6. 接口设计
### 6.1 统一约定
- 基础路径:B 端 `/admin/**`,C 端 `/app/**`。
- 响应壳(与后台 axios 拦截器一致):`{ "code": 0, "message": "ok", "data": ... }`;失败 `code != 0`;未登录 `code = 401`。
- 分页:请求 `pageIndex`(从 1 开始)、`pageSize`;响应 `{ "total": n, "list": [...] }`。
- 鉴权:请求头 `Authorization: Bearer <token>`;B 端与 C 端 token 独立签发、独立校验。
- 时间:`yyyy-MM-dd HH:mm:ss` 或毫秒时间戳,全项目统一一种。
### 6.2 B 端接口
必须与现有 mock 结构兼容的两个接口:
| 方法 | 路径 | 说明 | 兼容要求 |
| --- | --- | --- | --- |
| POST | `/admin/auth/login` | 管理员登录 | 返回 `data` 至少含 `username / role / roleId / token`,字段名与 `mock/user/index.mock.ts` 一致;`permissions` 换为真实权限码数组 |
| GET | `/admin/auth/menus` | 当前用户菜单路由树 | 结构与 `mock/role/index.mock.ts` 的 `adminList` 完全一致(`path / component / name / meta.title / meta.icon`),`component` 仍是前端组件字符串 |
其余 B 端接口:
| 模块 | 接口 |
| --- | --- |
| 认证 | `POST /admin/auth/login`、`POST /admin/auth/logout`、`GET /admin/auth/profile`、`POST /admin/auth/password` |
| 管理员 | `GET /admin/admin-users/page`、`POST /admin/admin-users`、`PUT /admin/admin-users/{id}`、`DELETE /admin/admin-users/{id}`、`PUT /admin/admin-users/{id}/status`、`PUT /admin/admin-users/{id}/password` |
| 角色 | `GET /admin/roles/page`、`POST /admin/roles`、`PUT /admin/roles/{id}`、`DELETE /admin/roles/{id}`、`GET /admin/roles/{id}/permissions`、`PUT /admin/roles/{id}/permissions` |
| 角色分类 | `GET /admin/role-categories`、`POST /admin/role-categories`、`PUT /admin/role-categories/{id}`、`DELETE /admin/role-categories/{id}` |
| 权限点 | `GET /admin/permissions`(按 group 分组,直接喂权限配置页) |
| 菜单 | `GET /admin/menus/tree`、`POST /admin/menus`、`PUT /admin/menus/{id}`、`DELETE /admin/menus/{id}` |
| 短剧 | `GET /admin/dramas/page`、`GET /admin/dramas/{id}`、`POST /admin/dramas`、`PUT /admin/dramas/{id}`、`DELETE /admin/dramas/{id}`、`PUT /admin/dramas/{id}/status` |
| 剧集 | `GET /admin/dramas/{dramaId}/episodes`、`POST /admin/episodes`、`PUT /admin/episodes/{id}`、`DELETE /admin/episodes/{id}`、`PUT /admin/episodes/sort` |
| 审核 | `POST /admin/dramas/{id}/submit`、`/approve`、`/reject`、`/publish`、`/offline` |
| 媒资 | `POST /admin/media/upload-token`(分片直传凭证)、`POST /admin/media/upload`(小文件)、`GET /admin/media/{id}`(转码进度) |
| 字典 | `GET /admin/dicts/{type}`(题材、状态等) |
| 设置 | `GET /admin/configs`、`PUT /admin/configs` |
| 统计 | `GET /admin/stat/overview`、`GET /admin/stat/dramas`、`GET /admin/stat/trend` |
短剧列表响应示例(字段名对齐前端 `src/data/dramas.js`,前端只需换数据源):
```json
{
"id": 1,
"title": "替嫁新娘:总裁的掌心宠",
"genre": "都市情感",
"status": "published",
"statusText": "已上线",
"episodes": 68,
"views": 2400000,
"cover": "https://cdn.example.com/drama/1/cover.jpg",
"description": "一场意外替嫁,让她成为冷面总裁的契约新娘。",
"updatedAt": "2025-06-12 10:00:00"
}
```
### 6.3 C 端接口
| 模块 | 接口 | 说明 |
| --- | --- | --- |
| 认证 | `POST /app/auth/sms-code` | 发送验证码(Redis 限流防刷) |
| | `POST /app/auth/login` | 手机号+验证码 / 邮箱+密码,返回 `accessToken`、`refreshToken`、用户资料 |
| | `POST /app/auth/refresh` | 刷新 token |
| | `POST /app/auth/logout` | 注销当前设备 |
| 用户 | `GET /app/user/profile`、`PUT /app/user/profile`、`POST /app/user/avatar` | 对应客户端「我的」页 |
| 目录 | `GET /app/home/feed` | 首页分区流(推荐、热播、新上线) |
| | `GET /app/discover/sections` | 发现页分区/轮播 |
| | `GET /app/categories` | 题材分类 |
| | `GET /app/dramas/{id}` | 详情:简介、总集数、免费集数、收藏/点赞数、当前用户是否收藏/点赞 |
| | `GET /app/dramas/{id}/episodes` | 剧集列表(含 `isFree`、`locked`、`unlockCoin`) |
| 播放 | `POST /app/episodes/{id}/play` | 播放鉴权,返回签名播放地址与续播位置 |
| | `POST /app/history` | 上报观看进度(播放中每 10~15s 节流上报一次) |
| | `GET /app/history` | 观看历史列表 |
| 互动 | `GET/POST /app/dramas/{id}/favorites`、`GET/POST /app/dramas/{id}/likes`、`GET/POST /app/dramas/{id}/comments`、`POST /app/comments/{id}/like`、`DELETE /app/comments/{id}` | 对应 `savedDramaIds / likedDramaIds / commentsByDrama` |
| 钱包 | `GET /app/wallet` | 金币余额、会员状态、到期时间 |
| | `GET /app/wallet/plans` | 充值档位与 VIP 套餐 |
| | `POST /app/orders` | 创建充值/会员订单 |
| | `POST /app/pay/callback/{channel}` | 支付回调(公开接口,验签 + 幂等) |
| 解锁 | `POST /app/episodes/{id}/unlock` | 金币解锁单集;VIP 用户直接放行 |
| 其他 | `GET /app/configs` | 客户端启动配置(公告、分享文案、开关) |
播放接口响应示例:
```json
{
"episodeId": 101,
"dramaId": 1,
"epNo": 4,
"duration": 138,
"accessible": true,
"accessType": "FREE",
"playUrl": "https://cdn.example.com/hls/1/101/index.m3u8?auth_key=...&expires=1730000000",
"posterUrl": "https://cdn.example.com/hls/1/101/poster.jpg",
"resumePosition": 42
}
```
`accessible = false` 时返回 `accessType = COIN_REQUIRED | VIP_REQUIRED` 与 `unlockCoin`,客户端据此弹出解锁或开通会员面板。
---
## 7. 关键流程
### 7.1 内容生产(后台)
```
创建短剧草稿 → 上传封面 → 批量上传剧集视频(分片直传 OSS)
→ 发布转码任务(MQ 异步) → FFmpeg 产出 HLS/封面 → 回调更新 media_asset
→ 填写剧集信息(集号、时长、免费标记、解锁金币)
→ 提交审核(draft → reviewing) → 审核通过(reviewing → approved)
→ 定时或手动发布(approved → published,publish_at 到点由 job 自动上线)
→ 客户端目录可见
```
- 转码异步化,后台轮询 `GET /admin/media/{id}` 拿进度即可,不用长连接。
- 审核、发布、下线三个动作全部写 `drama_review_record` 与 `sys_operation_log`,可追溯。
### 7.2 播放链路(客户端)
```
客户端请求 POST /app/episodes/{id}/play
→ 校验登录态与用户状态
→ 判定可见性:剧 / 集是否 published
→ 判定播放权:is_free 命中,或 episode_unlock 命中,或 VIP 有效
→ 有权:生成带签名的 CDN 地址(有效期 10~30 分钟)→ 返回 playUrl + 续播位置
→ 无权:返回 COIN_REQUIRED / VIP_REQUIRED,客户端弹窗引导
```
防盗链三条措施:签名地址短时效、CDN Referer/UA 白名单、HLS 分片加密(AES-128,密钥接口同样鉴权)。
### 7.3 解锁扣费(幂等)
```
POST /app/episodes/{id}/unlock
→ 幂等键 user_id + episode_id:先查 episode_unlock,命中直接返回成功
→ Redis 分布式锁 lock:unlock:{userId}:{episodeId},挡住连点重复扣费
→ 一个事务内:扣 user_coin_account(乐观锁 version 校验)
→ 写 coin_transaction → 写 episode_unlock
→ 返回最新余额
```
余额不足返回明确业务码,客户端直接跳充值页。
### 7.4 支付回调
```
POST /app/pay/callback/{channel}
→ 验签 → 幂等(channel + channel_trade_no 唯一索引)
→ 一个事务内:订单置为 PAID → 金币入账(写流水)或开通/续期 VIP
→ 更新 app_user.vip_expire_at
→ 返回渠道要求的成功应答
```
配套对账任务:定时拉取渠道账单与本地订单比对,处理「渠道成功、本地漏回调」。
---
## 8. 非功能设计
### 8.1 鉴权与权限
- B 端:JWT 载荷含 `userId / username / roleId`,过滤器解析后写入 `SecurityContext`;权限校验用注解 `@PreAuthorize("@ss.hasPermission('drama:create')")`,权限码风格与前端 `src/permissions/permissions.js` 对齐。
- C 端:`accessToken` 短时效(2h)+ `refreshToken` 长时效(30d),存 Redis 支持主动踢下线;键设计 `user:token:{userId}:{deviceId}`。
- 后台高危操作(删除、发布、改权限)二次校验密码并写 `sys_operation_log`;`two_factor` 字段已在表结构预留。
### 8.2 缓存策略
| 数据 | Key | 策略 |
| --- | --- | --- |
| 首页/发现页分区 | `cache:home:feed` | 5 分钟过期,发布/上线/下线时主动失效 |
| 短剧详情 | `cache:drama:{id}` | 10 分钟 + 主动失效 |
| 分类字典 | `cache:dict:category` | 1 小时 |
| 后台权限码 | `cache:perm:role:{roleId}` | 30 分钟,改角色权限时失效 |
| 验证码 | `sms:code:{mobile}` | 5 分钟,发送限流 1 次/分、5 次/天 |
| 播放签名 | —— | 不缓存,实时生成 |
热点 Key 加随机后缀防雪崩;空结果缓存短空值防穿透。
### 8.3 限流与防刷
- 接口级限流(Redis 令牌桶):登录、验证码、评论、解锁扣费。
- 播放量与观看时长上报去重(`user_id + episode_id + 时间窗`),防止刷量污染统计。
- 评论接入敏感词过滤,可扩展到第三方内容安全审核。
- 客户端接口做版本兼容(`X-App-Version`),避免旧版本升级后崩溃。
### 8.4 数据量与扩展
- 增长最快的表:`user_watch_history`、`drama_comment`、`coin_transaction`、`stat_*_daily`。
- 单表超 2000 万行或写入明显变慢时,按 `user_id` 取模分表(ShardingSphere),对业务代码透明。
- `stat_*_daily` 属聚合表,量级可控,用分区表即可。
- 图片与视频全部走 CDN,应用服务器不落盘媒体文件。
### 8.5 可观测与运维
- 日志:JSON 结构化,traceId 贯穿全链路;后台敏感操作另写 `sys_operation_log`。
- 监控:接口 P99、错误率、转码队列积压、支付回调失败率、Redis 命中率。
- 健康检查 `/actuator/health` 暴露给负载均衡。
- 环境:dev / test / prod 三套配置,密钥通过环境变量注入,不写进仓库。
---
## 9. 部署与演进
起步形态(单体):
```
Nginx
├── reelshort-admin-api (/admin)
├── reelshort-app-api (/app)
└── reelshort-job (定时任务、异步消费)
PostgreSQL(主从) / Redis / OSS(或 MinIO) / 转码 Worker(独立容器,可多实例水平扩容)
```
Docker Compose 足够跑通全链路,上线后按量升级到 K8s。
演进路径:单体多模块 → 转码与统计独立进程 → 读写分离 → 内容域/交易域拆为独立服务 → 网关统一入口。拆分只在某模块成为独立瓶颈或有独立迭代节奏时进行,不为拆而拆。
---
## 10. 与现有前端的对接改造点
### 10.1 管理后台(ReelShortWeb)
1. `.env.*`:`VITE_USE_MOCK=false`,`VITE_API_BASE_PATH` 指向后端地址。
2. 登录:`src/api/login/index.ts` 的 `/mock/user/login` → `/admin/auth/login`,返回体保持 `{ username, role, roleId, token, permissions }`,前端改动最小。
3. 菜单:`/mock/role/list` → `/admin/auth/menus`,返回同样的路由树结构,`src/router/index.ts` 无需改动。
4. 业务数据源替换:
- `src/data/dramas.js` → `GET /admin/dramas/page`
- `src/data/episodes.js` → `GET /admin/dramas/{dramaId}/episodes`
- `src/data/roles.js` → `GET /admin/roles/page`
- `src/views/userManagement/data/adminUsers.js` → `GET /admin/admin-users/page`
- `src/data/customMenus.js`(localStorage)→ `GET/POST /admin/menus`
- `src/stores/rbac.js` 的本地权限表 → 登录返回的 `permissions` + `GET /admin/permissions`
5. 展示文案(已上线/审核中/草稿)改由 `status` 码映射,`src/utils/status.js` 可逐步改用后端返回的 `statusText`。
### 10.2 客户端(ReelShort / Flutter)
1. `DemoCatalogRepository` → HTTP 实现,对接 `/app/home/feed`、`/app/dramas/{id}`、`/app/dramas/{id}/episodes`。
2. `SharedPreferencesEngagementRepository`(收藏/点赞/评论本地存储)→ HTTP 实现,`EngagementSnapshot` 变为服务端快照。
3. 观看历史本地存储 → `/app/history`;登录后做一次本地数据合并上传。
4. `Episode.isFree` 与 `PlaybackSession.canPlay` → 由 `/play` 返回的 `accessible / accessType` 驱动。
5. 「我的」页金币与会员(当前是 `$ 0` 假数据)→ `/app/wallet`。
6. 新增登录页,token 用 `flutter_secure_storage` 保存,`refreshToken` 自动续期。
---
## 11. 迭代建议
**M1 · 打通后台**
系统域(登录、RBAC、菜单、管理员)+ 内容域(短剧/剧集 CRUD)+ 媒资上传(先整文件上传,转码后置)+ 统一响应与鉴权框架。目标:后台 mock 全部替换为真实接口。
**M2 · 打通客户端**
C 端登录、目录/详情/剧集、观看历史、收藏点赞评论、播放鉴权与签名地址。目标:客户端真实播放,前端演示仓库全部退役。
**M3 · 变现与质量**
转码流水线(HLS 多码率)、金币与会员、单集解锁、支付与对账、后台数据看板、限流防刷、监控告警。
**M4 · 规模化**
读写分离、热点缓存优化、大表分表、内容安全、多语言内容(`drama.language` 已预留)。
---
## 附录 A · 错误码规划
| 码 | 含义 |
| --- | --- |
| 0 | 成功 |
| 400 | 参数校验失败 |
| 401 | 未登录 / token 失效 |
| 403 | 无权限 |
| 404 | 资源不存在 |
| 429 | 请求过于频繁 |
| 500 | 系统异常 |
| 100001 | 金币余额不足 |
| 100002 | 剧集未发布或不存在 |
| 100003 | 剧集已解锁(幂等返回成功) |
| 100004 | 订单已支付或已关闭 |
| 100005 | VIP 已过期 |
## 附录 B · 配置骨架(application.yml)
```yaml
server:
port: 8080
spring:
datasource:
url: jdbc:postgresql://127.0.0.1:5432/reelshort
username: ${DB_USER:reelshort}
password: ${DB_PASSWORD:reelshort}
data:
redis:
host: 127.0.0.1
port: 6379
servlet:
multipart:
max-file-size: 2GB
max-request-size: 2GB
reelshort:
jwt:
admin-secret: ${JWT_ADMIN_SECRET}
app-secret: ${JWT_APP_SECRET}
app-access-ttl: 2h
app-refresh-ttl: 30d
oss:
endpoint: ${OSS_ENDPOINT}
bucket: ${OSS_BUCKET}
access-key: ${OSS_KEY}
secret-key: ${OSS_SECRET}
cdn:
play-domain: https://cdn.example.com
sign-ttl-seconds: 1800
```
+143
View File
@@ -0,0 +1,143 @@
<?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>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.reelshort</groupId>
<artifactId>reelshort</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<name>reelshort</name>
<description>ReelShort 短剧服务</description>
<modules>
<module>reelshort-common</module>
<module>reelshort-framework</module>
<module>reelshort-module-system</module>
<module>reelshort-module-content</module>
<module>reelshort-module-media</module>
<module>reelshort-admin-api</module>
<module>reelshort-app-api</module>
</modules>
<properties>
<java.version>17</java.version>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<mybatis-plus.version>3.5.7</mybatis-plus.version>
<jjwt.version>0.12.5</jjwt.version>
<springdoc.version>2.5.0</springdoc.version>
<!-- Spring Boot 3.2 默认的 1.18.32 在 JDK 23+ 上会编译失败,这里提升到支持新 JDK 的版本 -->
<lombok.version>1.18.48</lombok.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.reelshort</groupId>
<artifactId>reelshort-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.reelshort</groupId>
<artifactId>reelshort-framework</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.reelshort</groupId>
<artifactId>reelshort-module-system</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.reelshort</groupId>
<artifactId>reelshort-module-content</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.reelshort</groupId>
<artifactId>reelshort-module-media</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<pluginManagement>
<plugins>
<!-- 显式声明注解处理器:JDK 23 起不再默认扫描 classpath 上的处理器,否则 Lombok 会失效 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<proc>full</proc>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.reelshort</groupId>
<artifactId>reelshort</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>reelshort-admin-api</artifactId>
<name>reelshort-admin-api</name>
<description>管理后台接入层:/admin/**</description>
<dependencies>
<dependency>
<groupId>com.reelshort</groupId>
<artifactId>reelshort-module-system</artifactId>
</dependency>
<dependency>
<groupId>com.reelshort</groupId>
<artifactId>reelshort-module-content</artifactId>
</dependency>
<dependency>
<groupId>com.reelshort</groupId>
<artifactId>reelshort-module-media</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,14 @@
package com.reelshort;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("com.reelshort.**.dal.mapper")
public class ReelShortApplication {
public static void main(String[] args) {
SpringApplication.run(ReelShortApplication.class, args);
}
}
@@ -0,0 +1,83 @@
package com.reelshort.admin.controller;
import com.reelshort.common.pojo.PageResult;
import com.reelshort.common.pojo.Result;
import com.reelshort.framework.security.SecurityUtils;
import com.reelshort.system.service.AdminUserService;
import com.reelshort.framework.log.OperationLog;
import com.reelshort.system.vo.AdminUserPageReqVO;
import com.reelshort.system.vo.AdminUserRespVO;
import com.reelshort.system.vo.AdminUserSaveReqVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/admin/admin-users")
@RequiredArgsConstructor
public class AdminUserController {
private final AdminUserService adminUserService;
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
public Result<PageResult<AdminUserRespVO>> page(AdminUserPageReqVO req) {
return Result.ok(adminUserService.page(req));
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
public Result<AdminUserRespVO> get(@PathVariable Long id) {
return Result.ok(adminUserService.get(id));
}
@PostMapping
@PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "新增管理员")
public Result<Long> create(@Valid @RequestBody AdminUserSaveReqVO req) {
return Result.ok(adminUserService.create(req));
}
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "编辑管理员")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody AdminUserSaveReqVO req) {
adminUserService.update(id, req);
return Result.ok();
}
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "删除管理员")
public Result<Void> delete(@PathVariable Long id) {
adminUserService.delete(id);
return Result.ok();
}
@PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "修改管理员状态")
public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
adminUserService.updateStatus(id, status);
return Result.ok();
}
@PutMapping("/{id}/password")
@PreAuthorize("@ss.hasPermission('system:admin-user')")
@OperationLog(module = "后台管理", action = "重置管理员密码")
public Result<Void> resetPassword(@PathVariable Long id, @RequestBody Map<String, String> body) {
adminUserService.resetPassword(id, body.get("password"));
return Result.ok();
}
}
@@ -0,0 +1,61 @@
package com.reelshort.admin.controller;
import com.reelshort.common.pojo.Result;
import com.reelshort.framework.security.SecurityUtils;
import com.reelshort.framework.log.OperationLog;
import com.reelshort.framework.web.WebUtils;
import com.reelshort.system.service.AuthService;
import com.reelshort.system.vo.LoginReqVO;
import com.reelshort.system.vo.LoginRespVO;
import com.reelshort.system.vo.MenuVO;
import com.reelshort.system.vo.PasswordChangeReqVO;
import com.reelshort.system.vo.ProfileRespVO;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/admin/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
@OperationLog(module = "认证", action = "登录")
public Result<LoginRespVO> login(@Valid @RequestBody LoginReqVO req, HttpServletRequest request) {
return Result.ok(authService.login(req, WebUtils.getClientIp(request)));
}
/** 当前用户的菜单路由树,结构与前端 element-plus-admin 的动态路由一致。 */
@GetMapping("/menus")
public Result<List<MenuVO>> menus() {
return Result.ok(authService.listMenus(SecurityUtils.getUserId()));
}
@GetMapping("/profile")
public Result<ProfileRespVO> profile() {
return Result.ok(authService.profile(SecurityUtils.getUserId()));
}
/** JWT 无状态,登出由前端清除 token 即可。 */
@PostMapping("/logout")
@OperationLog(module = "认证", action = "退出登录")
public Result<Void> logout() {
return Result.ok();
}
@PostMapping("/password")
@OperationLog(module = "认证", action = "修改密码")
public Result<Void> changePassword(@Valid @RequestBody PasswordChangeReqVO req) {
authService.changePassword(SecurityUtils.getUserId(), req.getOldPassword(), req.getNewPassword());
return Result.ok();
}
}
@@ -0,0 +1,86 @@
package com.reelshort.admin.controller;
import com.reelshort.common.pojo.PageResult;
import com.reelshort.common.pojo.Result;
import com.reelshort.content.service.BannerService;
import com.reelshort.content.vo.BannerRespVO;
import com.reelshort.content.vo.BannerSaveReqVO;
import com.reelshort.framework.security.SecurityUtils;
import com.reelshort.framework.log.OperationLog;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/admin/banners")
@RequiredArgsConstructor
public class BannerController {
private final BannerService bannerService;
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('banner:list')")
public Result<PageResult<BannerRespVO>> page(@RequestParam(required = false) String position,
@RequestParam(required = false) Integer status,
@RequestParam(defaultValue = "1") Integer pageIndex,
@RequestParam(defaultValue = "10") Integer pageSize) {
return Result.ok(bannerService.page(position, status, pageIndex, pageSize));
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('banner:list')")
public Result<BannerRespVO> get(@PathVariable Long id) {
return Result.ok(bannerService.get(id));
}
@PostMapping
@PreAuthorize("@ss.hasPermission('banner:create')")
@OperationLog(module = "内容管理", action = "新增轮播图")
public Result<Long> create(@Valid @RequestBody BannerSaveReqVO req) {
return Result.ok(bannerService.create(req, SecurityUtils.getUserId()));
}
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('banner:update')")
@OperationLog(module = "内容管理", action = "编辑轮播图")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody BannerSaveReqVO req) {
bannerService.update(id, req);
return Result.ok();
}
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('banner:delete')")
@OperationLog(module = "内容管理", action = "删除轮播图")
public Result<Void> delete(@PathVariable Long id) {
bannerService.delete(id);
return Result.ok();
}
@PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('banner:update')")
@OperationLog(module = "内容管理", action = "修改轮播图状态")
public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
bannerService.updateStatus(id, status);
return Result.ok();
}
@PutMapping("/sort")
@PreAuthorize("@ss.hasPermission('banner:update')")
public Result<Void> sort(@RequestBody Map<String, List<Long>> body) {
bannerService.updateSort(body.getOrDefault("ids", List.of()));
return Result.ok();
}
}
@@ -0,0 +1,38 @@
package com.reelshort.admin.controller;
import com.reelshort.common.pojo.Result;
import com.reelshort.system.service.ConfigService;
import com.reelshort.framework.log.OperationLog;
import com.reelshort.system.vo.ConfigItemVO;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/admin/configs")
@RequiredArgsConstructor
public class ConfigController {
private final ConfigService configService;
@GetMapping
@PreAuthorize("@ss.hasAnyPermission('system:role','system:menu')")
public Result<List<ConfigItemVO>> list() {
return Result.ok(configService.list());
}
@PutMapping
@PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "修改系统设置")
public Result<Void> update(@RequestBody Map<String, String> values) {
configService.update(values);
return Result.ok();
}
}
@@ -0,0 +1,40 @@
package com.reelshort.admin.controller;
import com.reelshort.common.pojo.Result;
import com.reelshort.content.enums.ContentStatus;
import com.reelshort.content.service.DramaCategoryService;
import com.reelshort.content.vo.DramaCategoryRespVO;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
/**
* 字典接口:题材、内容状态等下拉数据。
*/
@RestController
@RequestMapping("/admin/dicts")
@RequiredArgsConstructor
public class DictController {
private final DramaCategoryService dramaCategoryService;
@GetMapping("/drama-category")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<DramaCategoryRespVO>> dramaCategories() {
return Result.ok(dramaCategoryService.list());
}
@GetMapping("/content-status")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<Map<String, String>>> contentStatus() {
return Result.ok(Arrays.stream(ContentStatus.values())
.map(status -> Map.of("code", status.getCode(), "text", status.getText()))
.toList());
}
}
@@ -0,0 +1,61 @@
package com.reelshort.admin.controller;
import com.reelshort.common.pojo.Result;
import com.reelshort.content.service.DramaCategoryService;
import com.reelshort.content.vo.DramaCategoryRespVO;
import com.reelshort.content.vo.DramaCategorySaveReqVO;
import com.reelshort.framework.security.SecurityUtils;
import com.reelshort.framework.log.OperationLog;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 短剧类型管理。类型属于短剧域,权限沿用 drama:*,不再单开权限点。
*/
@RestController
@RequestMapping("/admin/drama-categories")
@RequiredArgsConstructor
public class DramaCategoryController {
private final DramaCategoryService dramaCategoryService;
@GetMapping
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<DramaCategoryRespVO>> list() {
return Result.ok(dramaCategoryService.list());
}
@PostMapping
@PreAuthorize("@ss.hasPermission('drama:create')")
@OperationLog(module = "内容管理", action = "新增短剧类型")
public Result<Long> create(@Valid @RequestBody DramaCategorySaveReqVO req) {
return Result.ok(dramaCategoryService.create(req));
}
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "编辑短剧类型")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody DramaCategorySaveReqVO req) {
dramaCategoryService.update(id, req);
return Result.ok();
}
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:delete')")
@OperationLog(module = "内容管理", action = "删除短剧类型")
public Result<Void> delete(@PathVariable Long id) {
dramaCategoryService.delete(id);
return Result.ok();
}
}
@@ -0,0 +1,120 @@
package com.reelshort.admin.controller;
import com.reelshort.common.pojo.PageResult;
import com.reelshort.common.pojo.Result;
import com.reelshort.content.dal.entity.DramaReviewRecord;
import com.reelshort.content.service.DramaService;
import com.reelshort.content.vo.DramaPageReqVO;
import com.reelshort.content.vo.DramaRespVO;
import com.reelshort.content.vo.DramaSaveReqVO;
import com.reelshort.content.vo.DramaStatusReqVO;
import com.reelshort.framework.security.SecurityUtils;
import com.reelshort.framework.log.OperationLog;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/admin/dramas")
@RequiredArgsConstructor
public class DramaController {
private final DramaService dramaService;
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<PageResult<DramaRespVO>> page(DramaPageReqVO req) {
return Result.ok(dramaService.page(req));
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<DramaRespVO> get(@PathVariable Long id) {
return Result.ok(dramaService.get(id));
}
@PostMapping
@PreAuthorize("@ss.hasPermission('drama:create')")
@OperationLog(module = "内容管理", action = "新建短剧")
public Result<Long> create(@Valid @RequestBody DramaSaveReqVO req) {
return Result.ok(dramaService.create(req, SecurityUtils.getUserId()));
}
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "编辑短剧")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody DramaSaveReqVO req) {
dramaService.update(id, req);
return Result.ok();
}
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:delete')")
@OperationLog(module = "内容管理", action = "删除短剧")
public Result<Void> delete(@PathVariable Long id) {
dramaService.delete(id);
return Result.ok();
}
/** 提交审核:内容运营即可操作。 */
@PostMapping("/{id}/submit")
@PreAuthorize("@ss.hasAnyPermission('drama:review','drama:update')")
@OperationLog(module = "内容管理", action = "提交审核")
public Result<Void> submit(@PathVariable Long id, @RequestBody(required = false) DramaStatusReqVO req) {
changeStatus(id, "SUBMIT", req);
return Result.ok();
}
@PostMapping("/{id}/approve")
@PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "审核通过")
public Result<Void> approve(@PathVariable Long id, @RequestBody(required = false) DramaStatusReqVO req) {
changeStatus(id, "APPROVE", req);
return Result.ok();
}
@PostMapping("/{id}/reject")
@PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "驳回短剧")
public Result<Void> reject(@PathVariable Long id, @RequestBody(required = false) DramaStatusReqVO req) {
changeStatus(id, "REJECT", req);
return Result.ok();
}
@PostMapping("/{id}/publish")
@PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "短剧上线")
public Result<Void> publish(@PathVariable Long id, @RequestBody(required = false) DramaStatusReqVO req) {
changeStatus(id, "PUBLISH", req);
return Result.ok();
}
@PostMapping("/{id}/offline")
@PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "短剧下线")
public Result<Void> offline(@PathVariable Long id, @RequestBody(required = false) DramaStatusReqVO req) {
changeStatus(id, "OFFLINE", req);
return Result.ok();
}
@GetMapping("/{id}/review-records")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<DramaReviewRecord>> reviewRecords(@PathVariable Long id) {
return Result.ok(dramaService.listReviewRecords(id));
}
private void changeStatus(Long id, String action, DramaStatusReqVO req) {
dramaService.changeStatus(id, action, req == null ? null : req.getReason(),
SecurityUtils.getUserId(), SecurityUtils.getUsername());
}
}
@@ -0,0 +1,68 @@
package com.reelshort.admin.controller;
import com.reelshort.common.pojo.Result;
import com.reelshort.content.service.EpisodeService;
import com.reelshort.content.vo.EpisodeRespVO;
import com.reelshort.content.vo.EpisodeSaveReqVO;
import com.reelshort.content.vo.EpisodeSortReqVO;
import com.reelshort.framework.log.OperationLog;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/admin/episodes")
@RequiredArgsConstructor
public class EpisodeController {
private final EpisodeService episodeService;
@GetMapping
@PreAuthorize("@ss.hasPermission('episode:list')")
public Result<List<EpisodeRespVO>> list(@RequestParam Long dramaId,
@RequestParam(required = false) String status) {
return Result.ok(episodeService.listByDrama(dramaId, status));
}
@PostMapping
@PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "内容管理", action = "新增剧集")
public Result<Long> create(@Valid @RequestBody EpisodeSaveReqVO req) {
return Result.ok(episodeService.create(req));
}
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "内容管理", action = "编辑剧集")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody EpisodeSaveReqVO req) {
episodeService.update(id, req);
return Result.ok();
}
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "内容管理", action = "删除剧集")
public Result<Void> delete(@PathVariable Long id) {
episodeService.delete(id);
return Result.ok();
}
@PutMapping("/sort")
@PreAuthorize("@ss.hasPermission('episode:sort')")
@OperationLog(module = "内容管理", action = "调整剧集顺序")
public Result<Void> sort(@Valid @RequestBody EpisodeSortReqVO req) {
episodeService.updateSort(req.getIds());
return Result.ok();
}
}
@@ -0,0 +1,58 @@
package com.reelshort.admin.controller;
import com.reelshort.common.pojo.Result;
import com.reelshort.framework.security.SecurityUtils;
import com.reelshort.media.service.MediaService;
import com.reelshort.media.vo.MediaAssetRespVO;
import com.reelshort.media.vo.UploadCompleteReqVO;
import com.reelshort.media.vo.UploadInitReqVO;
import com.reelshort.media.vo.UploadInitRespVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* 媒资上传(本地存储版):init 建会话 → chunk 传分片 → complete 合并出地址。
*/
@RestController
@RequestMapping("/admin/media")
@RequiredArgsConstructor
public class MediaController {
private final MediaService mediaService;
@PostMapping("/upload/init")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<UploadInitRespVO> init(@Valid @RequestBody UploadInitReqVO req) {
return Result.ok(mediaService.init(req, SecurityUtils.getUserId()));
}
@PostMapping("/upload/chunk")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<Integer> uploadChunk(@RequestParam String uploadId,
@RequestParam int index,
@RequestParam("file") MultipartFile file) {
mediaService.saveChunk(uploadId, index, file);
return Result.ok(index);
}
@PostMapping("/upload/complete")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<MediaAssetRespVO> complete(@Valid @RequestBody UploadCompleteReqVO req) {
return Result.ok(mediaService.complete(req));
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<MediaAssetRespVO> get(@PathVariable Long id) {
return Result.ok(mediaService.get(id));
}
}
@@ -0,0 +1,57 @@
package com.reelshort.admin.controller;
import com.reelshort.common.pojo.Result;
import com.reelshort.system.service.MenuService;
import com.reelshort.framework.log.OperationLog;
import com.reelshort.system.vo.MenuSaveReqVO;
import com.reelshort.system.vo.MenuVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/admin/menus")
@RequiredArgsConstructor
public class MenuController {
private final MenuService menuService;
@GetMapping("/tree")
@PreAuthorize("@ss.hasPermission('system:menu')")
public Result<List<MenuVO>> tree() {
return Result.ok(menuService.listAll());
}
@PostMapping
@PreAuthorize("@ss.hasPermission('system:menu')")
@OperationLog(module = "后台管理", action = "新增菜单")
public Result<Long> create(@Valid @RequestBody MenuSaveReqVO req) {
return Result.ok(menuService.create(req));
}
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:menu')")
@OperationLog(module = "后台管理", action = "编辑菜单")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody MenuSaveReqVO req) {
menuService.update(id, req);
return Result.ok();
}
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:menu')")
@OperationLog(module = "后台管理", action = "删除菜单")
public Result<Void> delete(@PathVariable Long id) {
menuService.delete(id);
return Result.ok();
}
}
@@ -0,0 +1,33 @@
package com.reelshort.admin.controller;
import com.reelshort.common.pojo.PageResult;
import com.reelshort.common.pojo.Result;
import com.reelshort.system.service.OperationLogService;
import com.reelshort.system.vo.OperationLogPageReqVO;
import com.reelshort.system.vo.OperationLogRespVO;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/admin/operation-logs")
@RequiredArgsConstructor
public class OperationLogController {
private final OperationLogService operationLogService;
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('log:list')")
public Result<PageResult<OperationLogRespVO>> page(OperationLogPageReqVO req) {
return Result.ok(operationLogService.page(req));
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('log:list')")
public Result<OperationLogRespVO> get(@PathVariable Long id) {
return Result.ok(operationLogService.get(id));
}
}
@@ -0,0 +1,27 @@
package com.reelshort.admin.controller;
import com.reelshort.common.pojo.Result;
import com.reelshort.system.service.PermissionService;
import com.reelshort.system.vo.PermissionGroupVO;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/admin/permissions")
@RequiredArgsConstructor
public class PermissionController {
private final PermissionService permissionService;
/** 按分组返回权限点,直接喂给前端的角色权限配置页。 */
@GetMapping
@PreAuthorize("@ss.hasPermission('system:role')")
public Result<List<PermissionGroupVO>> groups() {
return Result.ok(permissionService.listGroups());
}
}
@@ -0,0 +1,57 @@
package com.reelshort.admin.controller;
import com.reelshort.common.pojo.Result;
import com.reelshort.system.service.RoleCategoryService;
import com.reelshort.framework.log.OperationLog;
import com.reelshort.system.vo.RoleCategoryRespVO;
import com.reelshort.system.vo.RoleCategorySaveReqVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/admin/role-categories")
@RequiredArgsConstructor
public class RoleCategoryController {
private final RoleCategoryService roleCategoryService;
@GetMapping
@PreAuthorize("@ss.hasPermission('system:role-category')")
public Result<List<RoleCategoryRespVO>> list() {
return Result.ok(roleCategoryService.list());
}
@PostMapping
@PreAuthorize("@ss.hasPermission('system:role-category')")
@OperationLog(module = "后台管理", action = "新增角色分类")
public Result<Long> create(@Valid @RequestBody RoleCategorySaveReqVO req) {
return Result.ok(roleCategoryService.create(req));
}
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:role-category')")
@OperationLog(module = "后台管理", action = "编辑角色分类")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody RoleCategorySaveReqVO req) {
roleCategoryService.update(id, req);
return Result.ok();
}
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:role-category')")
@OperationLog(module = "后台管理", action = "删除角色分类")
public Result<Void> delete(@PathVariable Long id) {
roleCategoryService.delete(id);
return Result.ok();
}
}
@@ -0,0 +1,88 @@
package com.reelshort.admin.controller;
import com.reelshort.common.pojo.PageResult;
import com.reelshort.common.pojo.Result;
import com.reelshort.system.service.RoleService;
import com.reelshort.framework.log.OperationLog;
import com.reelshort.system.vo.RolePermissionSaveReqVO;
import com.reelshort.system.vo.RoleRespVO;
import com.reelshort.system.vo.RoleSaveReqVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/admin/roles")
@RequiredArgsConstructor
public class RoleController {
private final RoleService roleService;
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('system:role')")
public Result<PageResult<RoleRespVO>> page(@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") Integer pageIndex,
@RequestParam(defaultValue = "10") Integer pageSize) {
return Result.ok(roleService.page(keyword, pageIndex, pageSize));
}
@GetMapping("/list")
public Result<List<RoleRespVO>> list() {
return Result.ok(roleService.listAll());
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:role')")
public Result<RoleRespVO> get(@PathVariable Long id) {
return Result.ok(roleService.get(id));
}
@PostMapping
@PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "新增角色")
public Result<Long> create(@Valid @RequestBody RoleSaveReqVO req) {
return Result.ok(roleService.create(req));
}
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "编辑角色")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody RoleSaveReqVO req) {
roleService.update(id, req);
return Result.ok();
}
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "删除角色")
public Result<Void> delete(@PathVariable Long id) {
roleService.delete(id);
return Result.ok();
}
@GetMapping("/{id}/permissions")
@PreAuthorize("@ss.hasPermission('system:role')")
public Result<List<String>> permissions(@PathVariable Long id) {
return Result.ok(roleService.listCodesByRoleId(id));
}
@PutMapping("/{id}/permissions")
@PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "修改角色权限")
public Result<Void> updatePermissions(@PathVariable Long id,
@Valid @RequestBody RolePermissionSaveReqVO req) {
roleService.updatePermissions(id, req.getPermissions());
return Result.ok();
}
}
@@ -0,0 +1,53 @@
server:
port: 8080
spring:
application:
name: reelshort-admin-api
autoconfigure:
exclude: org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://127.0.0.1:5432/reelshort
username: ${DB_USER:reelshort}
password: ${DB_PASSWORD:reelshort}
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
servlet:
multipart:
# 分片上传,单个请求只需要容纳一个分片
max-file-size: 20MB
max-request-size: 20MB
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
global-config:
banner: false
db-config:
id-type: auto
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
reelshort:
media:
# 本地媒资目录(相对应用启动目录),通过 /files/** 访问
root-dir: ./data/uploads
public-prefix: /files
chunk-size: 5242880
max-file-size: 2147483648
jwt:
admin-secret: ${JWT_ADMIN_SECRET:reelshort-admin-dev-secret-change-me-in-production-32b}
admin-ttl-hours: 12
cors:
allowed-origins: http://localhost:3005,http://127.0.0.1:3005
springdoc:
swagger-ui:
path: /doc.html
logging:
level:
com.reelshort: debug
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.reelshort</groupId>
<artifactId>reelshort</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>reelshort-app-api</artifactId>
<name>reelshort-app-api</name>
<description>客户端接入层:/app/**</description>
<dependencies>
<dependency>
<groupId>com.reelshort</groupId>
<artifactId>reelshort-module-content</artifactId>
</dependency>
<dependency>
<groupId>com.reelshort</groupId>
<artifactId>reelshort-module-media</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,19 @@
package com.reelshort.app;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 客户端接口服务,只暴露 /app/** 与静态媒资 /files/**。
* 与 reelshort-admin-api 共用同一套领域模块和数据库,但入口、鉴权和限流策略独立。
*/
/* 显式指定扫描根包:本类在 com.reelshort.app 下,默认扫不到 content/system/framework 模块的 Bean */
@SpringBootApplication(scanBasePackages = "com.reelshort")
@MapperScan("com.reelshort.**.dal.mapper")
public class AppApiApplication {
public static void main(String[] args) {
SpringApplication.run(AppApiApplication.class, args);
}
}
@@ -0,0 +1,38 @@
package com.reelshort.app.controller;
import com.reelshort.app.service.AppCatalogService;
import com.reelshort.app.vo.AppCategoryVO;
import com.reelshort.app.vo.AppFeedVO;
import com.reelshort.common.pojo.Result;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 客户端目录接口:首页与发现页各一次请求拿全量聚合数据。
*/
@RestController
@RequestMapping("/app")
@RequiredArgsConstructor
public class AppCatalogController {
private final AppCatalogService appCatalogService;
@GetMapping("/home/feed")
public Result<AppFeedVO> homeFeed() {
return Result.ok(appCatalogService.homeFeed());
}
@GetMapping("/discover/feed")
public Result<AppFeedVO> discoverFeed() {
return Result.ok(appCatalogService.discoverFeed());
}
@GetMapping("/categories")
public Result<List<AppCategoryVO>> categories() {
return Result.ok(appCatalogService.categories());
}
}
@@ -0,0 +1,31 @@
package com.reelshort.app.controller;
import com.reelshort.app.service.AppCatalogService;
import com.reelshort.app.vo.AppDramaDetailVO;
import com.reelshort.app.vo.AppPlayTicketVO;
import com.reelshort.common.pojo.Result;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/app")
@RequiredArgsConstructor
public class AppDramaController {
private final AppCatalogService appCatalogService;
/** 详情:包含剧集列表,客户端进详情页一次请求即可渲染 */
@GetMapping("/dramas/{id}")
public Result<AppDramaDetailVO> detail(@PathVariable Long id) {
return Result.ok(appCatalogService.dramaDetail(id));
}
/** 播放票据:返回可访问性与播放地址,未解锁时只返回需要多少金币 */
@GetMapping("/episodes/{id}/play")
public Result<AppPlayTicketVO> play(@PathVariable Long id) {
return Result.ok(appCatalogService.playTicket(id));
}
}
@@ -0,0 +1,211 @@
package com.reelshort.app.service;
import com.reelshort.app.vo.AppBannerVO;
import com.reelshort.app.vo.AppCategoryVO;
import com.reelshort.app.vo.AppDramaCardVO;
import com.reelshort.app.vo.AppDramaDetailVO;
import com.reelshort.app.vo.AppEpisodeVO;
import com.reelshort.app.vo.AppFeedVO;
import com.reelshort.app.vo.AppPlayTicketVO;
import com.reelshort.app.vo.AppSectionVO;
import com.reelshort.content.dal.entity.ContentBanner;
import com.reelshort.content.dal.entity.Drama;
import com.reelshort.content.dal.entity.DramaEpisode;
import com.reelshort.content.enums.BannerPosition;
import com.reelshort.content.service.BannerService;
import com.reelshort.content.service.DramaCategoryService;
import com.reelshort.content.service.DramaService;
import com.reelshort.content.service.EpisodeService;
import com.reelshort.content.vo.DramaCategoryRespVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* C 端目录聚合:把内容域的只读视图组装成客户端一次请求就能渲染的结构。
* 只读取已上线内容,运营状态、审核信息等管理端字段不下发。
*/
@Service
@RequiredArgsConstructor
public class AppCatalogService {
private static final int HOME_SECTION_SIZE = 6;
private static final int DISCOVER_SECTION_SIZE = 10;
private static final int MAX_CATEGORY_SECTIONS = 4;
private final DramaService dramaService;
private final EpisodeService episodeService;
private final BannerService bannerService;
private final DramaCategoryService dramaCategoryService;
public AppFeedVO homeFeed() {
Map<Long, String> categoryNames = categoryNameMap();
List<AppSectionVO> sections = new ArrayList<>();
addIfNotEmpty(sections, "hot", "热门推荐",
dramaService.listPublishedHot(HOME_SECTION_SIZE), categoryNames);
addIfNotEmpty(sections, "new", "新剧上线",
dramaService.listPublishedLatest(HOME_SECTION_SIZE), categoryNames);
sections.addAll(categorySections(HOME_SECTION_SIZE, categoryNames));
return new AppFeedVO(banners(BannerPosition.HOME_TOP.name()), sections);
}
public AppFeedVO discoverFeed() {
return new AppFeedVO(banners(BannerPosition.DISCOVER.name()),
categorySections(DISCOVER_SECTION_SIZE, categoryNameMap()));
}
public List<AppCategoryVO> categories() {
return dramaCategoryService.listBasic().stream()
.filter(category -> category.getStatus() == null || category.getStatus() == 1)
.map(category -> new AppCategoryVO(category.getId(), category.getName()))
.toList();
}
public AppDramaDetailVO dramaDetail(Long dramaId) {
Drama drama = dramaService.getPublished(dramaId);
List<DramaEpisode> episodes = episodeService.listPublishedByDrama(dramaId);
String categoryName = categoryNameMap().getOrDefault(drama.getCategoryId(), "");
AppDramaDetailVO detail = new AppDramaDetailVO();
detail.setId(drama.getId());
detail.setTitle(drama.getTitle());
detail.setSynopsis(drama.getSynopsis());
detail.setCover(drama.getCoverUrl());
detail.setGenre(drama.getGenre());
detail.setCategory(categoryName);
detail.setTotalEpisodes(drama.getTotalEpisodes());
detail.setFreeEpisodes(drama.getFreeEpisodes());
detail.setUnlockCoin(drama.getUnlockCoin());
detail.setIsHot(drama.getIsHot() != null && drama.getIsHot() == 1);
detail.setViewCount(drama.getViewCount());
detail.setEpisodes(episodes.stream().map(this::toEpisode).toList());
return detail;
}
/** 播放票据:目前免费集直接给地址,未解锁只返回需要多少金币 */
public AppPlayTicketVO playTicket(Long episodeId) {
DramaEpisode episode = episodeService.getPublished(episodeId);
dramaService.getPublished(episode.getDramaId());
boolean free = episode.getIsFree() != null && episode.getIsFree() == 1;
boolean hasMedia = episode.getVideoUrl() != null && !episode.getVideoUrl().isBlank();
AppPlayTicketVO ticket = new AppPlayTicketVO();
ticket.setEpisodeId(episode.getId());
ticket.setDramaId(episode.getDramaId());
ticket.setEpNo(episode.getEpNo());
ticket.setTitle(episode.getTitle());
ticket.setDurationSec(episode.getDurationSec());
ticket.setUnlockCoin(episode.getUnlockCoin());
if (!free) {
ticket.setAccessible(false);
ticket.setAccessType("COIN_REQUIRED");
} else if (!hasMedia) {
ticket.setAccessible(false);
ticket.setAccessType("NO_MEDIA");
} else {
ticket.setAccessible(true);
ticket.setAccessType("FREE");
ticket.setPlayUrl(episode.getVideoUrl());
}
return ticket;
}
private List<AppBannerVO> banners(String position) {
return bannerService.listActive(position).stream().map(this::toBanner).toList();
}
private List<AppSectionVO> categorySections(int size, Map<Long, String> categoryNames) {
List<AppSectionVO> sections = new ArrayList<>();
for (DramaCategoryRespVO category : dramaCategoryService.listBasic()) {
if (sections.size() >= MAX_CATEGORY_SECTIONS) {
break;
}
List<Drama> dramas = dramaService.listPublished(category.getId(), size);
if (!dramas.isEmpty()) {
sections.add(new AppSectionVO(
"category-" + category.getId(),
category.getName(),
cards(dramas, categoryNames)));
}
}
return sections;
}
private void addIfNotEmpty(List<AppSectionVO> sections, String code, String title,
List<Drama> dramas, Map<Long, String> categoryNames) {
if (!dramas.isEmpty()) {
sections.add(new AppSectionVO(code, title, cards(dramas, categoryNames)));
}
}
private AppBannerVO toBanner(ContentBanner banner) {
AppBannerVO vo = new AppBannerVO();
vo.setId(banner.getId());
vo.setImageUrl(banner.getImageUrl());
vo.setLinkType(banner.getLinkType());
vo.setLinkValue(banner.getLinkValue());
return vo;
}
/** 整屏卡片共用一次剧集查询,取每部剧的首个可播免费集作为预览。 */
private List<AppDramaCardVO> cards(List<Drama> dramas, Map<Long, String> categoryNames) {
Map<Long, DramaEpisode> previews = episodeService.firstPlayableByDramas(
dramas.stream().map(Drama::getId).toList());
return dramas.stream()
.map(drama -> toCard(drama, categoryNames, previews.get(drama.getId())))
.toList();
}
private AppDramaCardVO toCard(Drama drama, Map<Long, String> categoryNames,
DramaEpisode preview) {
AppDramaCardVO card = new AppDramaCardVO();
card.setId(drama.getId());
card.setTitle(drama.getTitle());
card.setCover(drama.getCoverUrl());
card.setSynopsis(shortSynopsis(drama.getSynopsis()));
card.setGenre(drama.getGenre());
card.setCategory(categoryNames.getOrDefault(drama.getCategoryId(), ""));
card.setTotalEpisodes(drama.getTotalEpisodes());
card.setIsHot(drama.getIsHot() != null && drama.getIsHot() == 1);
card.setViewCount(drama.getViewCount());
card.setPreview(preview == null ? null : toEpisode(preview));
return card;
}
private AppEpisodeVO toEpisode(DramaEpisode episode) {
AppEpisodeVO vo = new AppEpisodeVO();
vo.setId(episode.getId());
vo.setNumber(episode.getEpNo());
vo.setTitle(episode.getTitle());
vo.setDurationSec(episode.getDurationSec());
int seconds = episode.getDurationSec() == null ? 0 : episode.getDurationSec();
vo.setDuration(String.format("%02d:%02d", seconds / 60, seconds % 60));
boolean free = episode.getIsFree() != null && episode.getIsFree() == 1;
vo.setIsFree(free);
vo.setLocked(!free);
vo.setUnlockCoin(episode.getUnlockCoin());
if (free && episode.getVideoUrl() != null && !episode.getVideoUrl().isBlank()) {
vo.setPlayUrl(episode.getVideoUrl());
}
return vo;
}
private Map<Long, String> categoryNameMap() {
Map<Long, String> names = new HashMap<>();
dramaCategoryService.listBasic().forEach(category -> names.put(category.getId(), category.getName()));
return names;
}
/** 卡片只带一句简介,避免列表响应体过大 */
private String shortSynopsis(String synopsis) {
if (synopsis == null) {
return "";
}
return synopsis.length() <= 60 ? synopsis : synopsis.substring(0, 60);
}
}
@@ -0,0 +1,14 @@
package com.reelshort.app.vo;
import lombok.Data;
@Data
public class AppBannerVO {
private Long id;
private String imageUrl;
/** NONE / DRAMA / URL */
private String linkType;
/** 短剧 ID 或跳转地址 */
private String linkValue;
}
@@ -0,0 +1,12 @@
package com.reelshort.app.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class AppCategoryVO {
private Long id;
private String name;
}
@@ -0,0 +1,23 @@
package com.reelshort.app.vo;
import lombok.Data;
/** 列表卡片,用于首页/发现页/分类分区 */
@Data
public class AppDramaCardVO {
private Long id;
private String title;
private String cover;
private String synopsis;
private String genre;
private String category;
private Integer totalEpisodes;
private Boolean isHot;
private Long viewCount;
/**
* 首个可播免费集。首页竖屏播放需要在列表阶段就能起播,
* 带上这一集可以省掉「点开→请求详情→再请求播放地址」的往返。
*/
private AppEpisodeVO preview;
}
@@ -0,0 +1,22 @@
package com.reelshort.app.vo;
import lombok.Data;
import java.util.List;
@Data
public class AppDramaDetailVO {
private Long id;
private String title;
private String synopsis;
private String cover;
private String genre;
private String category;
private Integer totalEpisodes;
private Integer freeEpisodes;
private Integer unlockCoin;
private Boolean isHot;
private Long viewCount;
private List<AppEpisodeVO> episodes;
}
@@ -0,0 +1,23 @@
package com.reelshort.app.vo;
import lombok.Data;
@Data
public class AppEpisodeVO {
private Long id;
private Integer number;
private String title;
/** 展示用时长 mm:ss */
private String duration;
private Integer durationSec;
private Boolean isFree;
/** true 表示需要解锁后才能播放 */
private Boolean locked;
private Integer unlockCoin;
/**
* 可播放时的地址;锁定或不含片源时为空。
* 详情页拿到就能直接播,不必按集再请求一次播放票据。
*/
private String playUrl;
}
@@ -0,0 +1,15 @@
package com.reelshort.app.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.List;
/** 首页/发现页聚合响应:一次请求拿到轮播与所有分区,减少客户端往返 */
@Data
@AllArgsConstructor
public class AppFeedVO {
private List<AppBannerVO> banners;
private List<AppSectionVO> sections;
}
@@ -0,0 +1,20 @@
package com.reelshort.app.vo;
import lombok.Data;
/** 播放票据:客户端据此决定直接播放还是弹解锁 */
@Data
public class AppPlayTicketVO {
private Long episodeId;
private Long dramaId;
private Integer epNo;
private String title;
private Integer durationSec;
private Boolean accessible;
/** FREE / COIN_REQUIRED / VIP_REQUIRED / NO_MEDIA */
private String accessType;
private Integer unlockCoin;
/** 可访问时的播放地址(相对路径,客户端拼上服务地址) */
private String playUrl;
}
@@ -0,0 +1,17 @@
package com.reelshort.app.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.List;
/** 首页/发现页的一个横滑分区 */
@Data
@AllArgsConstructor
public class AppSectionVO {
/** 分区标识:hot / new / category-{id},客户端可据此做埋点与样式 */
private String code;
private String title;
private List<AppDramaCardVO> dramas;
}
@@ -0,0 +1,43 @@
server:
port: 8081
spring:
application:
name: reelshort-app-api
autoconfigure:
exclude: org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://127.0.0.1:5432/reelshort
username: ${DB_USER:reelshort}
password: ${DB_PASSWORD:reelshort}
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
global-config:
banner: false
db-config:
id-type: auto
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
reelshort:
media:
root-dir: ./data/uploads
public-prefix: /files
chunk-size: 5242880
max-file-size: 2147483648
jwt:
admin-secret: ${JWT_ADMIN_SECRET:reelshort-admin-dev-secret-change-me-in-production-32b}
admin-ttl-hours: 12
cors:
allowed-origins: "*"
logging:
level:
com.reelshort: info
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.reelshort</groupId>
<artifactId>reelshort</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>reelshort-common</artifactId>
<name>reelshort-common</name>
<description>通用模块:统一响应、分页、异常、工具</description>
</project>
@@ -0,0 +1,39 @@
package com.reelshort.common.exception;
import lombok.Getter;
/**
* 全局错误码,详见 docs/architecture-design.md 附录 A。
*/
@Getter
public enum ErrorCode {
SUCCESS(0, "ok"),
BAD_REQUEST(400, "参数校验失败"),
UNAUTHORIZED(401, "未登录或登录已过期"),
FORBIDDEN(403, "无权限"),
NOT_FOUND(404, "资源不存在"),
TOO_MANY_REQUESTS(429, "请求过于频繁"),
INTERNAL_ERROR(500, "系统异常"),
LOGIN_FAILED(100000, "账号或密码错误"),
ACCOUNT_DISABLED(100001, "账号已停用"),
COIN_NOT_ENOUGH(100002, "金币余额不足"),
EPISODE_NOT_AVAILABLE(100003, "剧集未发布或不存在"),
EPISODE_UNLOCKED(100004, "剧集已解锁"),
ORDER_STATUS_INVALID(100005, "订单已支付或已关闭"),
VIP_EXPIRED(100006, "VIP 已过期"),
DATA_EXISTS(200001, "数据已存在"),
DATA_NOT_FOUND(200002, "数据不存在"),
OPERATION_NOT_ALLOWED(200003, "当前状态不允许该操作");
private final int code;
private final String message;
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
}
@@ -0,0 +1,31 @@
package com.reelshort.common.exception;
import lombok.Getter;
/**
* 业务异常:带业务错误码,由全局异常处理器转成统一响应。
*/
@Getter
public class ServiceException extends RuntimeException {
private final int code;
public ServiceException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
}
public ServiceException(ErrorCode errorCode, String message) {
super(message);
this.code = errorCode.getCode();
}
public ServiceException(String message) {
super(message);
this.code = ErrorCode.INTERNAL_ERROR.getCode();
}
public static ServiceException of(ErrorCode errorCode) {
return new ServiceException(errorCode);
}
}
@@ -0,0 +1,30 @@
package com.reelshort.common.pojo;
import lombok.Data;
/**
* 分页请求参数,pageIndex 从 1 开始,与前端一致。
*/
@Data
public class PageParam {
private static final int MAX_PAGE_SIZE = 200;
private Integer pageIndex = 1;
private Integer pageSize = 10;
public long getOffset() {
return (long) (getSafePageIndex() - 1) * getSafePageSize();
}
public int getSafePageIndex() {
return pageIndex == null || pageIndex < 1 ? 1 : pageIndex;
}
public int getSafePageSize() {
if (pageSize == null || pageSize < 1) {
return 10;
}
return Math.min(pageSize, MAX_PAGE_SIZE);
}
}
@@ -0,0 +1,21 @@
package com.reelshort.common.pojo;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.List;
/**
* 分页响应,字段名与前端表格组件(total / list)保持一致。
*/
@Getter
@AllArgsConstructor
public class PageResult<T> {
private final long total;
private final List<T> list;
public static <T> PageResult<T> empty() {
return new PageResult<>(0L, List.of());
}
}
@@ -0,0 +1,37 @@
package com.reelshort.common.pojo;
import com.reelshort.common.exception.ErrorCode;
import lombok.Getter;
/**
* 统一响应体,与前端 axios 拦截器约定一致:{ code, message, data }。
*/
@Getter
public class Result<T> {
private final int code;
private final String message;
private final T data;
private Result(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
public static Result<Void> ok() {
return new Result<>(ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), null);
}
public static <T> Result<T> ok(T data) {
return new Result<>(ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), data);
}
public static <T> Result<T> error(ErrorCode errorCode) {
return new Result<>(errorCode.getCode(), errorCode.getMessage(), null);
}
public static <T> Result<T> error(int code, String message) {
return new Result<>(code, message, null);
}
}
+68
View File
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.reelshort</groupId>
<artifactId>reelshort</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>reelshort-framework</artifactId>
<name>reelshort-framework</name>
<description>技术框架:安全、鉴权、持久层、Web 配置</description>
<dependencies>
<dependency>
<groupId>com.reelshort</groupId>
<artifactId>reelshort-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,18 @@
package com.reelshort.framework.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL));
return interceptor;
}
}
@@ -0,0 +1,58 @@
package com.reelshort.framework.log;
import com.reelshort.framework.security.LoginUser;
import com.reelshort.framework.security.SecurityUtils;
import com.reelshort.framework.web.WebUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* 越权访问留痕。
* 方法级鉴权(@PreAuthorize)抛出的异常走全局异常处理,过滤器级的走安全入口,
* 两处都调用这里,避免逻辑重复。
*/
@Component
@RequiredArgsConstructor
public class AccessDeniedRecorder {
private final ObjectProvider<OperationLogSink> sinkProvider;
public void record(HttpServletRequest request) {
OperationLogSink sink = sinkProvider.getIfAvailable();
if (sink == null) {
return;
}
LoginUser loginUser = null;
try {
loginUser = SecurityUtils.getLoginUser();
} catch (Exception ignored) {
// 未登录时没有登录态
}
sink.save(OperationLogEntry.builder()
.userId(loginUser == null ? 0L : loginUser.userId())
.username(loginUser == null ? "" : loginUser.username())
.module("安全")
.action("越权访问被拒绝")
.requestMethod(request == null ? "" : request.getMethod())
.requestUrl(request == null ? "" : request.getRequestURI())
.requestParams(request == null || request.getQueryString() == null
? "" : request.getQueryString())
.responseBody("{\"code\":403,\"message\":\"无权限\"}")
.status("FAIL")
.errorMsg("无权限")
.durationMs(0)
.ip(request == null ? "" : WebUtils.getClientIp(request))
.build());
}
/** 从当前线程取出请求,供全局异常处理使用。 */
public void recordCurrentRequest() {
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) {
record(attributes.getRequest());
}
}
}
@@ -0,0 +1,21 @@
package com.reelshort.framework.log;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 标注在需要留痕的接口方法上。
* 请求参数、请求体、响应体、操作人、耗时、IP 由 {@link OperationLogAspect} 自动采集。
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
/** 所属模块,如「内容管理」 */
String module() default "";
/** 动作名称,如「新增轮播图」 */
String action() default "";
}
@@ -0,0 +1,151 @@
package com.reelshort.framework.log;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.reelshort.framework.security.LoginUser;
import com.reelshort.framework.security.SecurityUtils;
import com.reelshort.framework.web.WebUtils;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import org.springframework.validation.BindingResult;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 自动记录后台写操作:谁、什么时候、调了哪个接口、传了什么、返回了什么、耗时多少。
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class OperationLogAspect {
private static final int MAX_TEXT = 4000;
private static final String TRUNCATED = "\n...(已截断)";
/** 密码、密钥、令牌等不落库 */
private static final Pattern SENSITIVE_FIELDS = Pattern.compile(
"(?i)(\"(?:password|oldPassword|newPassword|confirmPassword|secret|token|accessToken|refreshToken)\"\\s*:\\s*)\"[^\"]*\"");
private final ObjectMapper objectMapper;
private final ObjectProvider<OperationLogSink> sinkProvider;
@Around("@annotation(operationLog)")
public Object around(ProceedingJoinPoint point, OperationLog operationLog) throws Throwable {
long started = System.currentTimeMillis();
Object result = null;
Throwable error = null;
try {
result = point.proceed();
return result;
} catch (Throwable ex) {
error = ex;
throw ex;
} finally {
try {
save(point, operationLog, result, error, (int) (System.currentTimeMillis() - started));
} catch (Exception ex) {
log.warn("写操作日志失败: {}", operationLog.action(), ex);
}
}
}
private void save(ProceedingJoinPoint point, OperationLog annotation, Object result,
Throwable error, int durationMs) {
OperationLogSink sink = sinkProvider.getIfAvailable();
if (sink == null) {
return;
}
HttpServletRequest request = currentRequest();
LoginUser loginUser = currentUser();
sink.save(OperationLogEntry.builder()
.userId(loginUser == null ? 0L : loginUser.userId())
.username(loginUser == null ? "" : loginUser.username())
.module(annotation.module())
.action(annotation.action())
.requestMethod(request == null ? "" : request.getMethod())
.requestUrl(request == null ? "" : request.getRequestURI())
.requestParams(request == null ? "" : truncate(request.getQueryString()))
.requestBody(body(point))
.responseBody(error != null ? "" : json(result))
.status(error == null ? "SUCCESS" : "FAIL")
.errorMsg(error == null ? "" : truncate(error.getMessage()))
.durationMs(durationMs)
.ip(request == null ? "" : WebUtils.getClientIp(request))
.build());
}
/** 把方法入参整理成 JSON,文件只记名称和大小。 */
private String body(ProceedingJoinPoint point) {
Object[] args = point.getArgs();
String[] names = ((MethodSignature) point.getSignature()).getParameterNames();
Map<String, Object> payload = new LinkedHashMap<>();
for (int index = 0; index < args.length; index++) {
Object arg = args[index];
if (arg == null || arg instanceof ServletRequest || arg instanceof ServletResponse
|| arg instanceof BindingResult) {
continue;
}
String name = names != null && index < names.length ? names[index] : "arg" + index;
if (arg instanceof MultipartFile file) {
payload.put(name, "%s (%d bytes)".formatted(file.getOriginalFilename(), file.getSize()));
} else {
payload.put(name, arg);
}
}
if (payload.isEmpty()) {
return "";
}
// 只有一个入参时直接序列化该参数,日志更易读
return payload.size() == 1 ? json(payload.values().iterator().next()) : json(payload);
}
private String json(Object value) {
if (value == null) {
return "";
}
try {
String text = objectMapper.writeValueAsString(value);
text = SENSITIVE_FIELDS.matcher(text).replaceAll("$1\"***\"");
return truncate(text);
} catch (Exception ex) {
return String.valueOf(value);
}
}
private String truncate(String text) {
if (text == null) {
return "";
}
return text.length() <= MAX_TEXT ? text : text.substring(0, MAX_TEXT) + TRUNCATED;
}
private HttpServletRequest currentRequest() {
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) {
return attributes.getRequest();
}
return null;
}
private LoginUser currentUser() {
try {
return SecurityUtils.getLoginUser();
} catch (Exception ex) {
// 登录等公开接口没有登录态
return null;
}
}
}
@@ -0,0 +1,25 @@
package com.reelshort.framework.log;
import lombok.Builder;
import lombok.Data;
/** 一条待落库的操作记录。 */
@Data
@Builder
public class OperationLogEntry {
private Long userId;
private String username;
private String module;
private String action;
private String requestMethod;
private String requestUrl;
private String requestParams;
private String requestBody;
private String responseBody;
/** SUCCESS / FAIL */
private String status;
private String errorMsg;
private Integer durationMs;
private String ip;
}
@@ -0,0 +1,9 @@
package com.reelshort.framework.log;
/**
* 操作日志落库的扩展点,由业务模块实现(框架层不依赖具体存储)。
*/
public interface OperationLogSink {
void save(OperationLogEntry entry);
}
@@ -0,0 +1,51 @@
package com.reelshort.framework.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
/**
* 解析 Authorization: Bearer <token>,写入 SecurityContext。
*/
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String HEADER = "Authorization";
private static final String PREFIX = "Bearer ";
private final JwtTokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader(HEADER);
if (StringUtils.hasText(header) && header.startsWith(PREFIX)) {
String token = header.substring(PREFIX.length()).trim();
try {
LoginUser loginUser = tokenProvider.parseAdminToken(token);
List<SimpleGrantedAuthority> authorities = loginUser.permissions() == null
? List.of()
: loginUser.permissions().stream().map(SimpleGrantedAuthority::new).toList();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(loginUser, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception ignored) {
// token 无效时不抛出,交给后续的鉴权入口统一返回 401
SecurityContextHolder.clearContext();
}
}
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,17 @@
package com.reelshort.framework.security;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "reelshort.jwt")
public class JwtProperties {
/** 管理端签名密钥,长度必须 >= 32 字节。生产环境通过环境变量注入。 */
private String adminSecret = "reelshort-admin-dev-secret-change-me-in-production-32b";
/** 管理端 token 有效期(小时)。 */
private int adminTtlHours = 12;
}
@@ -0,0 +1,67 @@
package com.reelshort.framework.security;
import com.reelshort.common.exception.ErrorCode;
import com.reelshort.common.exception.ServiceException;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Date;
import java.util.List;
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private static final String CLAIM_USER_ID = "userId";
private static final String CLAIM_ROLE_CODE = "roleCode";
private static final String CLAIM_PERMISSIONS = "permissions";
private final JwtProperties properties;
public String createAdminToken(LoginUser loginUser) {
Date now = new Date();
Date expiration = new Date(now.getTime() + Duration.ofHours(properties.getAdminTtlHours()).toMillis());
return Jwts.builder()
.subject(loginUser.username())
.claim(CLAIM_USER_ID, loginUser.userId())
.claim(CLAIM_ROLE_CODE, loginUser.roleCode())
.claim(CLAIM_PERMISSIONS, loginUser.permissions())
.issuedAt(now)
.expiration(expiration)
.signWith(adminKey())
.compact();
}
@SuppressWarnings("unchecked")
public LoginUser parseAdminToken(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(adminKey())
.build()
.parseSignedClaims(token)
.getPayload();
Number userId = claims.get(CLAIM_USER_ID, Number.class);
return new LoginUser(
userId == null ? null : userId.longValue(),
claims.getSubject(),
claims.get(CLAIM_ROLE_CODE, String.class),
(List<String>) claims.get(CLAIM_PERMISSIONS, List.class));
} catch (Exception ex) {
throw new ServiceException(ErrorCode.UNAUTHORIZED);
}
}
private SecretKey adminKey() {
byte[] bytes = properties.getAdminSecret().getBytes(StandardCharsets.UTF_8);
if (bytes.length < 32) {
throw new IllegalStateException("reelshort.jwt.admin-secret 长度必须不少于 32 字节");
}
return Keys.hmacShaKeyFor(bytes);
}
}
@@ -0,0 +1,13 @@
package com.reelshort.framework.security;
import java.util.List;
/**
* 登录态载体,由 JWT 解析得到,随 SecurityContext 传递。
*/
public record LoginUser(Long userId, String username, String roleCode, List<String> permissions) {
public boolean hasPermission(String permission) {
return permissions != null && permissions.contains(permission);
}
}
@@ -0,0 +1,50 @@
package com.reelshort.framework.security;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 供 {@code @PreAuthorize("@ss.hasPermission('drama:create')")} 使用的权限判断。
* 优先按用户实时查询权限(角色的权限改了立即生效),查不到时退回 JWT 中的权限列表。
*/
@Component("ss")
@RequiredArgsConstructor
public class PermissionChecker {
private final ObjectProvider<PermissionProvider> permissionProvider;
public boolean hasPermission(String permission) {
return permissions().contains(permission);
}
public boolean hasAnyPermission(String... permissions) {
Set<String> granted = permissions();
for (String permission : permissions) {
if (granted.contains(permission)) {
return true;
}
}
return false;
}
private Set<String> permissions() {
LoginUser loginUser = SecurityUtils.getLoginUser();
PermissionProvider provider = permissionProvider.getIfAvailable();
if (provider != null && loginUser.userId() != null) {
try {
List<String> codes = provider.listPermissionsByUserId(loginUser.userId());
if (codes != null) {
return new HashSet<>(codes);
}
} catch (Exception ignored) {
// 查询失败时退回 JWT 中的权限,避免因为权限服务异常导致全部请求失败
}
}
return loginUser.permissions() == null ? Set.of() : new HashSet<>(loginUser.permissions());
}
}
@@ -0,0 +1,12 @@
package com.reelshort.framework.security;
import java.util.List;
/**
* 权限来源的扩展点:由业务模块实现,用于按用户实时查询权限码。
* 没有实现时,权限判断退回到 JWT 中携带的权限列表。
*/
public interface PermissionProvider {
List<String> listPermissionsByUserId(Long userId);
}
@@ -0,0 +1,49 @@
package com.reelshort.framework.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.reelshort.common.exception.ErrorCode;
import com.reelshort.common.pojo.Result;
import com.reelshort.framework.log.AccessDeniedRecorder;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* 未登录与无权限时,也返回统一响应体,保证前端拦截器能识别 401 并登出。
*/
@Component
@RequiredArgsConstructor
public class RestAuthEntryPoint implements AuthenticationEntryPoint, AccessDeniedHandler {
private final ObjectMapper objectMapper;
private final AccessDeniedRecorder accessDeniedRecorder;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
write(response, Result.error(ErrorCode.UNAUTHORIZED));
}
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
accessDeniedRecorder.record(request);
write(response, Result.error(ErrorCode.FORBIDDEN));
}
private void write(HttpServletResponse response, Result<?> result) throws IOException {
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().write(objectMapper.writeValueAsString(result));
}
}
@@ -0,0 +1,80 @@
package com.reelshort.framework.security;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private static final String[] PUBLIC_PATHS = {
"/admin/auth/login",
/* 客户端接口:浏览与播放暂不要求登录,登录体系(手机号/邮箱)在 C 端鉴权落地后收紧 */
"/app/**",
/* 本地媒资:图片与视频用 <img>/<video> 直接访问,无法带请求头 */
"/files/**",
"/doc.html",
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**",
"/actuator/health"
};
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final RestAuthEntryPoint restAuthEntryPoint;
@Value("${reelshort.cors.allowed-origins:http://localhost:3005}")
private String allowedOrigins;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(registry -> registry
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers(PUBLIC_PATHS).permitAll()
.anyRequest().authenticated())
.exceptionHandling(handling -> handling
.authenticationEntryPoint(restAuthEntryPoint)
.accessDeniedHandler(restAuthEntryPoint))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.stream(allowedOrigins.split(",")).map(String::trim).toList());
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@@ -0,0 +1,28 @@
package com.reelshort.framework.security;
import com.reelshort.common.exception.ErrorCode;
import com.reelshort.common.exception.ServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
public final class SecurityUtils {
private SecurityUtils() {
}
public static LoginUser getLoginUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof LoginUser loginUser) {
return loginUser;
}
throw new ServiceException(ErrorCode.UNAUTHORIZED);
}
public static Long getUserId() {
return getLoginUser().userId();
}
public static String getUsername() {
return getLoginUser().username();
}
}
@@ -0,0 +1,84 @@
package com.reelshort.framework.web;
import com.reelshort.common.exception.ErrorCode;
import com.reelshort.common.exception.ServiceException;
import com.reelshort.common.pojo.Result;
import com.reelshort.framework.log.AccessDeniedRecorder;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import lombok.RequiredArgsConstructor;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.apache.catalina.connector.ClientAbortException;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
/**
* 全局异常处理:所有异常都转成统一响应体,前端拦截器只需判断 code。
*/
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private final AccessDeniedRecorder accessDeniedRecorder;
@ExceptionHandler(ServiceException.class)
public Result<Void> handleServiceException(ServiceException ex) {
log.warn("业务异常: code={}, message={}", ex.getCode(), ex.getMessage());
return Result.error(ex.getCode(), ex.getMessage());
}
/** 方法级权限校验失败:统一返回 403,前端据此提示无权限。 */
@ExceptionHandler(AccessDeniedException.class)
public Result<Void> handleAccessDenied(AccessDeniedException ex) {
log.warn("无权限访问: {}", ex.getMessage());
accessDeniedRecorder.recordCurrentRequest();
return Result.error(ErrorCode.FORBIDDEN);
}
@ExceptionHandler({MethodArgumentNotValidException.class, BindException.class})
public Result<Void> handleBindException(BindException ex) {
FieldError fieldError = ex.getBindingResult().getFieldError();
String message = fieldError == null ? ErrorCode.BAD_REQUEST.getMessage() : fieldError.getDefaultMessage();
return Result.error(ErrorCode.BAD_REQUEST.getCode(), message);
}
@ExceptionHandler(ConstraintViolationException.class)
public Result<Void> handleConstraintViolation(ConstraintViolationException ex) {
return Result.error(ErrorCode.BAD_REQUEST.getCode(), ex.getMessage());
}
@ExceptionHandler({MissingServletRequestParameterException.class, HttpMessageNotReadableException.class})
public Result<Void> handleBadRequest(Exception ex) {
return Result.error(ErrorCode.BAD_REQUEST.getCode(), ErrorCode.BAD_REQUEST.getMessage());
}
/** 静态资源或未匹配的路径:按 404 返回,不要吞成 500。 */
@ExceptionHandler(NoResourceFoundException.class)
public Result<Void> handleNoResourceFound(NoResourceFoundException ex) {
log.warn("资源不存在: {}", ex.getResourcePath());
return Result.error(ErrorCode.NOT_FOUND);
}
/**
* 客户端中断(例如视频边下边播时用户划走、关闭页面)是正常现象,
* 不该记成系统异常,日志降级为 debug。
*/
@ExceptionHandler({ClientAbortException.class, AsyncRequestNotUsableException.class})
public void handleClientAbort(Exception ex) {
log.debug("客户端中断连接: {}", ex.getMessage());
}
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception ex) {
log.error("系统异常", ex);
return Result.error(ErrorCode.INTERNAL_ERROR);
}
}
@@ -0,0 +1,40 @@
package com.reelshort.framework.web;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 慢请求告警:客户端接口的响应时间目标是 50ms,超过阈值的请求打 WARN,
* 方便在内容量增长、索引失效或缓存缺失时第一时间发现。
*/
@Slf4j
@Component
public class SlowRequestLogger extends OncePerRequestFilter {
private static final long SLOW_THRESHOLD_MS = 50;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
long started = System.currentTimeMillis();
try {
filterChain.doFilter(request, response);
} finally {
// 只关注客户端接口,管理端后台页面不做这个约束
if (request.getRequestURI().startsWith("/app/")) {
long cost = System.currentTimeMillis() - started;
if (cost > SLOW_THRESHOLD_MS) {
log.warn("慢请求 {} {} 耗时 {}ms(目标 {}ms)",
request.getMethod(), request.getRequestURI(), cost, SLOW_THRESHOLD_MS);
}
}
}
}
}
@@ -0,0 +1,25 @@
package com.reelshort.framework.web;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.util.StringUtils;
public final class WebUtils {
private static final String UNKNOWN = "unknown";
private WebUtils() {
}
/** 取真实客户端 IP,兼容 Nginx 反向代理。 */
public static String getClientIp(HttpServletRequest request) {
String[] headers = {"X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP"};
for (String header : headers) {
String value = request.getHeader(header);
if (StringUtils.hasText(value) && !UNKNOWN.equalsIgnoreCase(value)) {
int comma = value.indexOf(',');
return comma > 0 ? value.substring(0, comma).trim() : value.trim();
}
}
return request.getRemoteAddr();
}
}
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.reelshort</groupId>
<artifactId>reelshort</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>reelshort-module-content</artifactId>
<name>reelshort-module-content</name>
<description>内容域:短剧、剧集、审核发布</description>
<dependencies>
<dependency>
<groupId>com.reelshort</groupId>
<artifactId>reelshort-framework</artifactId>
</dependency>
<dependency>
<groupId>com.reelshort</groupId>
<artifactId>reelshort-module-system</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,36 @@
package com.reelshort.content.dal.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("content_banner")
public class ContentBanner {
@TableId(type = IdType.AUTO)
private Long id;
private String imageUrl;
/** 展示位置,列名为 slot(position 在 PostgreSQL 里是关键字) */
@TableField("slot")
private String position;
private String linkType;
private String linkValue;
private Integer sort;
private Integer status;
private Long createBy;
private LocalDateTime createdAt;
/** updated_at 交给数据库的 ON UPDATE CURRENT_TIMESTAMP 维护 */
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,48 @@
package com.reelshort.content.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("drama")
public class Drama {
@TableId(type = IdType.AUTO)
private Long id;
private String code;
private String title;
private String subtitle;
private Long categoryId;
private String genre;
private String synopsis;
private String coverUrl;
private String horizontalCoverUrl;
private String language;
private Integer totalEpisodes;
private Integer freeEpisodes;
private Integer unlockCoin;
/** 是否热门:1 是,0 否 */
private Integer isHot;
private String status;
private LocalDateTime publishAt;
private LocalDateTime publishedAt;
private Long viewCount;
private Long playCount;
private Long favoriteCount;
private Long likeCount;
private Integer sort;
private Long createBy;
private LocalDateTime createdAt;
/** updated_at 交给数据库的 ON UPDATE CURRENT_TIMESTAMP 维护 */
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,17 @@
package com.reelshort.content.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("drama_category")
public class DramaCategory {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private Integer sort;
private Integer status;
}
@@ -0,0 +1,38 @@
package com.reelshort.content.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("drama_episode")
public class DramaEpisode {
@TableId(type = IdType.AUTO)
private Long id;
private Long dramaId;
private Integer epNo;
private String title;
private Integer durationSec;
private String coverUrl;
private Long videoAssetId;
private String videoUrl;
private Integer isFree;
private Integer unlockCoin;
private String status;
private LocalDateTime releaseAt;
private Long playCount;
private Integer sort;
private LocalDateTime createdAt;
/** updated_at 交给数据库的 ON UPDATE CURRENT_TIMESTAMP 维护 */
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,23 @@
package com.reelshort.content.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("drama_review_record")
public class DramaReviewRecord {
@TableId(type = IdType.AUTO)
private Long id;
private String bizType;
private Long bizId;
private String action;
private String reason;
private Long operatorId;
private String operatorName;
private LocalDateTime createdAt;
}
@@ -0,0 +1,7 @@
package com.reelshort.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.reelshort.content.dal.entity.ContentBanner;
public interface ContentBannerMapper extends BaseMapper<ContentBanner> {
}
@@ -0,0 +1,7 @@
package com.reelshort.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.reelshort.content.dal.entity.DramaCategory;
public interface DramaCategoryMapper extends BaseMapper<DramaCategory> {
}
@@ -0,0 +1,7 @@
package com.reelshort.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.reelshort.content.dal.entity.DramaEpisode;
public interface DramaEpisodeMapper extends BaseMapper<DramaEpisode> {
}
@@ -0,0 +1,7 @@
package com.reelshort.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.reelshort.content.dal.entity.Drama;
public interface DramaMapper extends BaseMapper<Drama> {
}
@@ -0,0 +1,7 @@
package com.reelshort.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.reelshort.content.dal.entity.DramaReviewRecord;
public interface DramaReviewRecordMapper extends BaseMapper<DramaReviewRecord> {
}
@@ -0,0 +1,36 @@
package com.reelshort.content.enums;
import lombok.Getter;
/** 轮播图点击后的跳转类型。 */
@Getter
public enum BannerLinkType {
NONE("不跳转"),
DRAMA("跳转短剧"),
URL("打开链接");
private final String text;
BannerLinkType(String text) {
this.text = text;
}
public static String textOf(String code) {
for (BannerLinkType type : values()) {
if (type.name().equals(code)) {
return type.text;
}
}
return code;
}
public static boolean isValid(String code) {
for (BannerLinkType type : values()) {
if (type.name().equals(code)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,35 @@
package com.reelshort.content.enums;
import lombok.Getter;
/** 轮播图展示位置。 */
@Getter
public enum BannerPosition {
HOME_TOP("首页顶部"),
DISCOVER("发现页");
private final String text;
BannerPosition(String text) {
this.text = text;
}
public static String textOf(String code) {
for (BannerPosition position : values()) {
if (position.name().equals(code)) {
return position.text;
}
}
return code;
}
public static boolean isValid(String code) {
for (BannerPosition position : values()) {
if (position.name().equals(code)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,33 @@
package com.reelshort.content.enums;
import lombok.Getter;
/**
* 短剧与剧集共用一套状态码,与前端展示文案一一对应。
*/
@Getter
public enum ContentStatus {
DRAFT("draft", "草稿"),
REVIEWING("reviewing", "审核中"),
APPROVED("approved", "待发布"),
PUBLISHED("published", "已上线"),
OFFLINE("offline", "已下线");
private final String code;
private final String text;
ContentStatus(String code, String text) {
this.code = code;
this.text = text;
}
public static String textOf(String code) {
for (ContentStatus status : values()) {
if (status.code.equals(code)) {
return status.text;
}
}
return code;
}
}
@@ -0,0 +1,191 @@
package com.reelshort.content.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.reelshort.common.exception.ErrorCode;
import com.reelshort.common.exception.ServiceException;
import com.reelshort.common.pojo.PageResult;
import com.reelshort.content.dal.entity.ContentBanner;
import com.reelshort.content.dal.entity.Drama;
import com.reelshort.content.dal.mapper.ContentBannerMapper;
import com.reelshort.content.dal.mapper.DramaMapper;
import com.reelshort.content.enums.BannerLinkType;
import com.reelshort.content.enums.BannerPosition;
import com.reelshort.content.vo.BannerRespVO;
import com.reelshort.content.vo.BannerSaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class BannerService {
private final ContentBannerMapper contentBannerMapper;
private final DramaMapper dramaMapper;
public PageResult<BannerRespVO> page(String position, Integer status, Integer pageIndex, Integer pageSize) {
Page<ContentBanner> page = new Page<>(
pageIndex == null || pageIndex < 1 ? 1 : pageIndex,
pageSize == null || pageSize < 1 ? 10 : pageSize);
IPage<ContentBanner> result = contentBannerMapper.selectPage(page,
Wrappers.<ContentBanner>lambdaQuery()
.eq(StringUtils.hasText(position), ContentBanner::getPosition, position)
.eq(status != null, ContentBanner::getStatus, status)
.orderByAsc(ContentBanner::getSort)
.orderByDesc(ContentBanner::getId));
return new PageResult<>(result.getTotal(), toRespList(result.getRecords()));
}
public BannerRespVO get(Long id) {
return toRespList(List.of(getExisting(id))).get(0);
}
/** C 端:启用中的轮播图,按运营排序 */
public List<ContentBanner> listActive(String position) {
return contentBannerMapper.selectList(Wrappers.<ContentBanner>lambdaQuery()
.eq(ContentBanner::getStatus, 1)
.eq(StringUtils.hasText(position), ContentBanner::getPosition, position)
.orderByAsc(ContentBanner::getSort)
.orderByAsc(ContentBanner::getId));
}
@Transactional(rollbackFor = Exception.class)
public Long create(BannerSaveReqVO req, Long operatorId) {
ContentBanner banner = new ContentBanner();
apply(banner, req);
banner.setCreateBy(operatorId == null ? 0L : operatorId);
contentBannerMapper.insert(banner);
return banner.getId();
}
@Transactional(rollbackFor = Exception.class)
public void update(Long id, BannerSaveReqVO req) {
ContentBanner banner = getExisting(id);
apply(banner, req);
contentBannerMapper.updateById(banner);
}
public void delete(Long id) {
getExisting(id);
contentBannerMapper.deleteById(id);
}
public void updateStatus(Long id, Integer status) {
ContentBanner banner = getExisting(id);
banner.setStatus(status == null || status == 0 ? 0 : 1);
contentBannerMapper.updateById(banner);
}
/** 按传入顺序重排 sort */
@Transactional(rollbackFor = Exception.class)
public void updateSort(List<Long> ids) {
for (int index = 0; index < ids.size(); index++) {
ContentBanner banner = contentBannerMapper.selectById(ids.get(index));
if (banner != null) {
banner.setSort(index + 1);
contentBannerMapper.updateById(banner);
}
}
}
private ContentBanner getExisting(Long id) {
ContentBanner banner = id == null ? null : contentBannerMapper.selectById(id);
if (banner == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "轮播图不存在");
}
return banner;
}
private void apply(ContentBanner banner, BannerSaveReqVO req) {
String position = StringUtils.hasText(req.getPosition()) ? req.getPosition() : BannerPosition.HOME_TOP.name();
if (!BannerPosition.isValid(position)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "未知的展示位置: " + position);
}
String linkType = StringUtils.hasText(req.getLinkType()) ? req.getLinkType() : BannerLinkType.NONE.name();
if (!BannerLinkType.isValid(linkType)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "未知的跳转类型: " + linkType);
}
String linkValue = req.getLinkValue() == null ? "" : req.getLinkValue().trim();
if (BannerLinkType.DRAMA.name().equals(linkType)) {
if (!StringUtils.hasText(linkValue)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请选择要跳转的短剧");
}
// link_value 存的是字符串,查询前必须转成 Long,
// 否则 PostgreSQL 会直接拒绝 varchar 与 bigint 比较。
Long dramaId = parseId(linkValue);
if (dramaId == null || dramaMapper.selectById(dramaId) == null) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "跳转的短剧不存在");
}
linkValue = String.valueOf(dramaId);
} else if (BannerLinkType.URL.name().equals(linkType)) {
if (!StringUtils.hasText(linkValue)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请填写跳转地址");
}
} else {
linkValue = "";
}
banner.setImageUrl(req.getImageUrl().trim());
banner.setPosition(position);
banner.setLinkType(linkType);
banner.setLinkValue(linkValue);
banner.setSort(req.getSort() == null ? 0 : req.getSort());
banner.setStatus(req.getStatus() == null ? 1 : req.getStatus());
}
private List<BannerRespVO> toRespList(List<ContentBanner> banners) {
if (banners.isEmpty()) {
return List.of();
}
// 批量取跳转短剧名称
List<Long> dramaIds = banners.stream()
.filter(banner -> BannerLinkType.DRAMA.name().equals(banner.getLinkType()))
.map(banner -> parseId(banner.getLinkValue()))
.filter(id -> id != null)
.distinct()
.toList();
Map<Long, String> dramaNames = new HashMap<>();
if (!dramaIds.isEmpty()) {
dramaMapper.selectList(Wrappers.<Drama>lambdaQuery().in(Drama::getId, dramaIds))
.forEach(drama -> dramaNames.put(drama.getId(), drama.getTitle()));
}
return banners.stream().map(banner -> {
BannerRespVO vo = new BannerRespVO();
vo.setId(banner.getId());
vo.setImageUrl(banner.getImageUrl());
vo.setPosition(banner.getPosition());
vo.setPositionText(BannerPosition.textOf(banner.getPosition()));
vo.setLinkType(banner.getLinkType());
vo.setLinkTypeText(BannerLinkType.textOf(banner.getLinkType()));
vo.setLinkValue(banner.getLinkValue());
if (BannerLinkType.DRAMA.name().equals(banner.getLinkType())) {
Long dramaId = parseId(banner.getLinkValue());
vo.setLinkText(dramaId == null ? "" : dramaNames.getOrDefault(dramaId, "已删除的短剧"));
} else if (BannerLinkType.URL.name().equals(banner.getLinkType())) {
vo.setLinkText(banner.getLinkValue());
} else {
vo.setLinkText("");
}
vo.setSort(banner.getSort());
vo.setStatus(banner.getStatus());
vo.setStatusText(banner.getStatus() != null && banner.getStatus() == 1 ? "启用" : "停用");
vo.setUpdatedAt(banner.getUpdatedAt());
return vo;
}).toList();
}
private Long parseId(String value) {
try {
return Long.valueOf(value);
} catch (NumberFormatException ex) {
return null;
}
}
}
@@ -0,0 +1,127 @@
package com.reelshort.content.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.reelshort.common.exception.ErrorCode;
import com.reelshort.common.exception.ServiceException;
import com.reelshort.content.dal.entity.Drama;
import com.reelshort.content.dal.entity.DramaCategory;
import com.reelshort.content.dal.mapper.DramaMapper;
import com.reelshort.content.dal.mapper.DramaCategoryMapper;
import com.reelshort.content.vo.DramaCategoryRespVO;
import com.reelshort.content.vo.DramaCategorySaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class DramaCategoryService {
private final DramaCategoryMapper dramaCategoryMapper;
private final DramaMapper dramaMapper;
public List<DramaCategoryRespVO> list() {
Map<Long, Integer> counts = countDramasByCategory();
return dramaCategoryMapper.selectList(
Wrappers.<DramaCategory>lambdaQuery().orderByAsc(DramaCategory::getSort))
.stream().map(category -> {
DramaCategoryRespVO vo = new DramaCategoryRespVO();
vo.setId(category.getId());
vo.setName(category.getName());
vo.setSort(category.getSort());
vo.setStatus(category.getStatus());
vo.setDramaCount(counts.getOrDefault(category.getId(), 0));
return vo;
}).toList();
}
public Long create(DramaCategorySaveReqVO req) {
ensureNameAvailable(req.getName(), null);
DramaCategory category = new DramaCategory();
category.setName(req.getName().trim());
Long count = dramaCategoryMapper.selectCount(null);
category.setSort(req.getSort() == null
? (count == null ? 0 : count.intValue()) + 1
: req.getSort());
category.setStatus(req.getStatus() == null ? 1 : req.getStatus());
dramaCategoryMapper.insert(category);
return category.getId();
}
/**
* 轻量列表:只查分类本身,不统计短剧数量。
* C 端展示和名称映射用它,避免为了一个名称把整张短剧表扫一遍。
*/
public List<DramaCategoryRespVO> listBasic() {
return dramaCategoryMapper.selectList(
Wrappers.<DramaCategory>lambdaQuery().orderByAsc(DramaCategory::getSort))
.stream().map(category -> {
DramaCategoryRespVO vo = new DramaCategoryRespVO();
vo.setId(category.getId());
vo.setName(category.getName());
vo.setSort(category.getSort());
vo.setStatus(category.getStatus());
return vo;
}).toList();
}
public void update(Long id, DramaCategorySaveReqVO req) {
DramaCategory category = getExisting(id);
ensureNameAvailable(req.getName(), id);
category.setName(req.getName().trim());
if (req.getSort() != null) {
category.setSort(req.getSort());
}
if (req.getStatus() != null) {
category.setStatus(req.getStatus());
}
dramaCategoryMapper.updateById(category);
}
public void delete(Long id) {
DramaCategory category = getExisting(id);
Integer used = countDramasByCategory().getOrDefault(id, 0);
if (used > 0) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"「" + category.getName() + "」下还有 " + used + " 部短剧,无法删除");
}
dramaCategoryMapper.deleteById(id);
}
private DramaCategory getExisting(Long id) {
DramaCategory category = id == null ? null : dramaCategoryMapper.selectById(id);
if (category == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "类型不存在");
}
return category;
}
private void ensureNameAvailable(String name, Long excludeId) {
Long count = dramaCategoryMapper.selectCount(Wrappers.<DramaCategory>lambdaQuery()
.eq(DramaCategory::getName, name.trim())
.ne(excludeId != null, DramaCategory::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "类型名称已存在");
}
}
private Map<Long, Integer> countDramasByCategory() {
Map<Long, Integer> counts = new HashMap<>();
dramaMapper.selectList(Wrappers.<Drama>lambdaQuery().select(Drama::getCategoryId))
.forEach(drama -> {
if (drama.getCategoryId() != null && drama.getCategoryId() > 0) {
counts.merge(drama.getCategoryId(), 1, Integer::sum);
}
});
return counts;
}
public Map<Long, String> nameMap() {
return dramaCategoryMapper.selectList(null).stream()
.collect(Collectors.toMap(DramaCategory::getId, DramaCategory::getName, (a, b) -> a));
}
}
@@ -0,0 +1,263 @@
package com.reelshort.content.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.reelshort.common.exception.ErrorCode;
import com.reelshort.common.exception.ServiceException;
import com.reelshort.common.pojo.PageResult;
import com.reelshort.content.dal.entity.Drama;
import com.reelshort.content.dal.entity.DramaEpisode;
import com.reelshort.content.dal.entity.DramaReviewRecord;
import com.reelshort.content.dal.mapper.DramaEpisodeMapper;
import com.reelshort.content.dal.mapper.DramaMapper;
import com.reelshort.content.dal.mapper.DramaReviewRecordMapper;
import com.reelshort.content.enums.ContentStatus;
import com.reelshort.content.vo.DramaPageReqVO;
import com.reelshort.content.vo.DramaRespVO;
import com.reelshort.content.vo.DramaSaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class DramaService {
private static final DateTimeFormatter CODE_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
private final DramaMapper dramaMapper;
private final DramaEpisodeMapper dramaEpisodeMapper;
private final DramaReviewRecordMapper dramaReviewRecordMapper;
private final DramaCategoryService dramaCategoryService;
public PageResult<DramaRespVO> page(DramaPageReqVO req) {
Page<Drama> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
IPage<Drama> result = dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery()
.like(StringUtils.hasText(req.getKeyword()), Drama::getTitle, req.getKeyword())
.eq(StringUtils.hasText(req.getStatus()), Drama::getStatus, req.getStatus())
.eq(req.getCategoryId() != null, Drama::getCategoryId, req.getCategoryId())
.eq(req.getIsHot() != null, Drama::getIsHot, Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0)
.orderByDesc(Drama::getSort)
.orderByDesc(Drama::getId));
return new PageResult<>(result.getTotal(), toRespList(result.getRecords()));
}
public DramaRespVO get(Long id) {
return toRespList(List.of(getExisting(id))).get(0);
}
public Drama getExisting(Long id) {
Drama drama = id == null ? null : dramaMapper.selectById(id);
if (drama == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "短剧不存在");
}
return drama;
}
@Transactional(rollbackFor = Exception.class)
public Long create(DramaSaveReqVO req, Long operatorId) {
Drama drama = new Drama();
apply(drama, req);
drama.setCode(generateCode());
drama.setStatus(ContentStatus.DRAFT.getCode());
drama.setTotalEpisodes(0);
drama.setViewCount(0L);
drama.setPlayCount(0L);
drama.setFavoriteCount(0L);
drama.setLikeCount(0L);
drama.setCreateBy(operatorId == null ? 0L : operatorId);
dramaMapper.insert(drama);
return drama.getId();
}
@Transactional(rollbackFor = Exception.class)
public void update(Long id, DramaSaveReqVO req) {
Drama drama = getExisting(id);
apply(drama, req);
dramaMapper.updateById(drama);
}
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
Drama drama = getExisting(id);
if (ContentStatus.PUBLISHED.getCode().equals(drama.getStatus())) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "已上线的短剧需先下线再删除");
}
dramaMapper.deleteById(id);
dramaEpisodeMapper.delete(Wrappers.<DramaEpisode>lambdaQuery().eq(DramaEpisode::getDramaId, id));
}
/** 审核与发布的状态流转。 */
@Transactional(rollbackFor = Exception.class)
public void changeStatus(Long id, String action, String reason, Long operatorId, String operatorName) {
Drama drama = getExisting(id);
String current = drama.getStatus();
String target = switch (action) {
case "SUBMIT" -> {
requireState(current, "SUBMIT", Set.of(ContentStatus.DRAFT.getCode(), ContentStatus.OFFLINE.getCode()));
yield ContentStatus.REVIEWING.getCode();
}
case "APPROVE" -> {
requireState(current, "APPROVE", Set.of(ContentStatus.REVIEWING.getCode()));
yield ContentStatus.APPROVED.getCode();
}
case "REJECT" -> {
requireState(current, "REJECT", Set.of(ContentStatus.REVIEWING.getCode()));
yield ContentStatus.DRAFT.getCode();
}
case "PUBLISH" -> {
requireState(current, "PUBLISH", Set.of(ContentStatus.APPROVED.getCode(),
ContentStatus.OFFLINE.getCode()));
yield ContentStatus.PUBLISHED.getCode();
}
case "OFFLINE" -> {
requireState(current, "OFFLINE", Set.of(ContentStatus.PUBLISHED.getCode()));
yield ContentStatus.OFFLINE.getCode();
}
default -> throw new ServiceException(ErrorCode.BAD_REQUEST, "未知的审核动作: " + action);
};
drama.setStatus(target);
if (ContentStatus.PUBLISHED.getCode().equals(target)) {
drama.setPublishedAt(LocalDateTime.now());
}
dramaMapper.updateById(drama);
DramaReviewRecord record = new DramaReviewRecord();
record.setBizType("DRAMA");
record.setBizId(id);
record.setAction(action);
record.setReason(reason == null ? "" : reason);
record.setOperatorId(operatorId == null ? 0L : operatorId);
record.setOperatorName(operatorName == null ? "" : operatorName);
dramaReviewRecordMapper.insert(record);
}
public List<DramaReviewRecord> listReviewRecords(Long dramaId) {
return dramaReviewRecordMapper.selectList(Wrappers.<DramaReviewRecord>lambdaQuery()
.eq(DramaReviewRecord::getBizType, "DRAMA")
.eq(DramaReviewRecord::getBizId, dramaId)
.orderByDesc(DramaReviewRecord::getCreatedAt));
}
// ========== C 端只读视图:只暴露已上线内容 ==========
/** 已上线短剧,按运营排序 + 播放量 */
public List<Drama> listPublished(Long categoryId, long limit) {
Page<Drama> page = new Page<>(1, Math.max(1, limit));
return dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery()
.eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode())
.eq(categoryId != null && categoryId > 0, Drama::getCategoryId, categoryId)
.orderByDesc(Drama::getSort)
.orderByDesc(Drama::getViewCount)).getRecords();
}
/** 已上线短剧,按上线时间倒序 */
public List<Drama> listPublishedLatest(long limit) {
Page<Drama> page = new Page<>(1, Math.max(1, limit));
return dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery()
.eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode())
.orderByDesc(Drama::getPublishedAt)
.orderByDesc(Drama::getId)).getRecords();
}
/** 已上线的热门短剧 */
public List<Drama> listPublishedHot(long limit) {
Page<Drama> page = new Page<>(1, Math.max(1, limit));
return dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery()
.eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode())
.eq(Drama::getIsHot, 1)
.orderByDesc(Drama::getSort)
.orderByDesc(Drama::getViewCount)).getRecords();
}
/** 已上线的短剧,未上线/已下线一律按不存在处理 */
public Drama getPublished(Long id) {
Drama drama = getExisting(id);
if (!ContentStatus.PUBLISHED.getCode().equals(drama.getStatus())) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "短剧不存在或已下线");
}
return drama;
}
/** 剧集数量变化后同步冗余字段。 */
public void syncTotalEpisodes(Long dramaId) {
Long count = dramaEpisodeMapper.selectCount(
Wrappers.<DramaEpisode>lambdaQuery().eq(DramaEpisode::getDramaId, dramaId));
Drama drama = dramaMapper.selectById(dramaId);
if (drama != null) {
drama.setTotalEpisodes(count == null ? 0 : count.intValue());
dramaMapper.updateById(drama);
}
}
private void requireState(String current, String action, Set<String> allowed) {
if (!allowed.contains(current)) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"当前状态(" + ContentStatus.textOf(current) + ")不允许执行该操作");
}
}
private void apply(Drama drama, DramaSaveReqVO req) {
drama.setTitle(req.getTitle());
drama.setSubtitle(req.getSubtitle() == null ? "" : req.getSubtitle());
drama.setCategoryId(req.getCategoryId() == null ? 0L : req.getCategoryId());
drama.setGenre(req.getGenre() == null ? "" : req.getGenre());
drama.setSynopsis(req.getDescription() == null ? "" : req.getDescription());
drama.setCoverUrl(req.getCover() == null ? "" : req.getCover());
drama.setHorizontalCoverUrl(drama.getHorizontalCoverUrl() == null ? "" : drama.getHorizontalCoverUrl());
drama.setLanguage(req.getLanguage() == null ? "zh-CN" : req.getLanguage());
drama.setFreeEpisodes(req.getFreeEpisodes() == null ? 3 : req.getFreeEpisodes());
drama.setUnlockCoin(req.getUnlockCoin() == null ? 0 : req.getUnlockCoin());
drama.setIsHot(Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0);
drama.setSort(req.getSort() == null ? 0 : req.getSort());
}
private String generateCode() {
Long count = dramaMapper.selectCount(null);
return "D" + LocalDate.now().format(CODE_DATE)
+ String.format("%03d", (count == null ? 0 : count + 1));
}
private List<DramaRespVO> toRespList(List<Drama> dramas) {
if (dramas.isEmpty()) {
return List.of();
}
Map<Long, String> categories = dramaCategoryService.nameMap();
return dramas.stream().map(drama -> {
DramaRespVO vo = new DramaRespVO();
vo.setId(drama.getId());
vo.setCode(drama.getCode());
vo.setTitle(drama.getTitle());
vo.setSubtitle(drama.getSubtitle());
vo.setCategoryId(drama.getCategoryId());
vo.setCategory(categories.getOrDefault(drama.getCategoryId(), ""));
vo.setGenre(drama.getGenre());
vo.setDescription(drama.getSynopsis());
vo.setCover(drama.getCoverUrl());
vo.setLanguage(drama.getLanguage());
vo.setEpisodes(drama.getTotalEpisodes());
vo.setFreeEpisodes(drama.getFreeEpisodes());
vo.setUnlockCoin(drama.getUnlockCoin());
vo.setIsHot(drama.getIsHot() != null && drama.getIsHot() == 1);
vo.setStatus(drama.getStatus());
vo.setStatusText(ContentStatus.textOf(drama.getStatus()));
vo.setViews(drama.getViewCount());
vo.setSort(drama.getSort());
vo.setPublishAt(drama.getPublishedAt());
vo.setCreatedAt(drama.getCreatedAt());
vo.setUpdatedAt(drama.getUpdatedAt());
return vo;
}).toList();
}
}
@@ -0,0 +1,180 @@
package com.reelshort.content.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.reelshort.common.exception.ErrorCode;
import com.reelshort.common.exception.ServiceException;
import com.reelshort.content.dal.entity.DramaEpisode;
import com.reelshort.content.dal.mapper.DramaEpisodeMapper;
import com.reelshort.content.enums.ContentStatus;
import com.reelshort.content.vo.EpisodeRespVO;
import com.reelshort.content.vo.EpisodeSaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class EpisodeService {
private final DramaEpisodeMapper dramaEpisodeMapper;
private final DramaService dramaService;
public List<EpisodeRespVO> listByDrama(Long dramaId, String status) {
dramaService.getExisting(dramaId);
return dramaEpisodeMapper.selectList(Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getDramaId, dramaId)
.eq(status != null && !status.isBlank(), DramaEpisode::getStatus, status)
.orderByAsc(DramaEpisode::getSort)
.orderByAsc(DramaEpisode::getEpNo))
.stream().map(this::toResp).toList();
}
/** C 端:只返回已上线剧集,按播放顺序 */
public List<DramaEpisode> listPublishedByDrama(Long dramaId) {
return dramaEpisodeMapper.selectList(Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getDramaId, dramaId)
.eq(DramaEpisode::getStatus, ContentStatus.PUBLISHED.getCode())
.orderByAsc(DramaEpisode::getSort)
.orderByAsc(DramaEpisode::getEpNo));
}
/**
* C 端:批量取每部剧的首个可播免费集,用于列表卡片直接起播。
* 整屏卡片共用一次 IN 查询,避免逐条查库。
*/
public Map<Long, DramaEpisode> firstPlayableByDramas(List<Long> dramaIds) {
if (dramaIds == null || dramaIds.isEmpty()) {
return Map.of();
}
List<DramaEpisode> episodes = dramaEpisodeMapper.selectList(
Wrappers.<DramaEpisode>lambdaQuery()
.in(DramaEpisode::getDramaId, dramaIds)
.eq(DramaEpisode::getStatus, ContentStatus.PUBLISHED.getCode())
.eq(DramaEpisode::getIsFree, 1)
.isNotNull(DramaEpisode::getVideoUrl)
.ne(DramaEpisode::getVideoUrl, "")
.orderByAsc(DramaEpisode::getDramaId)
.orderByAsc(DramaEpisode::getSort)
.orderByAsc(DramaEpisode::getEpNo));
Map<Long, DramaEpisode> firstPlayable = new LinkedHashMap<>();
for (DramaEpisode episode : episodes) {
firstPlayable.putIfAbsent(episode.getDramaId(), episode);
}
return firstPlayable;
}
/** C 端:已上线剧集,否则按不存在处理 */
public DramaEpisode getPublished(Long id) {
DramaEpisode episode = getExisting(id);
if (!ContentStatus.PUBLISHED.getCode().equals(episode.getStatus())) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "剧集不存在或未上线");
}
return episode;
}
public DramaEpisode getExisting(Long id) {
DramaEpisode episode = id == null ? null : dramaEpisodeMapper.selectById(id);
if (episode == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "剧集不存在");
}
return episode;
}
@Transactional(rollbackFor = Exception.class)
public Long create(EpisodeSaveReqVO req) {
dramaService.getExisting(req.getDramaId());
ensureEpNoAvailable(req.getDramaId(), req.getNumber(), null);
DramaEpisode episode = new DramaEpisode();
apply(episode, req);
episode.setStatus(req.getStatus() == null ? ContentStatus.DRAFT.getCode() : req.getStatus());
episode.setPlayCount(0L);
episode.setVideoAssetId(0L);
dramaEpisodeMapper.insert(episode);
dramaService.syncTotalEpisodes(req.getDramaId());
return episode.getId();
}
@Transactional(rollbackFor = Exception.class)
public void update(Long id, EpisodeSaveReqVO req) {
DramaEpisode episode = getExisting(id);
dramaService.getExisting(req.getDramaId());
ensureEpNoAvailable(req.getDramaId(), req.getNumber(), id);
apply(episode, req);
if (req.getStatus() != null) {
episode.setStatus(req.getStatus());
}
dramaEpisodeMapper.updateById(episode);
if (!req.getDramaId().equals(episode.getDramaId())) {
dramaService.syncTotalEpisodes(req.getDramaId());
}
dramaService.syncTotalEpisodes(episode.getDramaId());
}
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
DramaEpisode episode = getExisting(id);
dramaEpisodeMapper.deleteById(id);
dramaService.syncTotalEpisodes(episode.getDramaId());
}
@Transactional(rollbackFor = Exception.class)
public void updateSort(List<Long> ids) {
for (int index = 0; index < ids.size(); index++) {
DramaEpisode episode = dramaEpisodeMapper.selectById(ids.get(index));
if (episode != null) {
episode.setSort(index + 1);
dramaEpisodeMapper.updateById(episode);
}
}
}
private void ensureEpNoAvailable(Long dramaId, Integer epNo, Long excludeId) {
Long count = dramaEpisodeMapper.selectCount(Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getDramaId, dramaId)
.eq(DramaEpisode::getEpNo, epNo)
.ne(excludeId != null, DramaEpisode::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "第 " + epNo + " 集已存在");
}
}
private void apply(DramaEpisode episode, EpisodeSaveReqVO req) {
episode.setDramaId(req.getDramaId());
episode.setEpNo(req.getNumber());
episode.setTitle(req.getTitle() == null ? "" : req.getTitle());
episode.setDurationSec(req.getDurationSec() == null ? 0 : req.getDurationSec());
episode.setCoverUrl(req.getCover() == null ? "" : req.getCover());
episode.setVideoUrl(req.getVideoUrl() == null ? "" : req.getVideoUrl());
episode.setIsFree(Boolean.TRUE.equals(req.getIsFree()) ? 1 : 0);
episode.setUnlockCoin(req.getUnlockCoin() == null ? 0 : req.getUnlockCoin());
episode.setSort(req.getSort() == null ? (req.getNumber() == null ? 0 : req.getNumber()) : req.getSort());
}
private EpisodeRespVO toResp(DramaEpisode episode) {
EpisodeRespVO vo = new EpisodeRespVO();
vo.setId(episode.getId());
vo.setDramaId(episode.getDramaId());
vo.setNumber(episode.getEpNo());
vo.setTitle(episode.getTitle());
vo.setDurationSec(episode.getDurationSec());
vo.setDuration(formatDuration(episode.getDurationSec()));
vo.setCover(episode.getCoverUrl());
vo.setVideoUrl(episode.getVideoUrl());
vo.setIsFree(episode.getIsFree() != null && episode.getIsFree() == 1);
vo.setUnlockCoin(episode.getUnlockCoin());
vo.setStatus(episode.getStatus());
vo.setStatusText(ContentStatus.textOf(episode.getStatus()));
vo.setViews(episode.getPlayCount());
vo.setSort(episode.getSort());
return vo;
}
private String formatDuration(Integer seconds) {
int value = seconds == null ? 0 : seconds;
return String.format("%02d:%02d", value / 60, value % 60);
}
}
@@ -0,0 +1,26 @@
package com.reelshort.content.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class BannerRespVO {
private Long id;
private String imageUrl;
private String position;
private String positionText;
private String linkType;
private String linkTypeText;
private String linkValue;
/** 跳转目标的展示文案,如短剧名称 */
private String linkText;
private Integer sort;
private Integer status;
private String statusText;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updatedAt;
}
@@ -0,0 +1,22 @@
package com.reelshort.content.vo;
import lombok.Data;
@Data
public class BannerSaveReqVO {
@jakarta.validation.constraints.NotBlank(message = "请上传轮播图片")
private String imageUrl;
/** HOME_TOP / DISCOVER */
private String position;
/** NONE / DRAMA / URL */
private String linkType;
/** 剧集 ID 或跳转地址 */
private String linkValue;
private Integer sort;
private Integer status;
}
@@ -0,0 +1,14 @@
package com.reelshort.content.vo;
import lombok.Data;
@Data
public class DramaCategoryRespVO {
private Long id;
private String name;
private Integer sort;
private Integer status;
/** 该类型下的短剧数量 */
private Integer dramaCount;
}
@@ -0,0 +1,14 @@
package com.reelshort.content.vo;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class DramaCategorySaveReqVO {
@NotBlank(message = "请填写类型名称")
private String name;
private Integer sort;
private Integer status;
}
@@ -0,0 +1,17 @@
package com.reelshort.content.vo;
import com.reelshort.common.pojo.PageParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class DramaPageReqVO extends PageParam {
/** 标题关键字 */
private String keyword;
private String status;
private Long categoryId;
/** 是否只看热门 */
private Boolean isHot;
}
@@ -0,0 +1,41 @@
package com.reelshort.content.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 短剧列表/详情响应,字段名与后台页面表格列一致。
*/
@Data
public class DramaRespVO {
private Long id;
private String code;
private String title;
private String subtitle;
private Long categoryId;
private String category;
private String genre;
private String description;
private String cover;
private String language;
private Integer episodes;
private Integer freeEpisodes;
private Integer unlockCoin;
private Boolean isHot;
private String status;
private String statusText;
private Long views;
private Integer sort;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime publishAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updatedAt;
}
@@ -0,0 +1,23 @@
package com.reelshort.content.vo;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class DramaSaveReqVO {
@NotBlank(message = "请填写短剧名称")
private String title;
private String subtitle;
private Long categoryId;
private String genre;
private String description;
private String cover;
private String language;
private Integer freeEpisodes;
private Integer unlockCoin;
/** 是否热门 */
private Boolean isHot;
private Integer sort;
}
@@ -0,0 +1,10 @@
package com.reelshort.content.vo;
import lombok.Data;
@Data
public class DramaStatusReqVO {
/** 驳回原因,其他动作可空 */
private String reason;
}
@@ -0,0 +1,23 @@
package com.reelshort.content.vo;
import lombok.Data;
@Data
public class EpisodeRespVO {
private Long id;
private Long dramaId;
private Integer number;
private String title;
/** 展示用时长,mm:ss */
private String duration;
private Integer durationSec;
private String cover;
private String videoUrl;
private Boolean isFree;
private Integer unlockCoin;
private String status;
private String statusText;
private Long views;
private Integer sort;
}
@@ -0,0 +1,23 @@
package com.reelshort.content.vo;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class EpisodeSaveReqVO {
@NotNull(message = "请选择所属短剧")
private Long dramaId;
@NotNull(message = "请填写集号")
private Integer number;
private String title;
private Integer durationSec;
private String cover;
private String videoUrl;
private Boolean isFree;
private Integer unlockCoin;
private String status;
private Integer sort;
}
@@ -0,0 +1,13 @@
package com.reelshort.content.vo;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.List;
@Data
public class EpisodeSortReqVO {
@NotEmpty(message = "请提供排序后的剧集 ID 列表")
private List<Long> ids;
}
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.reelshort</groupId>
<artifactId>reelshort</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>reelshort-module-media</artifactId>
<name>reelshort-module-media</name>
<description>媒资域:分片上传、本地存储、访问地址</description>
<dependencies>
<dependency>
<groupId>com.reelshort</groupId>
<artifactId>reelshort-framework</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,23 @@
package com.reelshort.media.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "reelshort.media")
public class MediaProperties {
/** 本地存储根目录,可以是相对路径(相对应用启动目录)。 */
private String rootDir = "./data/uploads";
/** 对外访问前缀,与静态资源映射保持一致。 */
private String publicPrefix = "/files";
/** 分片大小(字节),默认 5MB。 */
private long chunkSize = 5L * 1024 * 1024;
/** 单个文件大小上限(字节),默认 2GB。 */
private long maxFileSize = 2L * 1024 * 1024 * 1024;
}
@@ -0,0 +1,38 @@
package com.reelshort.media.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.Files;
import java.io.IOException;
/**
* 把本地媒资目录映射成静态资源,前端直接用 /files/** 访问。
*/
@Configuration
@RequiredArgsConstructor
public class MediaWebConfig implements WebMvcConfigurer {
private final MediaProperties properties;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
Path root = Paths.get(properties.getRootDir()).toAbsolutePath().normalize();
// Spring 6 会跳过启动时还不存在的静态资源目录,这里先确保目录存在
try {
Files.createDirectories(root);
} catch (IOException ex) {
throw new IllegalStateException("无法创建媒资目录: " + root, ex);
}
String prefix = properties.getPublicPrefix();
if (!prefix.endsWith("/")) {
prefix = prefix + "/";
}
registry.addResourceHandler(prefix + "**")
.addResourceLocations(root.toUri().toString());
}
}
@@ -0,0 +1,39 @@
package com.reelshort.media.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("media_asset")
public class MediaAsset {
@TableId(type = IdType.AUTO)
private Long id;
private String bizType;
private String fileName;
private String objectKey;
private String sourceUrl;
private Long sizeBytes;
private String md5;
private Integer durationSec;
private Integer width;
private Integer height;
private String status;
private String hlsUrl;
private String mp4Url;
private String posterUrl;
private Long createBy;
private LocalDateTime createdAt;
/** updated_at 交给数据库的 ON UPDATE CURRENT_TIMESTAMP 维护 */
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,33 @@
package com.reelshort.media.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("media_upload_task")
public class MediaUploadTask {
@TableId(type = IdType.AUTO)
private Long id;
private String uploadId;
private String bizType;
private String fileName;
private Long fileSize;
private Integer chunkSize;
private Integer totalChunks;
private Integer receivedChunks;
private String objectKey;
private String status;
private Long assetId;
private Long createBy;
private LocalDateTime createdAt;
/** updated_at 交给数据库的 ON UPDATE CURRENT_TIMESTAMP 维护 */
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
}
@@ -0,0 +1,7 @@
package com.reelshort.media.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.reelshort.media.dal.entity.MediaAsset;
public interface MediaAssetMapper extends BaseMapper<MediaAsset> {
}
@@ -0,0 +1,7 @@
package com.reelshort.media.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.reelshort.media.dal.entity.MediaUploadTask;
public interface MediaUploadTaskMapper extends BaseMapper<MediaUploadTask> {
}
@@ -0,0 +1,169 @@
package com.reelshort.media.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.reelshort.common.exception.ErrorCode;
import com.reelshort.common.exception.ServiceException;
import com.reelshort.media.config.MediaProperties;
import com.reelshort.media.dal.entity.MediaAsset;
import com.reelshort.media.dal.entity.MediaUploadTask;
import com.reelshort.media.dal.mapper.MediaAssetMapper;
import com.reelshort.media.dal.mapper.MediaUploadTaskMapper;
import com.reelshort.media.vo.MediaAssetRespVO;
import com.reelshort.media.vo.UploadCompleteReqVO;
import com.reelshort.media.vo.UploadInitReqVO;
import com.reelshort.media.vo.UploadInitRespVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.util.Set;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class MediaService {
private static final String STATUS_READY = "ready";
private static final String STATUS_UPLOADED = "uploaded";
private static final String TASK_UPLOADING = "uploading";
private static final String TASK_COMPLETED = "completed";
private static final Set<String> IMAGE_EXTENSIONS =
Set.of(".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".avif");
private final MediaProperties properties;
private final MediaStorageService storageService;
private final MediaAssetMapper mediaAssetMapper;
private final MediaUploadTaskMapper mediaUploadTaskMapper;
public UploadInitRespVO init(UploadInitReqVO req, Long userId) {
if (req.getFileSize() > properties.getMaxFileSize()) {
throw new ServiceException(ErrorCode.BAD_REQUEST,
"文件超过上限 " + (properties.getMaxFileSize() / 1024 / 1024) + "MB");
}
long chunkSize = req.getChunkSize() != null && req.getChunkSize() > 0
? Math.min(req.getChunkSize(), properties.getChunkSize())
: properties.getChunkSize();
int totalChunks = (int) Math.ceil((double) req.getFileSize() / chunkSize);
MediaUploadTask task = new MediaUploadTask();
task.setUploadId(UUID.randomUUID().toString().replace("-", ""));
task.setBizType(normalizeBizType(req.getBizType()));
task.setFileName(req.getFileName());
task.setFileSize(req.getFileSize());
task.setChunkSize((int) chunkSize);
task.setTotalChunks(totalChunks);
task.setReceivedChunks(0);
task.setObjectKey(storageService.buildObjectKey(task.getBizType(), req.getFileName()));
task.setStatus(TASK_UPLOADING);
task.setAssetId(0L);
task.setCreateBy(userId == null ? 0L : userId);
mediaUploadTaskMapper.insert(task);
return new UploadInitRespVO(task.getUploadId(), chunkSize, totalChunks, task.getObjectKey());
}
public void saveChunk(String uploadId, int index, MultipartFile file) {
MediaUploadTask task = getTask(uploadId);
if (index < 0 || index >= task.getTotalChunks()) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "分片序号越界: " + index);
}
if (file == null || file.isEmpty()) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "分片内容为空");
}
storageService.saveChunk(uploadId, index, file);
task.setReceivedChunks(storageService.countChunks(uploadId));
mediaUploadTaskMapper.updateById(task);
}
@Transactional(rollbackFor = Exception.class)
public MediaAssetRespVO complete(UploadCompleteReqVO req) {
MediaUploadTask task = getTask(req.getUploadId());
if (TASK_COMPLETED.equals(task.getStatus()) && task.getAssetId() > 0) {
return get(task.getAssetId());
}
int chunks = storageService.countChunks(req.getUploadId());
if (chunks < task.getTotalChunks()) {
throw new ServiceException(ErrorCode.BAD_REQUEST,
"分片未传完(" + chunks + "/" + task.getTotalChunks() + ")");
}
MediaStorageService.MergeResult merged =
storageService.merge(req.getUploadId(), task.getObjectKey(), task.getTotalChunks());
String publicUrl = storageService.toPublicUrl(task.getObjectKey());
MediaAsset asset = new MediaAsset();
asset.setBizType(task.getBizType());
asset.setFileName(task.getFileName());
asset.setObjectKey(task.getObjectKey());
asset.setSourceUrl(publicUrl);
asset.setSizeBytes(merged.size());
asset.setMd5(merged.md5());
asset.setDurationSec(req.getDurationSec() == null ? 0 : req.getDurationSec());
asset.setWidth(0);
asset.setHeight(0);
asset.setStatus(isImage(task.getFileName()) ? STATUS_READY : STATUS_UPLOADED);
asset.setHlsUrl("");
// 没有转码前,mp4_url 直接指向原文件,播放器可以先跑通
asset.setMp4Url(publicUrl);
asset.setPosterUrl("");
asset.setCreateBy(task.getCreateBy());
mediaAssetMapper.insert(asset);
task.setStatus(TASK_COMPLETED);
task.setAssetId(asset.getId());
task.setReceivedChunks(chunks);
mediaUploadTaskMapper.updateById(task);
storageService.cleanChunkDir(req.getUploadId());
// 重新查一次,拿到数据库生成的 created_at
return toResp(mediaAssetMapper.selectById(asset.getId()));
}
public MediaAssetRespVO get(Long id) {
MediaAsset asset = id == null ? null : mediaAssetMapper.selectById(id);
if (asset == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "媒资不存在");
}
return toResp(asset);
}
private MediaUploadTask getTask(String uploadId) {
MediaUploadTask task = mediaUploadTaskMapper.selectOne(
Wrappers.<MediaUploadTask>lambdaQuery().eq(MediaUploadTask::getUploadId, uploadId));
if (task == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "上传会话不存在或已过期");
}
return task;
}
private String normalizeBizType(String bizType) {
if (bizType == null || bizType.isBlank()) {
return "EPISODE";
}
return bizType.trim().toUpperCase();
}
private boolean isImage(String fileName) {
if (fileName == null) {
return false;
}
String lower = fileName.toLowerCase();
return IMAGE_EXTENSIONS.stream().anyMatch(lower::endsWith);
}
private MediaAssetRespVO toResp(MediaAsset asset) {
MediaAssetRespVO vo = new MediaAssetRespVO();
vo.setId(asset.getId());
vo.setBizType(asset.getBizType());
vo.setFileName(asset.getFileName());
vo.setObjectKey(asset.getObjectKey());
vo.setUrl(asset.getSourceUrl());
vo.setSizeBytes(asset.getSizeBytes());
vo.setMd5(asset.getMd5());
vo.setDurationSec(asset.getDurationSec());
vo.setStatus(asset.getStatus());
vo.setCreatedAt(asset.getCreatedAt());
return vo;
}
}
@@ -0,0 +1,173 @@
package com.reelshort.media.service;
import com.reelshort.common.exception.ErrorCode;
import com.reelshort.common.exception.ServiceException;
import com.reelshort.media.config.MediaProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.UUID;
/**
* 本地磁盘媒资存储:分片写临时目录,完成后合并成正式文件。
* 之后要换成对象存储时,只需替换本类实现。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MediaStorageService {
private static final DateTimeFormatter MONTH_DIR = DateTimeFormatter.ofPattern("yyyyMM");
private final MediaProperties properties;
public record MergeResult(Path path, long size, String md5) {
}
public Path rootDir() {
Path root = Paths.get(properties.getRootDir()).toAbsolutePath().normalize();
try {
Files.createDirectories(root);
Files.createDirectories(root.resolve("tmp"));
} catch (IOException ex) {
throw new ServiceException("无法创建媒资目录: " + root);
}
return root;
}
public Path chunkDir(String uploadId) {
return rootDir().resolve("tmp").resolve(uploadId);
}
public void saveChunk(String uploadId, int index, MultipartFile file) {
Path dir = chunkDir(uploadId);
try {
Files.createDirectories(dir);
Path target = dir.resolve(index + ".part");
try (InputStream in = file.getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException ex) {
throw new ServiceException("写入分片失败: " + index);
}
}
public int countChunks(String uploadId) {
Path dir = chunkDir(uploadId);
if (!Files.isDirectory(dir)) {
return 0;
}
try (var stream = Files.list(dir)) {
return (int) stream.filter(path -> path.getFileName().toString().endsWith(".part")).count();
} catch (IOException ex) {
return 0;
}
}
/** 按分片序号顺序合并,同时计算 MD5。 */
public MergeResult merge(String uploadId, String objectKey, int totalChunks) {
Path target = rootDir().resolve(objectKey).normalize();
if (!target.startsWith(rootDir())) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "非法的存储路径");
}
MessageDigest digest = digest();
long size = 0;
try {
Files.createDirectories(target.getParent());
try (OutputStream fileOut = Files.newOutputStream(target,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
DigestOutputStream out = new DigestOutputStream(fileOut, digest)) {
for (int index = 0; index < totalChunks; index++) {
Path part = chunkDir(uploadId).resolve(index + ".part");
if (!Files.exists(part)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "分片 " + index + " 缺失");
}
size += Files.copy(part, out);
}
}
} catch (ServiceException ex) {
throw ex;
} catch (IOException ex) {
throw new ServiceException("合并分片失败");
}
return new MergeResult(target, size, HexFormat.of().formatHex(digest.digest()));
}
public void cleanChunkDir(String uploadId) {
Path dir = chunkDir(uploadId);
if (!Files.isDirectory(dir)) {
return;
}
try (var stream = Files.walk(dir)) {
stream.sorted((a, b) -> b.getNameCount() - a.getNameCount())
.forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
// 临时文件清理失败不影响主流程
}
});
} catch (IOException ex) {
log.warn("清理分片目录失败: {}", dir, ex);
}
}
public void delete(String objectKey) {
try {
Files.deleteIfExists(rootDir().resolve(objectKey).normalize());
} catch (IOException ex) {
log.warn("删除媒资文件失败: {}", objectKey, ex);
}
}
/** 生成对象键:bizType/yyyyMM/uuid.ext */
public String buildObjectKey(String bizType, String originalName) {
String type = (bizType == null || bizType.isBlank() ? "misc" : bizType).toLowerCase();
return type + "/" + LocalDate.now().format(MONTH_DIR) + "/"
+ UUID.randomUUID().toString().replace("-", "") + extension(originalName);
}
public String toPublicUrl(String objectKey) {
String prefix = properties.getPublicPrefix();
if (prefix.endsWith("/")) {
prefix = prefix.substring(0, prefix.length() - 1);
}
return prefix + "/" + objectKey;
}
private String extension(String fileName) {
if (fileName == null) {
return "";
}
int dot = fileName.lastIndexOf('.');
if (dot < 0 || dot == fileName.length() - 1) {
return "";
}
String ext = fileName.substring(dot).toLowerCase();
return ext.length() > 12 ? "" : ext;
}
private MessageDigest digest() {
try {
return MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException ex) {
throw new ServiceException("当前 JVM 不支持 MD5");
}
}
}
@@ -0,0 +1,23 @@
package com.reelshort.media.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class MediaAssetRespVO {
private Long id;
private String bizType;
private String fileName;
private String objectKey;
private String url;
private Long sizeBytes;
private String md5;
private Integer durationSec;
private String status;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
}
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff. Show more