feat: 初始化短剧服务端(管理后台 API + 客户端 API)
Spring Boot 3.2 / MyBatis-Plus / PostgreSQL,7 个 Maven 模块;含认证鉴权、内容与媒资管理、操作日志,以及客户端 /app/** 接口。
This commit is contained in:
commit
a4aa749cc6
152 files changed
+8027
No files matched your search
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# 构建产物
|
||||||
|
target/
|
||||||
|
|
||||||
|
# IDE / 编辑器
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# 系统文件
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# 运行期上传的媒资(图片、视频,几百 MB)不入库,只保留目录
|
||||||
|
data/uploads/*
|
||||||
|
!data/uploads/.gitkeep
|
||||||
@@ -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 的注解处理器配置保证这种情况下也能编译通过。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+38
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>com.reelshort</groupId>
|
||||||
|
<artifactId>reelshort</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>reelshort-framework</artifactId>
|
||||||
|
<name>reelshort-framework</name>
|
||||||
|
<description>技术框架:安全、鉴权、持久层、Web 配置</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.reelshort</groupId>
|
||||||
|
<artifactId>reelshort-common</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-aop</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.baomidou</groupId>
|
||||||
|
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.reelshort.framework.config;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.DbType;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class MybatisPlusConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||||
|
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||||
|
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL));
|
||||||
|
return interceptor;
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
package com.reelshort.framework.log;
|
||||||
|
|
||||||
|
import com.reelshort.framework.security.LoginUser;
|
||||||
|
import com.reelshort.framework.security.SecurityUtils;
|
||||||
|
import com.reelshort.framework.web.WebUtils;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 越权访问留痕。
|
||||||
|
* 方法级鉴权(@PreAuthorize)抛出的异常走全局异常处理,过滤器级的走安全入口,
|
||||||
|
* 两处都调用这里,避免逻辑重复。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AccessDeniedRecorder {
|
||||||
|
|
||||||
|
private final ObjectProvider<OperationLogSink> sinkProvider;
|
||||||
|
|
||||||
|
public void record(HttpServletRequest request) {
|
||||||
|
OperationLogSink sink = sinkProvider.getIfAvailable();
|
||||||
|
if (sink == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LoginUser loginUser = null;
|
||||||
|
try {
|
||||||
|
loginUser = SecurityUtils.getLoginUser();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 未登录时没有登录态
|
||||||
|
}
|
||||||
|
sink.save(OperationLogEntry.builder()
|
||||||
|
.userId(loginUser == null ? 0L : loginUser.userId())
|
||||||
|
.username(loginUser == null ? "" : loginUser.username())
|
||||||
|
.module("安全")
|
||||||
|
.action("越权访问被拒绝")
|
||||||
|
.requestMethod(request == null ? "" : request.getMethod())
|
||||||
|
.requestUrl(request == null ? "" : request.getRequestURI())
|
||||||
|
.requestParams(request == null || request.getQueryString() == null
|
||||||
|
? "" : request.getQueryString())
|
||||||
|
.responseBody("{\"code\":403,\"message\":\"无权限\"}")
|
||||||
|
.status("FAIL")
|
||||||
|
.errorMsg("无权限")
|
||||||
|
.durationMs(0)
|
||||||
|
.ip(request == null ? "" : WebUtils.getClientIp(request))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从当前线程取出请求,供全局异常处理使用。 */
|
||||||
|
public void recordCurrentRequest() {
|
||||||
|
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) {
|
||||||
|
record(attributes.getRequest());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.reelshort.framework.log;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标注在需要留痕的接口方法上。
|
||||||
|
* 请求参数、请求体、响应体、操作人、耗时、IP 由 {@link OperationLogAspect} 自动采集。
|
||||||
|
*/
|
||||||
|
@Target(ElementType.METHOD)
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
public @interface OperationLog {
|
||||||
|
|
||||||
|
/** 所属模块,如「内容管理」 */
|
||||||
|
String module() default "";
|
||||||
|
|
||||||
|
/** 动作名称,如「新增轮播图」 */
|
||||||
|
String action() default "";
|
||||||
|
}
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
package com.reelshort.framework.log;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.reelshort.framework.security.LoginUser;
|
||||||
|
import com.reelshort.framework.security.SecurityUtils;
|
||||||
|
import com.reelshort.framework.web.WebUtils;
|
||||||
|
import jakarta.servlet.ServletRequest;
|
||||||
|
import jakarta.servlet.ServletResponse;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.aspectj.lang.ProceedingJoinPoint;
|
||||||
|
import org.aspectj.lang.annotation.Around;
|
||||||
|
import org.aspectj.lang.annotation.Aspect;
|
||||||
|
import org.aspectj.lang.reflect.MethodSignature;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.validation.BindingResult;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动记录后台写操作:谁、什么时候、调了哪个接口、传了什么、返回了什么、耗时多少。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Aspect
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class OperationLogAspect {
|
||||||
|
|
||||||
|
private static final int MAX_TEXT = 4000;
|
||||||
|
private static final String TRUNCATED = "\n...(已截断)";
|
||||||
|
/** 密码、密钥、令牌等不落库 */
|
||||||
|
private static final Pattern SENSITIVE_FIELDS = Pattern.compile(
|
||||||
|
"(?i)(\"(?:password|oldPassword|newPassword|confirmPassword|secret|token|accessToken|refreshToken)\"\\s*:\\s*)\"[^\"]*\"");
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final ObjectProvider<OperationLogSink> sinkProvider;
|
||||||
|
|
||||||
|
@Around("@annotation(operationLog)")
|
||||||
|
public Object around(ProceedingJoinPoint point, OperationLog operationLog) throws Throwable {
|
||||||
|
long started = System.currentTimeMillis();
|
||||||
|
Object result = null;
|
||||||
|
Throwable error = null;
|
||||||
|
try {
|
||||||
|
result = point.proceed();
|
||||||
|
return result;
|
||||||
|
} catch (Throwable ex) {
|
||||||
|
error = ex;
|
||||||
|
throw ex;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
save(point, operationLog, result, error, (int) (System.currentTimeMillis() - started));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("写操作日志失败: {}", operationLog.action(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void save(ProceedingJoinPoint point, OperationLog annotation, Object result,
|
||||||
|
Throwable error, int durationMs) {
|
||||||
|
OperationLogSink sink = sinkProvider.getIfAvailable();
|
||||||
|
if (sink == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
HttpServletRequest request = currentRequest();
|
||||||
|
LoginUser loginUser = currentUser();
|
||||||
|
|
||||||
|
sink.save(OperationLogEntry.builder()
|
||||||
|
.userId(loginUser == null ? 0L : loginUser.userId())
|
||||||
|
.username(loginUser == null ? "" : loginUser.username())
|
||||||
|
.module(annotation.module())
|
||||||
|
.action(annotation.action())
|
||||||
|
.requestMethod(request == null ? "" : request.getMethod())
|
||||||
|
.requestUrl(request == null ? "" : request.getRequestURI())
|
||||||
|
.requestParams(request == null ? "" : truncate(request.getQueryString()))
|
||||||
|
.requestBody(body(point))
|
||||||
|
.responseBody(error != null ? "" : json(result))
|
||||||
|
.status(error == null ? "SUCCESS" : "FAIL")
|
||||||
|
.errorMsg(error == null ? "" : truncate(error.getMessage()))
|
||||||
|
.durationMs(durationMs)
|
||||||
|
.ip(request == null ? "" : WebUtils.getClientIp(request))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把方法入参整理成 JSON,文件只记名称和大小。 */
|
||||||
|
private String body(ProceedingJoinPoint point) {
|
||||||
|
Object[] args = point.getArgs();
|
||||||
|
String[] names = ((MethodSignature) point.getSignature()).getParameterNames();
|
||||||
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
|
for (int index = 0; index < args.length; index++) {
|
||||||
|
Object arg = args[index];
|
||||||
|
if (arg == null || arg instanceof ServletRequest || arg instanceof ServletResponse
|
||||||
|
|| arg instanceof BindingResult) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String name = names != null && index < names.length ? names[index] : "arg" + index;
|
||||||
|
if (arg instanceof MultipartFile file) {
|
||||||
|
payload.put(name, "%s (%d bytes)".formatted(file.getOriginalFilename(), file.getSize()));
|
||||||
|
} else {
|
||||||
|
payload.put(name, arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (payload.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
// 只有一个入参时直接序列化该参数,日志更易读
|
||||||
|
return payload.size() == 1 ? json(payload.values().iterator().next()) : json(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String json(Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String text = objectMapper.writeValueAsString(value);
|
||||||
|
text = SENSITIVE_FIELDS.matcher(text).replaceAll("$1\"***\"");
|
||||||
|
return truncate(text);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return String.valueOf(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String truncate(String text) {
|
||||||
|
if (text == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return text.length() <= MAX_TEXT ? text : text.substring(0, MAX_TEXT) + TRUNCATED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpServletRequest currentRequest() {
|
||||||
|
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) {
|
||||||
|
return attributes.getRequest();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LoginUser currentUser() {
|
||||||
|
try {
|
||||||
|
return SecurityUtils.getLoginUser();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
// 登录等公开接口没有登录态
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.reelshort.framework.log;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/** 一条待落库的操作记录。 */
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class OperationLogEntry {
|
||||||
|
|
||||||
|
private Long userId;
|
||||||
|
private String username;
|
||||||
|
private String module;
|
||||||
|
private String action;
|
||||||
|
private String requestMethod;
|
||||||
|
private String requestUrl;
|
||||||
|
private String requestParams;
|
||||||
|
private String requestBody;
|
||||||
|
private String responseBody;
|
||||||
|
/** SUCCESS / FAIL */
|
||||||
|
private String status;
|
||||||
|
private String errorMsg;
|
||||||
|
private Integer durationMs;
|
||||||
|
private String ip;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.reelshort.framework.log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作日志落库的扩展点,由业务模块实现(框架层不依赖具体存储)。
|
||||||
|
*/
|
||||||
|
public interface OperationLogSink {
|
||||||
|
|
||||||
|
void save(OperationLogEntry entry);
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package com.reelshort.framework.security;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 Authorization: Bearer <token>,写入 SecurityContext。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final String HEADER = "Authorization";
|
||||||
|
private static final String PREFIX = "Bearer ";
|
||||||
|
|
||||||
|
private final JwtTokenProvider tokenProvider;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
String header = request.getHeader(HEADER);
|
||||||
|
if (StringUtils.hasText(header) && header.startsWith(PREFIX)) {
|
||||||
|
String token = header.substring(PREFIX.length()).trim();
|
||||||
|
try {
|
||||||
|
LoginUser loginUser = tokenProvider.parseAdminToken(token);
|
||||||
|
List<SimpleGrantedAuthority> authorities = loginUser.permissions() == null
|
||||||
|
? List.of()
|
||||||
|
: loginUser.permissions().stream().map(SimpleGrantedAuthority::new).toList();
|
||||||
|
UsernamePasswordAuthenticationToken authentication =
|
||||||
|
new UsernamePasswordAuthenticationToken(loginUser, null, authorities);
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// token 无效时不抛出,交给后续的鉴权入口统一返回 401
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.reelshort.framework.security;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "reelshort.jwt")
|
||||||
|
public class JwtProperties {
|
||||||
|
|
||||||
|
/** 管理端签名密钥,长度必须 >= 32 字节。生产环境通过环境变量注入。 */
|
||||||
|
private String adminSecret = "reelshort-admin-dev-secret-change-me-in-production-32b";
|
||||||
|
|
||||||
|
/** 管理端 token 有效期(小时)。 */
|
||||||
|
private int adminTtlHours = 12;
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
package com.reelshort.framework.security;
|
||||||
|
|
||||||
|
import com.reelshort.common.exception.ErrorCode;
|
||||||
|
import com.reelshort.common.exception.ServiceException;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class JwtTokenProvider {
|
||||||
|
|
||||||
|
private static final String CLAIM_USER_ID = "userId";
|
||||||
|
private static final String CLAIM_ROLE_CODE = "roleCode";
|
||||||
|
private static final String CLAIM_PERMISSIONS = "permissions";
|
||||||
|
|
||||||
|
private final JwtProperties properties;
|
||||||
|
|
||||||
|
public String createAdminToken(LoginUser loginUser) {
|
||||||
|
Date now = new Date();
|
||||||
|
Date expiration = new Date(now.getTime() + Duration.ofHours(properties.getAdminTtlHours()).toMillis());
|
||||||
|
return Jwts.builder()
|
||||||
|
.subject(loginUser.username())
|
||||||
|
.claim(CLAIM_USER_ID, loginUser.userId())
|
||||||
|
.claim(CLAIM_ROLE_CODE, loginUser.roleCode())
|
||||||
|
.claim(CLAIM_PERMISSIONS, loginUser.permissions())
|
||||||
|
.issuedAt(now)
|
||||||
|
.expiration(expiration)
|
||||||
|
.signWith(adminKey())
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public LoginUser parseAdminToken(String token) {
|
||||||
|
try {
|
||||||
|
Claims claims = Jwts.parser()
|
||||||
|
.verifyWith(adminKey())
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload();
|
||||||
|
Number userId = claims.get(CLAIM_USER_ID, Number.class);
|
||||||
|
return new LoginUser(
|
||||||
|
userId == null ? null : userId.longValue(),
|
||||||
|
claims.getSubject(),
|
||||||
|
claims.get(CLAIM_ROLE_CODE, String.class),
|
||||||
|
(List<String>) claims.get(CLAIM_PERMISSIONS, List.class));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new ServiceException(ErrorCode.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SecretKey adminKey() {
|
||||||
|
byte[] bytes = properties.getAdminSecret().getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (bytes.length < 32) {
|
||||||
|
throw new IllegalStateException("reelshort.jwt.admin-secret 长度必须不少于 32 字节");
|
||||||
|
}
|
||||||
|
return Keys.hmacShaKeyFor(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.reelshort.framework.security;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录态载体,由 JWT 解析得到,随 SecurityContext 传递。
|
||||||
|
*/
|
||||||
|
public record LoginUser(Long userId, String username, String roleCode, List<String> permissions) {
|
||||||
|
|
||||||
|
public boolean hasPermission(String permission) {
|
||||||
|
return permissions != null && permissions.contains(permission);
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
package com.reelshort.framework.security;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 供 {@code @PreAuthorize("@ss.hasPermission('drama:create')")} 使用的权限判断。
|
||||||
|
* 优先按用户实时查询权限(角色的权限改了立即生效),查不到时退回 JWT 中的权限列表。
|
||||||
|
*/
|
||||||
|
@Component("ss")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PermissionChecker {
|
||||||
|
|
||||||
|
private final ObjectProvider<PermissionProvider> permissionProvider;
|
||||||
|
|
||||||
|
public boolean hasPermission(String permission) {
|
||||||
|
return permissions().contains(permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasAnyPermission(String... permissions) {
|
||||||
|
Set<String> granted = permissions();
|
||||||
|
for (String permission : permissions) {
|
||||||
|
if (granted.contains(permission)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> permissions() {
|
||||||
|
LoginUser loginUser = SecurityUtils.getLoginUser();
|
||||||
|
PermissionProvider provider = permissionProvider.getIfAvailable();
|
||||||
|
if (provider != null && loginUser.userId() != null) {
|
||||||
|
try {
|
||||||
|
List<String> codes = provider.listPermissionsByUserId(loginUser.userId());
|
||||||
|
if (codes != null) {
|
||||||
|
return new HashSet<>(codes);
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 查询失败时退回 JWT 中的权限,避免因为权限服务异常导致全部请求失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return loginUser.permissions() == null ? Set.of() : new HashSet<>(loginUser.permissions());
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package com.reelshort.framework.security;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限来源的扩展点:由业务模块实现,用于按用户实时查询权限码。
|
||||||
|
* 没有实现时,权限判断退回到 JWT 中携带的权限列表。
|
||||||
|
*/
|
||||||
|
public interface PermissionProvider {
|
||||||
|
|
||||||
|
List<String> listPermissionsByUserId(Long userId);
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package com.reelshort.framework.security;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.reelshort.common.exception.ErrorCode;
|
||||||
|
import com.reelshort.common.pojo.Result;
|
||||||
|
import com.reelshort.framework.log.AccessDeniedRecorder;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.security.core.AuthenticationException;
|
||||||
|
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||||
|
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未登录与无权限时,也返回统一响应体,保证前端拦截器能识别 401 并登出。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RestAuthEntryPoint implements AuthenticationEntryPoint, AccessDeniedHandler {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final AccessDeniedRecorder accessDeniedRecorder;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void commence(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
AuthenticationException authException) throws IOException {
|
||||||
|
write(response, Result.error(ErrorCode.UNAUTHORIZED));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
AccessDeniedException accessDeniedException) throws IOException {
|
||||||
|
accessDeniedRecorder.record(request);
|
||||||
|
write(response, Result.error(ErrorCode.FORBIDDEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void write(HttpServletResponse response, Result<?> result) throws IOException {
|
||||||
|
response.setStatus(HttpServletResponse.SC_OK);
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||||
|
response.getWriter().write(objectMapper.writeValueAsString(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
package com.reelshort.framework.security;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableMethodSecurity
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
private static final String[] PUBLIC_PATHS = {
|
||||||
|
"/admin/auth/login",
|
||||||
|
/* 客户端接口:浏览与播放暂不要求登录,登录体系(手机号/邮箱)在 C 端鉴权落地后收紧 */
|
||||||
|
"/app/**",
|
||||||
|
/* 本地媒资:图片与视频用 <img>/<video> 直接访问,无法带请求头 */
|
||||||
|
"/files/**",
|
||||||
|
"/doc.html",
|
||||||
|
"/swagger-ui/**",
|
||||||
|
"/swagger-ui.html",
|
||||||
|
"/v3/api-docs/**",
|
||||||
|
"/actuator/health"
|
||||||
|
};
|
||||||
|
|
||||||
|
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||||
|
private final RestAuthEntryPoint restAuthEntryPoint;
|
||||||
|
|
||||||
|
@Value("${reelshort.cors.allowed-origins:http://localhost:3005}")
|
||||||
|
private String allowedOrigins;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
http.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.authorizeHttpRequests(registry -> registry
|
||||||
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
|
.requestMatchers(PUBLIC_PATHS).permitAll()
|
||||||
|
.anyRequest().authenticated())
|
||||||
|
.exceptionHandling(handling -> handling
|
||||||
|
.authenticationEntryPoint(restAuthEntryPoint)
|
||||||
|
.accessDeniedHandler(restAuthEntryPoint))
|
||||||
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
configuration.setAllowedOrigins(Arrays.stream(allowedOrigins.split(",")).map(String::trim).toList());
|
||||||
|
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
|
||||||
|
configuration.setAllowedHeaders(List.of("*"));
|
||||||
|
configuration.setAllowCredentials(true);
|
||||||
|
configuration.setMaxAge(3600L);
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.reelshort.framework.security;
|
||||||
|
|
||||||
|
import com.reelshort.common.exception.ErrorCode;
|
||||||
|
import com.reelshort.common.exception.ServiceException;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
|
||||||
|
public final class SecurityUtils {
|
||||||
|
|
||||||
|
private SecurityUtils() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LoginUser getLoginUser() {
|
||||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (authentication != null && authentication.getPrincipal() instanceof LoginUser loginUser) {
|
||||||
|
return loginUser;
|
||||||
|
}
|
||||||
|
throw new ServiceException(ErrorCode.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Long getUserId() {
|
||||||
|
return getLoginUser().userId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getUsername() {
|
||||||
|
return getLoginUser().username();
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
package com.reelshort.framework.web;
|
||||||
|
|
||||||
|
import com.reelshort.common.exception.ErrorCode;
|
||||||
|
import com.reelshort.common.exception.ServiceException;
|
||||||
|
import com.reelshort.common.pojo.Result;
|
||||||
|
import com.reelshort.framework.log.AccessDeniedRecorder;
|
||||||
|
import jakarta.validation.ConstraintViolationException;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.validation.BindException;
|
||||||
|
import org.springframework.validation.FieldError;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||||
|
import org.apache.catalina.connector.ClientAbortException;
|
||||||
|
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局异常处理:所有异常都转成统一响应体,前端拦截器只需判断 code。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestControllerAdvice
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
private final AccessDeniedRecorder accessDeniedRecorder;
|
||||||
|
|
||||||
|
@ExceptionHandler(ServiceException.class)
|
||||||
|
public Result<Void> handleServiceException(ServiceException ex) {
|
||||||
|
log.warn("业务异常: code={}, message={}", ex.getCode(), ex.getMessage());
|
||||||
|
return Result.error(ex.getCode(), ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 方法级权限校验失败:统一返回 403,前端据此提示无权限。 */
|
||||||
|
@ExceptionHandler(AccessDeniedException.class)
|
||||||
|
public Result<Void> handleAccessDenied(AccessDeniedException ex) {
|
||||||
|
log.warn("无权限访问: {}", ex.getMessage());
|
||||||
|
accessDeniedRecorder.recordCurrentRequest();
|
||||||
|
return Result.error(ErrorCode.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler({MethodArgumentNotValidException.class, BindException.class})
|
||||||
|
public Result<Void> handleBindException(BindException ex) {
|
||||||
|
FieldError fieldError = ex.getBindingResult().getFieldError();
|
||||||
|
String message = fieldError == null ? ErrorCode.BAD_REQUEST.getMessage() : fieldError.getDefaultMessage();
|
||||||
|
return Result.error(ErrorCode.BAD_REQUEST.getCode(), message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ConstraintViolationException.class)
|
||||||
|
public Result<Void> handleConstraintViolation(ConstraintViolationException ex) {
|
||||||
|
return Result.error(ErrorCode.BAD_REQUEST.getCode(), ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler({MissingServletRequestParameterException.class, HttpMessageNotReadableException.class})
|
||||||
|
public Result<Void> handleBadRequest(Exception ex) {
|
||||||
|
return Result.error(ErrorCode.BAD_REQUEST.getCode(), ErrorCode.BAD_REQUEST.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 静态资源或未匹配的路径:按 404 返回,不要吞成 500。 */
|
||||||
|
@ExceptionHandler(NoResourceFoundException.class)
|
||||||
|
public Result<Void> handleNoResourceFound(NoResourceFoundException ex) {
|
||||||
|
log.warn("资源不存在: {}", ex.getResourcePath());
|
||||||
|
return Result.error(ErrorCode.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端中断(例如视频边下边播时用户划走、关闭页面)是正常现象,
|
||||||
|
* 不该记成系统异常,日志降级为 debug。
|
||||||
|
*/
|
||||||
|
@ExceptionHandler({ClientAbortException.class, AsyncRequestNotUsableException.class})
|
||||||
|
public void handleClientAbort(Exception ex) {
|
||||||
|
log.debug("客户端中断连接: {}", ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public Result<Void> handleException(Exception ex) {
|
||||||
|
log.error("系统异常", ex);
|
||||||
|
return Result.error(ErrorCode.INTERNAL_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.reelshort.framework.web;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 慢请求告警:客户端接口的响应时间目标是 50ms,超过阈值的请求打 WARN,
|
||||||
|
* 方便在内容量增长、索引失效或缓存缺失时第一时间发现。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class SlowRequestLogger extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final long SLOW_THRESHOLD_MS = 50;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
long started = System.currentTimeMillis();
|
||||||
|
try {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
} finally {
|
||||||
|
// 只关注客户端接口,管理端后台页面不做这个约束
|
||||||
|
if (request.getRequestURI().startsWith("/app/")) {
|
||||||
|
long cost = System.currentTimeMillis() - started;
|
||||||
|
if (cost > SLOW_THRESHOLD_MS) {
|
||||||
|
log.warn("慢请求 {} {} 耗时 {}ms(目标 {}ms)",
|
||||||
|
request.getMethod(), request.getRequestURI(), cost, SLOW_THRESHOLD_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.reelshort.framework.web;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
public final class WebUtils {
|
||||||
|
|
||||||
|
private static final String UNKNOWN = "unknown";
|
||||||
|
|
||||||
|
private WebUtils() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取真实客户端 IP,兼容 Nginx 反向代理。 */
|
||||||
|
public static String getClientIp(HttpServletRequest request) {
|
||||||
|
String[] headers = {"X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP"};
|
||||||
|
for (String header : headers) {
|
||||||
|
String value = request.getHeader(header);
|
||||||
|
if (StringUtils.hasText(value) && !UNKNOWN.equalsIgnoreCase(value)) {
|
||||||
|
int comma = value.indexOf(',');
|
||||||
|
return comma > 0 ? value.substring(0, comma).trim() : value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return request.getRemoteAddr();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
+36
@@ -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;
|
||||||
|
}
|
||||||
+17
@@ -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;
|
||||||
|
}
|
||||||
+38
@@ -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;
|
||||||
|
}
|
||||||
+23
@@ -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;
|
||||||
|
}
|
||||||
+7
@@ -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> {
|
||||||
|
}
|
||||||
+7
@@ -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> {
|
||||||
|
}
|
||||||
+7
@@ -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> {
|
||||||
|
}
|
||||||
+7
@@ -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> {
|
||||||
|
}
|
||||||
+7
@@ -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> {
|
||||||
|
}
|
||||||
+36
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+191
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+263
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+180
@@ -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;
|
||||||
|
}
|
||||||
+14
@@ -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;
|
||||||
|
}
|
||||||
+14
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+33
@@ -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;
|
||||||
|
}
|
||||||
+7
@@ -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> {
|
||||||
|
}
|
||||||
+7
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+173
@@ -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
Reference in new issue
Block a user