refactor: 后端标识统一为 FruitShorts,并落地 05 离线下载
为什么: 1) 产品已定名 FruitShorts,但后端仍是 reelshort 系列标识 —— Java 包 com.reelshort、 模块名与 jar 名 reelshort-*、spring.application.name、配置前缀 reelshort.*, 这些会出现在启动日志、命令行和 IDE 里,与产品名不一致。本次整体替换为 fruitshorts, 数据库连接名上一轮已改名,保持不变。 2) 第一阶段下一批第 1 步(05 离线下载)要求把下载授权(设备绑定/有效期/吊销)、 下载任务与断点续传、配额与过期清理、仅 Wi-Fi 规则做成完整接口,本提交一并落地。 3) 顺带修复:schema-phase1.sql 里 playback_ticket / order_event 仍按普通表定义唯一索引, 表分区化之后重跑脚本必定报错(分区表的唯一索引必须包含分区键)。改为在本脚本里就 按分区表定义,并验证脚本可重复执行。
This commit is contained in:
1 parent
a4aa749cc6
commit
b3179fc836
251 files changed
+7261
-767
No files matched your search
@@ -1,7 +1,14 @@
|
||||
# ReelShort 短剧服务(后端)
|
||||
# FruitShorts 短剧服务(后端)
|
||||
|
||||
短剧服务的服务端,Spring Boot + PostgreSQL,目前实现管理后台接口(`/admin/**`)。
|
||||
完整架构设计与迭代计划见 [docs/architecture-design.md](docs/architecture-design.md)。
|
||||
短剧服务的服务端,Spring Boot + PostgreSQL,对外两个入口:
|
||||
管理后台 `/admin/**`(8080)与客户端 `/app/**`(8081)。
|
||||
|
||||
| 文档 | 内容 |
|
||||
| --- | --- |
|
||||
| [docs/prd-phase1.md](docs/prd-phase1.md) | 第一阶段 PRD:范围、验收口径、里程碑 |
|
||||
| [docs/backend-design-phase1.md](docs/backend-design-phase1.md) | 后端设计:1 万并发观看的容量推导、成本、部署 |
|
||||
| [docs/api-phase1.md](docs/api-phase1.md) | **接口清单:后台管理 + 客户端,标注已实现 / 下一批** |
|
||||
| [docs/architecture-design.md](docs/architecture-design.md) | 早期架构设计文档 |
|
||||
|
||||
| 项 | 版本 |
|
||||
| --- | --- |
|
||||
@@ -13,14 +20,18 @@
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
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/ 架构设计文档
|
||||
FruitServer/
|
||||
├── fruitshorts-common/ 统一响应、分页、错误码、业务异常
|
||||
├── fruitshorts-framework/ JWT 鉴权(管理端/客户端双密钥)、权限校验、全局异常、分区维护、MyBatis-Plus、CORS
|
||||
├── fruitshorts-module-system/ 管理员、角色、权限、菜单、角色分类、配置、操作日志
|
||||
├── fruitshorts-module-content/ 短剧、剧集、题材、轮播、审核发布
|
||||
├── fruitshorts-module-media/ 分片上传、媒资、播放地址签名、转码任务与多码率产物
|
||||
├── fruitshorts-module-user/ C 端账号:验证码、注册登录、会话、资料、注销、风控
|
||||
├── fruitshorts-module-playback/ 播放票据、观看进度与流水、收藏、下载授权
|
||||
├── fruitshorts-admin-api/ 启动模块与 /admin/** 控制器(8080)
|
||||
├── fruitshorts-app-api/ 启动模块与 /app/** 控制器(8081)
|
||||
├── sql/ 建表、初始化数据、分区维护(见下)
|
||||
└── docs/ PRD、后端设计、接口清单
|
||||
```
|
||||
|
||||
## 开发环境
|
||||
@@ -56,28 +67,40 @@ mvn -v # Maven 3.10.0 / Java 21.0.12.1
|
||||
|
||||
| 服务 | 模块 | 端口 | 面向 |
|
||||
| --- | --- | --- | --- |
|
||||
| 管理端 | `reelshort-admin-api` | 8080 | Vue 后台 |
|
||||
| 客户端 | `reelshort-app-api` | 8081 | Flutter 客户端 |
|
||||
| 管理端 | `fruitshorts-admin-api` | 8080 | Vue 后台 |
|
||||
| 客户端 | `fruitshorts-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
|
||||
psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/schema.sql
|
||||
psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/data.sql
|
||||
psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/schema-phase1.sql
|
||||
psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/partition-maintenance.sql
|
||||
psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/data-phase1.sql
|
||||
```
|
||||
|
||||
数据库名 `reelshort`,角色 `reelshort`(密码 `reelshort`),两个脚本都可重复执行。
|
||||
| 脚本 | 作用 |
|
||||
| --- | --- |
|
||||
| `schema.sql` / `data.sql` | 系统域与内容域的基础表与初始化数据 |
|
||||
| `schema-phase1.sql` | 第一阶段增量表:用户、播放记录、互动、交易、创作者、转码、运营 |
|
||||
| `partition-maintenance.sql` | 分区函数 + 把高写入表转成分区 + 预建未来 3 个月分区 |
|
||||
| `data-phase1.sql` | 新增权限点、超管授权、占位商品 |
|
||||
|
||||
四个脚本都幂等可重复执行,**顺序不要颠倒**:分区转换要求目标表是空的。
|
||||
|
||||
数据库名 `fruitshorts`,角色 `fruitshorts`(密码 `fruitshorts`)。
|
||||
首次搭建环境时先建角色与库:
|
||||
|
||||
```sh
|
||||
psql -d postgres -c "CREATE ROLE reelshort LOGIN PASSWORD 'reelshort';"
|
||||
psql -d postgres -c "CREATE DATABASE reelshort OWNER reelshort ENCODING 'UTF8';"
|
||||
psql -d postgres -c "CREATE ROLE fruitshorts LOGIN PASSWORD 'fruitshorts';"
|
||||
psql -d postgres -c "CREATE DATABASE fruitshorts OWNER fruitshorts ENCODING 'UTF8';"
|
||||
```
|
||||
|
||||
连接信息在
|
||||
`reelshort-admin-api/src/main/resources/application.yml`,可用环境变量
|
||||
`fruitshorts-admin-api/src/main/resources/application.yml`,可用环境变量
|
||||
`DB_USER` / `DB_PASSWORD` / `JWT_ADMIN_SECRET` 覆盖。
|
||||
|
||||
### 关于 PostgreSQL 的两点说明
|
||||
@@ -89,6 +112,41 @@ psql -d postgres -c "CREATE DATABASE reelshort OWNER reelshort ENCODING 'UTF8';"
|
||||
- 轮播图的展示位置列名叫 `slot`(`position` 在 PostgreSQL 里是关键字),
|
||||
实体用 `@TableField("slot")` 映射,对外接口字段仍是 `position`,前端无需改动。
|
||||
|
||||
### 大表分区与保留策略
|
||||
|
||||
**为什么要一开始就分区**:`watch_history`(观看流水)、`playback_ticket`(播放票据)、
|
||||
`order_event`(订单流水)都是「每次行为写一行」的表。按 1 万并发观看估算,观看流水约
|
||||
300 万行/天、播放票据约 360 万行/天,一年就是十亿级。这类表后补分区的代价很高
|
||||
(要在维护窗口里整表重建),所以第一阶段就按 `watched_at` / `created_at` 做按月声明式分区。
|
||||
|
||||
**光建表没用**:声明式分区不会自动长分区,只建 default 分区的话数据会全堆进 default,
|
||||
分区等于白做。所以有两层保障:
|
||||
|
||||
1. `sql/partition-maintenance.sql` 里的 `ensure_monthly_partitions(table, from, months)`
|
||||
负责按月建分区,执行脚本时会建好当前月 + 未来 3 个月;
|
||||
2. `fruitshorts-framework` 的 `PartitionMaintainer` 在**启动时**和**每天 03:17** 各调一次,
|
||||
跨月时即使服务停机一周也不会漏建。
|
||||
|
||||
验证分区是否真的生效(应落在 `watch_history_2026xx` 而不是 `_default`):
|
||||
|
||||
```sh
|
||||
psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -c \
|
||||
"select tableoid::regclass as 分区, count(*) from watch_history group by 1;"
|
||||
```
|
||||
|
||||
**保留策略默认只建不删**。删数据不可逆,第一阶段先解决"写不爆",等保留期与合规确认后
|
||||
再把 `fruitshorts.partition.retention-enabled` 打开;各表保留月数写在 `PartitionMaintainer`
|
||||
的 `TABLES` 常量里(观看流水 24 个月、播放票据 6 个月、订单流水 36 个月)。手工清理:
|
||||
|
||||
```sql
|
||||
SELECT drop_partitions_before('watch_history', CURRENT_DATE - INTERVAL '24 months');
|
||||
```
|
||||
|
||||
分区表有两条 PostgreSQL 限制要注意:唯一索引必须包含分区键,所以 `playback_ticket`
|
||||
的唯一索引是 `(ticket_no, created_at)`、主键是 `(id, created_at)`。
|
||||
`coin_ledger` 故意**不分区** —— 它靠 `idempotent_key` 全局唯一保证不重复发币,
|
||||
这个唯一性比分区更重要。
|
||||
|
||||
### 2. 构建
|
||||
|
||||
```sh
|
||||
@@ -100,12 +158,46 @@ mvn -DskipTests package
|
||||
|
||||
```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
|
||||
java -jar fruitshorts-admin-api/target/fruitshorts-admin-api-1.0.0-SNAPSHOT.jar
|
||||
java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar
|
||||
```
|
||||
|
||||
启动后:服务地址 `http://localhost:8080`,接口文档 `http://localhost:8080/doc.html`。
|
||||
|
||||
## 关键配置
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `fruitshorts.jwt.admin-secret` | 开发默认值 | 管理端签名密钥,**生产必须用 `JWT_ADMIN_SECRET` 注入** |
|
||||
| `fruitshorts.jwt.app-secret` | 开发默认值 | 客户端签名密钥,与管理端物理隔离,两边 token 不能互换 |
|
||||
| `fruitshorts.jwt.app-access-ttl-minutes` | 120 | 客户端 access token 有效期 |
|
||||
| `fruitshorts.jwt.app-refresh-ttl-days` | 30 | refresh token 有效期,每次刷新轮换 |
|
||||
| `fruitshorts.media.signature-secret` | 开发默认值 | 播放地址签名密钥,生产必须注入 |
|
||||
| `fruitshorts.media.signature-ttl-minutes` | 15 | 播放地址有效期 |
|
||||
| `fruitshorts.media.signature-required` | `false` | **生产必须打开**;打开后 `/files/episode/**` 无有效签名返回 403 |
|
||||
| `fruitshorts.otp.sender` | `log` | 验证码通道,开发打日志;接入短信/邮件时实现 `OtpSender` 并改这里 |
|
||||
| `fruitshorts.partition.enabled` | `true` | 是否自动维护分区 |
|
||||
| `fruitshorts.partition.months-ahead` | 3 | 预建未来几个月分区 |
|
||||
| `fruitshorts.partition.retention-enabled` | `false` | 是否自动清理过期分区(默认只建不删) |
|
||||
|
||||
开发阶段签名校验默认关闭,是为了不打断正在联调的客户端。**上线前必须打开**,
|
||||
否则片源地址可以被任意复用与盗链。
|
||||
|
||||
## 第一阶段实现进度
|
||||
|
||||
| 模块 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 01 基础与交付准备 | 已实现 | 多模块骨架、接口契约、双密钥鉴权、分区维护 |
|
||||
| 02 用户账号与登录 | 已实现 | 手机号/邮箱注册登录、验证码反刷、令牌轮换、资料、注销、封禁即时生效 |
|
||||
| 03 内容发现与详情 | 已实现 | 首页/发现/分类/详情;补内容分级、可播区域、版权窗口、标签字段 |
|
||||
| 04 播放器与播放控制 | 部分 | 播放票据 + 签名地址 + 进度已实现;多码率片源待转码 worker |
|
||||
| 05 记录、收藏与下载 | 部分 | 进度、观看流水、收藏已实现;离线下载授权待做 |
|
||||
| 06 评论、点赞与分享 | 待做 | 表已建,接口与实现待补 |
|
||||
| 07 创作者中心 | 待做 | 表已建;转码任务与多码率产物记账已实现 |
|
||||
| 08 平台运营后台 | 部分 | App 用户管理已实现;内容治理、订单、看板待做 |
|
||||
| 09 分集收费与支付 | 待做 | 表已建;播放入口的权益判定端口已预留,接上即生效 |
|
||||
| 10 质量、数据与上线 | 待做 | 压测、埋点、对账待做 |
|
||||
|
||||
## 默认账号
|
||||
|
||||
密码均为 `admin123`。
|
||||
@@ -168,9 +260,9 @@ java -jar reelshort-app-api/target/reelshort-app-api-1.0.0-SNAPSHOT.jar
|
||||
|
||||
| 项 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 存储目录 | `./data/uploads` | 配置项 `reelshort.media.root-dir`,按 `业务类型/年月/uuid.ext` 分目录 |
|
||||
| 访问前缀 | `/files/**` | 配置项 `reelshort.media.public-prefix`,映射为静态资源 |
|
||||
| 分片大小 | 5MB | `reelshort.media.chunk-size`,单文件上限 2GB |
|
||||
| 存储目录 | `./data/uploads` | 配置项 `fruitshorts.media.root-dir`,按 `业务类型/年月/uuid.ext` 分目录 |
|
||||
| 访问前缀 | `/files/**` | 配置项 `fruitshorts.media.public-prefix`,映射为静态资源 |
|
||||
| 分片大小 | 5MB | `fruitshorts.media.chunk-size`,单文件上限 2GB |
|
||||
| 请求上限 | 20MB | `spring.servlet.multipart`,只需容纳一个分片 |
|
||||
|
||||
要点:
|
||||
@@ -188,27 +280,45 @@ java -jar reelshort-app-api/target/reelshort-app-api-1.0.0-SNAPSHOT.jar
|
||||
- 鉴权头 `Authorization: Bearer <token>`;未登录 `code=401`,无权限 `code=403`。
|
||||
- 错误码见架构文档附录 A;业务错误如 `100000` 账号密码错误、`200003` 状态不允许该操作。
|
||||
|
||||
## 权限模型
|
||||
|
||||
## 客户端接口 `/app/**`
|
||||
|
||||
面向 Flutter 客户端,只暴露已上线内容,不含审核状态、操作日志等管理端字段。
|
||||
**完整接口清单(含请求/响应示例与错误码)见 [docs/api-phase1.md](docs/api-phase1.md)。**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| POST | `/app/auth/otp` | 发送验证码(REGISTER/LOGIN/RESET_PWD/BIND) |
|
||||
| POST | `/app/auth/register` | 手机号/邮箱注册,返回令牌 |
|
||||
| POST | `/app/auth/login` | 验证码或密码登录 |
|
||||
| POST | `/app/auth/refresh` | 刷新令牌(refresh token 每次轮换) |
|
||||
| POST | `/app/auth/logout` | 登出,`allDevices=true` 吊销全部会话 |
|
||||
| GET/PUT | `/app/me` | 我的资料 |
|
||||
| GET | `/app/me/devices` | 登录设备 |
|
||||
| GET | `/app/me/export` | 数据导出(隐私合规) |
|
||||
| POST | `/app/me/deactivate` `/cancel` | 申请/撤销注销(15 天冷静期) |
|
||||
| GET | `/app/home/feed` | 首页聚合:轮播(HOME_TOP)+ 分区(热门 / 新剧 / 各分类) |
|
||||
| GET | `/app/discover/feed` | 发现页聚合:轮播(DISCOVER)+ 分类分区 |
|
||||
| GET | `/app/categories` | 分类列表 |
|
||||
| GET | `/app/dramas/{id}` | 详情,含剧集列表,一次请求即可渲染详情页 |
|
||||
| GET | `/app/episodes/{id}/play` | 播放票据 |
|
||||
| GET | `/app/episodes/{id}/play` | 播放票据:服务端判定权益后签发**短时效签名地址** |
|
||||
| POST | `/app/watch/progress` | 上报观看进度(建议每 10s 一次) |
|
||||
| GET | `/app/watch/progress?dramaId=` | 某剧续看位置 |
|
||||
| GET | `/app/watch/recent` | 最近观看 |
|
||||
| DELETE | `/app/watch/progress[/{dramaId}]` | 删除 / 清空记录 |
|
||||
| POST | `/app/favorites/toggle` | 收藏切换 |
|
||||
| GET | `/app/favorites` | 收藏列表(卡片结构与首页一致) |
|
||||
|
||||
约定:
|
||||
|
||||
- 只返回 `status = published` 的短剧与剧集,未上线按不存在处理。
|
||||
- 图片与视频返回相对路径(`/files/...`),客户端拼上服务地址即可。
|
||||
- 播放票据三种结果:`FREE`(返回 `playUrl`)、`COIN_REQUIRED`(返回 `unlockCoin`)、
|
||||
`NO_MEDIA`(已上线但还没传视频)。
|
||||
- `/app/**` 目前在安全配置里放行(浏览无需登录);手机号/邮箱登录与权益体系接入后再收紧。
|
||||
- 图片与视频返回相对路径(`/files/...`),客户端拼上服务地址即可;
|
||||
播放地址会带 `?e=&s=` 签名参数,客户端**原样使用、不要自己拼接**。
|
||||
- 播放票据四种结果:`FREE`、`ENTITLED`(已购权益)、`COIN_REQUIRED`(返回 `unlockCoin`)、
|
||||
`NO_MEDIA`(已上线但还没传视频或未转码完成)。
|
||||
- 鉴权边界:只有浏览类接口(`/app/auth/**`、`/app/home|discover|categories|dramas`、
|
||||
`/app/episodes/*/play`)允许匿名,其余 `/app/**` 一律需要客户端 token;
|
||||
管理端 token 打到客户端接口会被拒(两边密钥不同)。
|
||||
- 需要登录的接口建议带 `X-Device-Id`,用于下载授权、并发播放限制与风控。
|
||||
|
||||
性能(本机实测,预热后 30 次采样,目标 50ms):
|
||||
|
||||
@@ -285,8 +395,13 @@ public Result<Long> create(@Valid @RequestBody DramaCategorySaveReqVO req) { ...
|
||||
|
||||
## 尚未实现
|
||||
|
||||
视频转码(HLS 多码率)、C 端接口(`/app/**`)、金币与会员、单集解锁、支付与对账、
|
||||
数据看板统计、Redis 缓存与限流。详见架构设计文档第 11 章。
|
||||
评论点赞分享、订单与钱包权益(IAP / Google Play Billing 票据校验、对账、退款回收)、
|
||||
创作者中心、内容治理与经营看板、转码 worker(消费 `transcode_task`,产出多码率 HLS)、
|
||||
Redis 缓存与限流、离线下载授权、埋点与数据看板。
|
||||
|
||||
这些模块的表结构、接口契约、状态机与权限点都已经冻结,见
|
||||
[docs/api-phase1.md](docs/api-phase1.md) 里标注「下一批」的部分 —— 接下来是照契约填代码,
|
||||
不会再有结构性改动。
|
||||
|
||||
## 常见问题
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
# 第一阶段接口清单(后台管理 + 客户端)
|
||||
|
||||
> 版本 v1.0 · 配套 [PRD](./prd-phase1.md) 与 [后端设计](./backend-design-phase1.md)
|
||||
> 状态说明:**已实现** = 代码已落地可联调;**下一批** = 表结构与契约已冻结,代码待补。
|
||||
|
||||
## 0. 通用约定
|
||||
|
||||
**服务入口**:后台管理 `fruitshorts-admin-api` 只暴露 `/admin/**`(8080);
|
||||
客户端 `fruitshorts-app-api` 只暴露 `/app/**` 与媒资 `/files/**`(8081)。
|
||||
|
||||
**统一响应体**
|
||||
|
||||
```json
|
||||
{ "code": 0, "message": "ok", "data": {} }
|
||||
```
|
||||
|
||||
`code = 0` 成功;非 0 时 `message` 可直接展示给用户。分页统一为 `{ total, list }`。
|
||||
|
||||
**鉴权**:`Authorization: Bearer <token>`。
|
||||
管理端与客户端用**两套独立密钥**签发,管理端 token 打到客户端接口会直接被拒。
|
||||
客户端 `access token` 默认 2 小时,`refresh token` 30 天且每次刷新轮换。
|
||||
|
||||
**设备标识**:客户端建议所有请求都带 `X-Device-Id`,用于下载授权、并发播放限制与风控。
|
||||
|
||||
**主要错误码**
|
||||
|
||||
| code | 含义 | 客户端处理 |
|
||||
| --- | --- | --- |
|
||||
| 0 | 成功 | — |
|
||||
| 400 | 参数校验失败 | 提示 message |
|
||||
| 401 | 未登录或登录过期 | 用 refresh token 换新 token,失败则跳登录 |
|
||||
| 403 | 无权限 | 提示无权限 |
|
||||
| 429 | 请求过于频繁 | 提示稍后再试(验证码频控走这里) |
|
||||
| 100000 | 账号或密码错误 | 提示 |
|
||||
| 100001 | 账号已停用 | 展示封禁申诉入口 |
|
||||
| 100002 | 金币余额不足 | 拉起充值 |
|
||||
| 100003 | 剧集未发布或不存在 | 返回上一页 |
|
||||
| 100005 | 订单已支付或已关闭 | 刷新订单状态 |
|
||||
| 200002 | 数据不存在 | 提示 |
|
||||
| 200003 | 当前状态不允许该操作 | 提示 |
|
||||
|
||||
## 1. 客户端接口(/app/**)
|
||||
|
||||
### 1.1 账号(已实现)
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/app/auth/otp` | 匿名 | 发送验证码。`scene` = REGISTER/LOGIN/RESET_PWD/BIND |
|
||||
| POST | `/app/auth/register` | 匿名 | 手机号/邮箱注册,返回令牌 |
|
||||
| POST | `/app/auth/login` | 匿名 | 验证码或密码登录 |
|
||||
| POST | `/app/auth/refresh` | 匿名 | 用 refresh token 换新令牌并轮换 |
|
||||
| POST | `/app/auth/logout` | 可选 | 登出;`allDevices=true` 吊销全部会话 |
|
||||
|
||||
发送验证码:
|
||||
|
||||
```json
|
||||
POST /app/auth/otp
|
||||
{ "scene": "REGISTER", "targetType": "PHONE", "target": "08123456789", "deviceId": "d-1" }
|
||||
```
|
||||
|
||||
手机号会自动规范化成 E.164(印尼本地 `08xx` → `+62xxx`)。频控:同号 60 秒一次、每日 10 次,
|
||||
同 IP 每小时 20 次;连续输错 5 次该验证码作废。
|
||||
|
||||
注册:
|
||||
|
||||
```json
|
||||
POST /app/auth/register
|
||||
{
|
||||
"targetType": "PHONE", "target": "08123456789", "code": "123456",
|
||||
"password": "可选,设置后可用密码登录",
|
||||
"nickname": "可选",
|
||||
"deviceId": "d-1", "platform": "IOS", "appVersion": "1.0.0",
|
||||
"agreementVersion": "2026-10-01", "marketingConsent": false
|
||||
}
|
||||
```
|
||||
|
||||
响应(登录、刷新同结构):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0, "message": "ok",
|
||||
"data": {
|
||||
"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "tokenType": "Bearer",
|
||||
"newUser": true,
|
||||
"user": { "uid": "U8KQ2ZP1", "nickname": "User8KQ2ZP", "status": "normal",
|
||||
"countryCode": "ID", "language": "id-ID", "currency": "IDR",
|
||||
"maskedTarget": "+628****789", "vipLevel": 0, "vipExpireAt": null }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 个人中心(已实现)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/app/me` | 当前用户资料 |
|
||||
| PUT | `/app/me` | 修改昵称/头像/性别/生日/语言/时区/区域 |
|
||||
| GET | `/app/me/devices` | 登录设备列表 |
|
||||
| GET | `/app/me/export` | 数据导出(商店与隐私法规要求) |
|
||||
| POST | `/app/me/deactivate` | 申请注销,进入 15 天冷静期并立即登出全部设备 |
|
||||
| POST | `/app/me/deactivate/cancel` | 冷静期内撤销注销 |
|
||||
|
||||
### 1.3 内容浏览(已实现)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/app/home/feed` | 首页:轮播 + 分区,卡片带 `preview` 可播预览集 |
|
||||
| GET | `/app/discover/feed` | 发现页分区 |
|
||||
| GET | `/app/categories` | 分类列表 |
|
||||
| GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) |
|
||||
|
||||
### 1.4 播放(已实现,链路已重做)
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/app/episodes/{id}/play` | 可选 | 签发播放票据与签名地址 |
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0, "message": "ok",
|
||||
"data": {
|
||||
"episodeId": 12, "dramaId": 5, "number": 1, "title": "第 1 集", "durationSec": 180,
|
||||
"accessible": true,
|
||||
"accessType": "FREE",
|
||||
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...",
|
||||
"qualities": ["AUTO"],
|
||||
"unlockCoin": 0,
|
||||
"previewSeconds": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`accessType` 取值:`FREE`(免费集)、`ENTITLED`(已购权益)、`COIN_REQUIRED`(需解锁,
|
||||
此时 `playUrl` 为空、`unlockCoin` 有值)、`NO_MEDIA`(还没上传片源或未转码完成)。
|
||||
|
||||
**判定顺序由服务端决定,客户端不得自行推断可否播放。** 签名默认 15 分钟有效;
|
||||
开启 `fruitshorts.media.signature-required` 后,`/files/episode/**` 没带有效签名会返回 403。
|
||||
|
||||
### 1.5 观看记录与收藏(已实现)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| POST | `/app/watch/progress` | 上报进度(建议每 10s 一次,切集时补一次) |
|
||||
| GET | `/app/watch/progress?dramaId=` | 某剧续看位置 |
|
||||
| GET | `/app/watch/recent?limit=20` | 最近观看 |
|
||||
| DELETE | `/app/watch/progress/{dramaId}` | 删除某剧记录 |
|
||||
| DELETE | `/app/watch/progress` | 清空记录 |
|
||||
| POST | `/app/favorites/toggle` | 收藏/取消收藏,返回切换后状态 |
|
||||
| GET | `/app/favorites` | 收藏列表(卡片结构与首页一致) |
|
||||
| DELETE | `/app/favorites/{dramaId}` | 取消收藏 |
|
||||
|
||||
进度合并规则:同一集内只前进不后退;换集直接采纳新集;`finished` 只在接近片尾或客户端显式上报时置位。
|
||||
这样用户换设备不会看到进度倒退。
|
||||
|
||||
### 1.6 离线下载(已实现)
|
||||
|
||||
服务端只发**授权**与**短时效地址**,分片由客户端直接向 CDN 拉取 ——
|
||||
这样并发下载不会打到应用服务器,服务端只记账(进度、配额、有效期)。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/app/downloads/policy` | 策略与配额:单设备上限、授权天数、仅 Wi-Fi、清晰度上限、已用/剩余 |
|
||||
| PUT | `/app/downloads/settings` | 偏好设置:仅 Wi-Fi 下载、最高清晰度 |
|
||||
| POST | `/app/downloads` | 申请下载授权(同剧集重复申请幂等,只刷新有效期) |
|
||||
| GET | `/app/downloads` | 下载列表(授权 + 任务进度,可直接渲染) |
|
||||
| GET | `/app/downloads/{episodeId}/url` | 断点续传前重新取地址(授权不变,地址是短时效的) |
|
||||
| POST | `/app/downloads/{episodeId}/progress` | 上报下载进度,用于断点续传与跨端展示 |
|
||||
| DELETE | `/app/downloads/{episodeId}` | 删除单条下载(吊销授权) |
|
||||
| DELETE | `/app/downloads` | 清空当前设备全部下载,返回清除条数 |
|
||||
|
||||
设备标识优先取请求体 `deviceId`,其次取请求头 `X-Device-Id`。
|
||||
授权默认 30 天有效,过期由每日定时任务置为 `EXPIRED`;被吊销或过期的授权客户端下次联网即失效。
|
||||
需要解锁的剧集走权益判定,未解锁不允许下载(当前内容均为免费集,付费链路见 09)。
|
||||
配额与有效期由 `fruitshorts.download.*` 配置,通过 `policy` 下发,客户端不要写死。
|
||||
|
||||
### 1.7 互动、交易、创作者(下一批)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| POST | `/app/comments` | 发表评论(先审后发) |
|
||||
| GET | `/app/comments?dramaId=&cursor=` | 评论列表(游标分页) |
|
||||
| DELETE | `/app/comments/{id}` | 删除自己的评论 |
|
||||
| POST | `/app/likes/toggle` | 点赞(DRAMA/EPISODE/COMMENT) |
|
||||
| POST | `/app/reports` | 举报 |
|
||||
| POST | `/app/orders/verify` | 提交 IAP / Google Play 票据,服务端二次校验后发权益 |
|
||||
| GET | `/app/orders` | 我的订单 |
|
||||
| GET | `/app/wallet` | 金币余额与流水 |
|
||||
| POST | `/app/episodes/{id}/unlock` | 金币解锁分集 |
|
||||
| POST | `/app/creator/apply` | 创作者入驻 |
|
||||
| GET | `/app/creator/works` | 我的作品与审核状态 |
|
||||
| GET | `/app/creator/stats` | 创作者数据看板 |
|
||||
| GET | `/app/config` | 启动配置(协议版本、开关、最低版本) |
|
||||
| GET | `/app/notices` | 系统公告 |
|
||||
|
||||
## 2. 后台管理接口(/admin/**)
|
||||
|
||||
### 2.1 认证与系统(已实现)
|
||||
|
||||
`POST /admin/auth/login`、`GET /admin/auth/menus`、`GET /admin/auth/profile`、
|
||||
`POST /admin/auth/logout`、`POST /admin/auth/password`;
|
||||
管理员、角色、权限、菜单、角色分类、系统配置、操作日志各自一套增删改查。
|
||||
|
||||
### 2.2 内容与媒资(已实现)
|
||||
|
||||
短剧(含是否热门、审核发布状态机)、剧集、分类、轮播图;媒资分片上传(init/chunk/complete)。
|
||||
新增字段:短剧 `rating`(内容分级)、`regionCodes`(可播区域)、版权窗口、
|
||||
`creatorId`、`tags`、`trailerUrl`;剧集 `transcodeStatus`、`hlsUrl`、`previewSeconds`、字幕。
|
||||
|
||||
### 2.3 App 用户管理(已实现)
|
||||
|
||||
| 方法 | 路径 | 权限点 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/admin/app-users/page` | `app-user:list` | 检索:keyword(uid/昵称/手机号/邮箱)、status、countryCode、registerSource |
|
||||
| GET | `/admin/app-users/{id}` | `app-user:list` | 详情 |
|
||||
| GET | `/admin/app-users/{id}/ban-records` | `app-user:list` | 封禁与申诉记录 |
|
||||
| PUT | `/admin/app-users/{id}/status` | `app-user:update` | 改状态:normal/limited/banned |
|
||||
|
||||
状态处置会**立即吊销该用户全部会话**并写入封禁记录,避免"封了还能用"。
|
||||
|
||||
### 2.4 下载管理(已实现)
|
||||
|
||||
| 方法 | 路径 | 权限点 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/admin/downloads/page` | `download:list` | 按用户 / 设备 / 状态检索下载授权,含剧名与集号 |
|
||||
| GET | `/admin/downloads/stats` | `download:list` | 授权与任务的分布统计 |
|
||||
| PUT | `/admin/downloads/{id}/revoke` | `download:revoke` | 吊销授权,用户侧下次联网即失效 |
|
||||
| POST | `/admin/downloads/cleanup` | `download:revoke` | 手动清理过期授权(默认每天凌晨自动执行) |
|
||||
|
||||
### 2.5 内容治理、订单、创作者审核、数据看板(下一批)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/admin/comments/page` | 评论审核队列 |
|
||||
| PUT | `/admin/comments/{id}/audit` | 通过/驳回(带原因码) |
|
||||
| GET | `/admin/reports/page` | 举报工单 |
|
||||
| PUT | `/admin/reports/{id}/handle` | 处理举报(可联动封禁) |
|
||||
| GET/POST/PUT | `/admin/sensitive-words` | 敏感词库 |
|
||||
| GET | `/admin/orders/page` | 订单检索(可按用户、状态、平台) |
|
||||
| POST | `/admin/orders/{orderNo}/compensate` | 票据丢失的补发 |
|
||||
| PUT | `/admin/entitlements/{id}/revoke` | 权益回收 |
|
||||
| POST | `/admin/reconcile/run` | 手动触发对账 |
|
||||
| GET | `/admin/creators/page` | 创作者审核队列 |
|
||||
| PUT | `/admin/creators/{id}/audit` | 入驻审核 |
|
||||
| GET | `/admin/submissions/page` | 内容提交审核 |
|
||||
| PUT | `/admin/submissions/{id}/audit` | 内容审核(通过/驳回/驳回原因码) |
|
||||
| PUT | `/admin/prices` | 分集价格与免费集配置 |
|
||||
| GET | `/admin/dashboard/overview` | 经营大盘(用户/播放/收入/成本) |
|
||||
| GET | `/admin/dashboard/transcode` | 转码队列与失败任务 |
|
||||
| POST | `/admin/notices` | 发布系统公告 |
|
||||
|
||||
## 3. 契约与实现状态对照
|
||||
|
||||
| 模块 | 表结构 | 接口 | 代码 |
|
||||
| --- | --- | --- | --- |
|
||||
| 01 基础与交付准备 | 已就绪 | 约定已冻结 | 脚手架 / 契约 / 签名 / 转码骨架 已实现 |
|
||||
| 02 用户账号与登录 | 已建 | 已冻结 | **已实现**(手机号/邮箱,第三方登录走同一凭证表) |
|
||||
| 03 内容发现与详情 | 已建 | 已冻结 | 已实现(补了分级、区域、版权、标签字段) |
|
||||
| 04 播放器与播放控制 | 已建 | 已冻结 | 票据 + 签名 + 进度 已实现;多码率依赖转码 worker |
|
||||
| 05 记录、收藏与下载 | 已建 | 已冻结 | **已实现**:进度、观看流水、收藏、离线下载授权与任务 |
|
||||
| 06 评论、点赞与分享 | 已建 | 已冻结 | 下一批 |
|
||||
| 07 创作者中心 | 已建 | 已冻结 | 下一批(转码任务与产物记账已实现) |
|
||||
| 08 平台运营后台 | 已建 | 用户管理已实现 | 治理/订单/看板 下一批 |
|
||||
| 09 分集收费与支付 | 已建 | 已冻结 | 下一批(权益判定端口已预留,接上即生效) |
|
||||
| 10 质量、数据与上线 | 已建 | 已冻结 | 压测与埋点 下一批 |
|
||||
+23
-23
@@ -1,4 +1,4 @@
|
||||
# ReelShort 短剧服务 · 后端架构设计
|
||||
# FruitShorts 短剧服务 · 后端架构设计
|
||||
|
||||
> 技术栈:JDK 21(LTS,编译目标 Java 17)+ Spring Boot 3.2 + PostgreSQL 18 + Redis
|
||||
>
|
||||
@@ -94,26 +94,26 @@
|
||||
```
|
||||
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/ # 定时任务与异步消费者
|
||||
├── fruitshorts-common/ # 统一响应/异常/常量/枚举/工具
|
||||
├── fruitshorts-framework/ # 安全、MyBatis、Redis、日志、限流、OSS、MQ 客户端
|
||||
├── fruitshorts-module-system/ # 管理员、角色、权限、菜单、配置、操作日志
|
||||
├── fruitshorts-module-content/ # 短剧、剧集、分类标签、审核发布
|
||||
├── fruitshorts-module-media/ # 媒资上传、转码、播放地址签名
|
||||
├── fruitshorts-module-member/ # C 端用户、登录、设备、资料
|
||||
├── fruitshorts-module-interaction/ # 收藏、点赞、评论、观看历史
|
||||
├── fruitshorts-module-trade/ # 金币、流水、解锁、订单、VIP
|
||||
├── fruitshorts-module-stat/ # 数据看板、日聚合
|
||||
├── fruitshorts-admin-api/ # B 端接口,/admin/**
|
||||
├── fruitshorts-app-api/ # C 端接口,/app/**
|
||||
└── fruitshorts-job/ # 定时任务与异步消费者
|
||||
```
|
||||
|
||||
起步阶段若不想铺这么开,可先合并为 `reelshort-common` + `reelshort-server`(含 system/content/media)+ `reelshort-app-api`,但包结构仍按域划分(`com.reelshort.content.*`),后续拆模块零成本。
|
||||
起步阶段若不想铺这么开,可先合并为 `fruitshorts-common` + `fruitshorts-server`(含 system/content/media)+ `fruitshorts-app-api`,但包结构仍按域划分(`com.fruitshorts.content.*`),后续拆模块零成本。
|
||||
|
||||
模块内部结构保持一致:
|
||||
|
||||
```
|
||||
com.reelshort.content
|
||||
com.fruitshorts.content
|
||||
├── controller/ # 仅 admin-api / app-api 暴露
|
||||
├── service/ # 接口 + impl
|
||||
├── dal/
|
||||
@@ -126,7 +126,7 @@ com.reelshort.content
|
||||
|
||||
命名约定:
|
||||
|
||||
- 包名 `com.reelshort.<module>.<layer>`
|
||||
- 包名 `com.fruitshorts.<module>.<layer>`
|
||||
- 实体 `Drama`、`DramaEpisode`、`AppUser`
|
||||
- 请求对象 `XxxCreateReqVO` / `XxxUpdateReqVO` / `XxxPageReqVO`,响应对象 `XxxRespVO`
|
||||
- 表名 `<域前缀>_<业务>`,如 `drama`、`drama_episode`、`sys_role`、`app_user`、`stat_drama_daily`
|
||||
@@ -769,9 +769,9 @@ POST /app/pay/callback/{channel}
|
||||
|
||||
```
|
||||
Nginx
|
||||
├── reelshort-admin-api (/admin)
|
||||
├── reelshort-app-api (/app)
|
||||
└── reelshort-job (定时任务、异步消费)
|
||||
├── fruitshorts-admin-api (/admin)
|
||||
├── fruitshorts-app-api (/app)
|
||||
└── fruitshorts-job (定时任务、异步消费)
|
||||
PostgreSQL(主从) / Redis / OSS(或 MinIO) / 转码 Worker(独立容器,可多实例水平扩容)
|
||||
```
|
||||
|
||||
@@ -849,9 +849,9 @@ server:
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://127.0.0.1:5432/reelshort
|
||||
username: ${DB_USER:reelshort}
|
||||
password: ${DB_PASSWORD:reelshort}
|
||||
url: jdbc:postgresql://127.0.0.1:5432/fruitshorts
|
||||
username: ${DB_USER:fruitshorts}
|
||||
password: ${DB_PASSWORD:fruitshorts}
|
||||
data:
|
||||
redis:
|
||||
host: 127.0.0.1
|
||||
@@ -861,7 +861,7 @@ spring:
|
||||
max-file-size: 2GB
|
||||
max-request-size: 2GB
|
||||
|
||||
reelshort:
|
||||
fruitshorts:
|
||||
jwt:
|
||||
admin-secret: ${JWT_ADMIN_SECRET}
|
||||
app-secret: ${JWT_APP_SECRET}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
# 后端服务设计(第一阶段 · 印尼市场 · 1 万并发观看)
|
||||
|
||||
> 版本 v1.0 · 配套 [第一阶段 PRD](./prd-phase1.md) · 现有实现见 [架构设计](./architecture-design.md)
|
||||
> 目标口径:**10,000 路并发播放不劣化**(对应 DAU 约 25–35 万),首发印尼。
|
||||
|
||||
## 1. 目标与约束
|
||||
|
||||
**业务目标**:日活规模下 1 万路并发观看流畅(首帧 P95 < 1.5s、卡顿率 < 2%),分集收费闭环准确无误,创作者上传到可播放全自动。
|
||||
|
||||
**工程约束**:
|
||||
|
||||
1. 首发印尼,用户全部在东南亚,服务端与内容必须就近部署,不能用国内机房直连。
|
||||
2. 团队是 14–16 人的 MVP 团队,运维只有 1 人,所以不做微服务拆分,保持模块化单体。
|
||||
3. 数字商品必须走 Apple IAP / Google Play Billing,服务端只做校验与权益发放。
|
||||
4. 成本敏感:视频流量是最大成本项,码率与缓存策略从一开始就要按成本设计。
|
||||
|
||||
## 2. 现状盘点与差距
|
||||
|
||||
当前实现:7 个 Maven 模块的模块化单体,`fruitshorts-admin-api`(8080) + `fruitshorts-app-api`(8081),
|
||||
MyBatis-Plus + PostgreSQL,媒资落本地磁盘 `data/uploads`,静态文件直出,无转码、无 CDN、无缓存层、无消息队列。
|
||||
|
||||
| 能力 | 现状 | 目标 | 优先级 |
|
||||
| --- | --- | --- | --- |
|
||||
| 媒资存储 | 本地磁盘,随机器走 | 对象存储(S3 兼容)+ 生命周期分层 | P0 |
|
||||
| 视频分发 | 源站直出原文件 | 转码成 HLS 多码率 + CDN 分发 + 签名 URL | P0 |
|
||||
| 缓存 | 无 | Redis + 本地 Caffeine 二级缓存 | P0 |
|
||||
| 异步 | 无 | 消息队列(转码任务、计数、埋点、对账) | P0 |
|
||||
| 数据库 | 单实例单库 | 主库 + 只读副本,热点与日志表分区 | P1 |
|
||||
| 播放鉴权 | 免费集直接给地址 | 票据 + 签名 URL + 有效期 + 吊销 | P0 |
|
||||
| 限流熔断 | 无 | 网关级限流 + 按接口降级 | P0 |
|
||||
| 支付 | 未实现 | 订单状态机 + 票据校验 + 对账 | P0 |
|
||||
| 可观测 | 慢请求日志 | 指标 + 链路追踪 + 告警 + 看板 | P1 |
|
||||
|
||||
结论:现有代码是功能验证级,架构骨架(模块划分、鉴权、内容与媒资模型)可以直接复用,
|
||||
但"视频从源站直出"这一点在 1 万并发下会立刻打爆,必须替换成 CDN + 签名 URL。
|
||||
|
||||
## 3. 容量规划
|
||||
|
||||
### 3.1 码率阶梯与带宽
|
||||
|
||||
印尼以 4G 为主、资费敏感,码率阶梯压到偏低档位:
|
||||
|
||||
| 档位 | 分辨率 | 视频 | 音频 | 合计 | 预期占比 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 360p | 640x360 | 400 kbps | 64 kbps | ~0.47 Mbps | 40% |
|
||||
| 480p | 854x480 | 800 kbps | 64 kbps | ~0.87 Mbps | 45% |
|
||||
| 720p | 1280x720 | 1600 kbps | 96 kbps | ~1.70 Mbps | 12% |
|
||||
| 1080p | 1920x1080 | 3200 kbps | 128 kbps | ~3.30 Mbps | 3% |
|
||||
|
||||
按占比加权,平均码率约 **0.88 Mbps**。
|
||||
|
||||
- 10,000 路并发出口带宽约 **8.8 Gbps**
|
||||
- 叠加首段突发、重连与预加载(+30%),峰值 **约 11–12 Gbps**
|
||||
- CDN 命中率按 98% 计,回源带宽 **约 240 Mbps**
|
||||
|
||||
源站只需 2 台带 1 Gbps 出口的机器即可承载回源,不需要为每个观众准备源站带宽。
|
||||
这条推导是整个设计的关键:**播放流量必须由 CDN 承担,源站只回源 2%**。
|
||||
|
||||
### 3.2 接口 QPS
|
||||
|
||||
| 链路 | 估算方式 | 平均 | 峰值(x3,20:00–23:00 WIB) |
|
||||
| --- | --- | --- | --- |
|
||||
| 首页/发现/详情 | 每活跃用户每分钟 2 次 | ~333 QPS | ~1,000 QPS |
|
||||
| 播放票据签发 | 每集一次,平均 4 分钟/集 | ~42 QPS | ~125 QPS |
|
||||
| 播放进度上报 | 每 10 秒一次 | 1,000 QPS | 3,000 QPS |
|
||||
| 埋点/播放计数 | 客户端按 30s 合批 | ~330 QPS | ~1,000 QPS |
|
||||
|
||||
结论:
|
||||
|
||||
- **读接口** 经 Redis + 本地缓存后,4–8 个 API 副本足够(单副本可撑 1,500+ QPS)。
|
||||
- **进度上报不直接打库**:写 Redis,后台任务按 `user+drama` 合并后批量 UPSERT,落库压力降到 100 QPS 量级。
|
||||
- **埋点与计数走消息队列**,消费者聚合后写回,不用事务强一致。
|
||||
|
||||
### 3.3 存储与转码
|
||||
|
||||
按第一阶段 300 部 x 60 集 x 单集 2 分钟估算:
|
||||
|
||||
| 项 | 计算 | 容量 |
|
||||
| --- | --- | --- |
|
||||
| 原片(1080p) | 18,000 集 x ~50 MB | ~0.9 TB |
|
||||
| 转码产物(4 档 HLS) | 18,000 集 x ~95 MB | ~1.7 TB |
|
||||
| 封面/截图/字幕 | — | ~0.1 TB |
|
||||
| **合计** | | **约 3 TB** |
|
||||
|
||||
转码算力:单集 1080p 转 4 档约 8 分钟机时,首月入库 18,000 集约 2,400 小时;
|
||||
100 核并行约 24 小时跑完。**按需弹性即可,不需要常驻转码集群**,但必须排队限流,避免抢在线服务的 CPU。
|
||||
|
||||
### 3.4 成本与单位经济性
|
||||
|
||||
假设 DAU 30 万、人均日观看 40 分钟、平均码率 0.88 Mbps:
|
||||
|
||||
| 项 | 月度量/成本 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| CDN 流量 | 约 79 TB/天,约 2,300 TB/月,**$50k–75k** | 按东南亚 $0.022–0.032/GB,**最大成本项** |
|
||||
| 对象存储 | 3 TB,约 $60–100 | 冷热分层后更低 |
|
||||
| 数据库 / Redis / MQ | $300–800 | 主从 + 只读副本 + 集群缓存 |
|
||||
| API 与后台计算 | $400–1,200 | 4–8 副本 + 弹性转码 |
|
||||
| 短信 OTP | $500–2,000 | 按 $0.02–0.08/条,必须频控 |
|
||||
| 监控 / 日志 / APM | $100–300 | |
|
||||
| **合计** | **约 $52k–79k/月** | CDN 占绝大部分可变成本 |
|
||||
|
||||
单位经济性(决定商业模型是否成立):
|
||||
|
||||
- 单集 2 分钟、0.88 Mbps,约 13 MB 流量,CDN 成本约 **$0.0004**
|
||||
- 单集解锁(按 30 金币约 $0.03),视频成本约占解锁收入 **1.5%**
|
||||
|
||||
推论:
|
||||
|
||||
1. 金币解锁模式下视频成本可忽略;真正的压力来自第二阶段的广告模式(ARPU 低一个数量级时 CDN 会变成主要成本)。
|
||||
2. 因此第一阶段就要把平均码率、缓存命中率、CDN 单价做成**看板与告警**(单位播放成本 $/千次播放)。
|
||||
3. 把平均码率从 0.88 压到 0.6 Mbps(更多 360p、更激进的 ABR 起播策略)可直接省掉约 30% 的 CDN 账单,比任何架构优化都有效。
|
||||
|
||||
## 4. 部署与网络拓扑
|
||||
|
||||
```
|
||||
印尼用户 (iOS / Android)
|
||||
| 1. 接口请求(JSON, HTTPS)
|
||||
v
|
||||
CDN / 全球加速 --- 2. 视频分片与静态资源(HLS,签名 URL,命中率 >=98%)
|
||||
| ^
|
||||
| 回源(约 2%) |
|
||||
v |
|
||||
区域入口 (Jakarta) |
|
||||
|- API 网关:TLS、限流、鉴权、WAF |
|
||||
|- app-api x4-8(K8s HPA) |
|
||||
|- admin-api x2(可放新加坡/内网,仅内部使用) |
|
||||
|- worker(转码调度、计数聚合、对账、清理) |
|
||||
|
|
||||
|- PostgreSQL(主 + 只读副本)
|
||||
|- Redis(集群:缓存 / 票据 / 进度缓冲 / 限流)
|
||||
|- 消息队列(转码任务、埋点、计数、对账事件)
|
||||
|- 对象存储 OSS(S3 兼容)--- 转码产物 -----------+
|
||||
```
|
||||
|
||||
**区域选择**:首选**雅加达**(阿里云 / 腾讯云 / AWS 均有区域),退路是新加坡
|
||||
(ap-southeast-1,到雅加达 RTT 约 20–30ms,仍可接受)。依据是到用户 RTT、CDN 覆盖与本地 PoP 价格、数据驻留要求。
|
||||
|
||||
**为什么要 API 网关**:限流、IP 黑名单、WAF、灰度都放在网关层,业务服务不必各自实现一遍。
|
||||
|
||||
**国内团队访问生产**:运营后台走独立入口 + 内网跳板,不要与 C 端出口混用。
|
||||
|
||||
## 5. 服务划分与演进
|
||||
|
||||
第一阶段**保持模块化单体**,按业务域划清边界,为后续拆分留缝:
|
||||
|
||||
| 模块 | 职责 | 未来拆分优先级 |
|
||||
| --- | --- | --- |
|
||||
| user | 账号、凭证、会话、资料、风控 | 中 |
|
||||
| content | 短剧、剧集、分类、标签、榜单 | 低 |
|
||||
| media | 上传、转码、媒资、播放票据 | **高**(与 CDN/转码强绑定) |
|
||||
| playback | 进度、记录、收藏、下载授权 | 中 |
|
||||
| interaction | 评论、点赞、举报、分享 | 低 |
|
||||
| commerce | 商品、订单、钱包、权益、对账 | **高**(支付需审计隔离) |
|
||||
| creator | 创作者入驻、内容提交、数据看板 | 中 |
|
||||
| admin/system | RBAC、菜单、审计、配置 | 低 |
|
||||
|
||||
拆分只在这两个条件下发生:**独立扩缩容需求**或**合规隔离需求**。其余情况一律不加服务。
|
||||
|
||||
## 6. 数据模型与存储
|
||||
|
||||
核心表(现有 `sql/schema.sql` 已有基础,"新增"为需要补充的部分):
|
||||
|
||||
- 内容域:`drama`、`drama_episode`、`drama_category`、`content_banner`,已具备。
|
||||
- 媒资域:`media_asset`、`media_upload_task` 已具备;**新增** `transcode_task`、`media_rendition`(多码率产物)。
|
||||
- 用户域:**新增** `app_user`、`user_credential`、`user_session`、`user_profile`(当前只有后台管理员表)。
|
||||
- 播放域:**新增** `watch_progress`(user+drama 唯一)、`watch_history`(按月分区)、`user_favorite`、`playback_ticket`。
|
||||
- 互动域:**新增** `comment`、`comment_reply`、`user_like`、`report`、`blocklist`。
|
||||
- 交易域:**新增** `product`、`order`、`order_event`、`wallet`、`coin_ledger`、`entitlement`、`receipt`。
|
||||
- 创作者域:**新增** `creator`、`creator_document`、`content_submission`、`audit_record`。
|
||||
|
||||
存储策略:
|
||||
|
||||
- 大表分区:`watch_history`、`order_event`、`coin_ledger`、`playback_event` 按月分区,保留 12–24 个月。
|
||||
- 冷热分离:长期不播放的媒资转低频/归档存储,源站只保留热内容。
|
||||
- 读写分离:报表、导出、后台检索走只读副本,避免影响 C 端。
|
||||
- 连接池:Hikari 每副本 20–30 连接,按副本数 x 连接数留足 PG `max_connections` 余量。
|
||||
|
||||
## 7. 关键链路设计
|
||||
|
||||
### 7.1 播放与地址签发(第一阶段最重要的改造)
|
||||
|
||||
1. 客户端请求 `/app/episodes/{id}/play`,服务端判定权益(已购 > 活动免费 > 免费集 > 试看 > 金币可扣 > 需充值)。
|
||||
2. 可播放时签发**短时效签名 URL**(建议 15 分钟),签名绑定 `episodeId + userId + 清晰度 + 过期时间`。
|
||||
3. 返回 HLS master playlist 地址(CDN 域名),CDN 校验签名后才回源。
|
||||
4. 签名校验失败、过期或用户被吊销,直接 403,客户端提示重新获取。
|
||||
5. 防盗链:Referer/UA 白名单 + 单账号同时在播设备数限制(建议 1–2 台)。
|
||||
|
||||
关键点:客户端拿不到可长期复用的裸地址,也不会出现"服务端扛视频"的情况。
|
||||
|
||||
### 7.2 上传与转码流水线
|
||||
|
||||
```
|
||||
创作者分片直传 OSS(STS 临时凭证)
|
||||
-> 合并校验(MD5 / 时长 / 编码探测)
|
||||
-> 写 transcode_task(排队)
|
||||
-> worker 拉起 FFmpeg 或云转码:4 档 HLS + 封面 + 字幕封装
|
||||
-> 回调写 media_rendition
|
||||
-> CDN 预热
|
||||
-> 状态回写创作者中心(可发布)
|
||||
```
|
||||
|
||||
失败重试 3 次并记录原因;转码队列与在线服务隔离(独立节点池或命名空间),避免抢占资源。
|
||||
|
||||
### 7.3 支付与权益
|
||||
|
||||
1. 客户端拿到商店票据后提交 `/app/orders/verify`。
|
||||
2. 服务端**必须**再调 Apple / Google 校验接口二次验证,不信任客户端上报。
|
||||
3. 订单状态机幂等:以 `(platform, transactionId)` 唯一键去重,重复回调只返回既有结果。
|
||||
4. 权益发放与订单在同一事务;发放失败进补偿队列,T+1 内自动修复并留痕。
|
||||
5. 退款与撤销事件(App Store Server Notifications / Google RTDN)触发权益回收或欠款标记,写 `coin_ledger`。
|
||||
6. 每日对账:拉取商店账单与本地订单比对,差异进入人工处理队列并告警。
|
||||
|
||||
### 7.4 观看进度与记录
|
||||
|
||||
- 客户端每 10s 上报一次;服务端写 Redis(`progress:{user}:{drama}`),不直接写库。
|
||||
- 后台任务每 30s 把 Redis 中的变更按剧合并,批量 UPSERT 到 `watch_progress`(每用户每剧一行)。
|
||||
- `watch_history` 按事件追加、按月分区,用于"最近观看"与后续推荐。
|
||||
- 冲突规则:以 `updatedAt` 较新者为准;同秒冲突取进度较大者,避免进度倒退。
|
||||
|
||||
### 7.5 计数与埋点
|
||||
|
||||
播放数、完播数、点赞数一律走消息队列,消费者按窗口聚合后定时写回计数表。
|
||||
读取计数走缓存,允许秒级最终一致,不允许为了强一致把写压力压到主库。
|
||||
|
||||
## 8. 高并发与稳定性手段
|
||||
|
||||
| 手段 | 落点 |
|
||||
| --- | --- |
|
||||
| 多级缓存 | 客户端本地缓存 > CDN > Redis > Caffeine 本地缓存 > PG |
|
||||
| 读缓存 | 首页/详情/分类结果缓存 30–300s,运营改动通过版本号主动失效 |
|
||||
| 异步化 | 进度、计数、埋点、转码、对账全部异步 |
|
||||
| 合批 | 进度上报合并写、计数聚合写、日志批量落盘 |
|
||||
| 限流 | 网关按 IP/设备/用户三维限流,重点保护 OTP 与票据签发 |
|
||||
| 熔断降级 | 短信通道、商店校验、审核供应商失败时降级并告警;播放链路的降级是"降码率"而不是"不可播" |
|
||||
| 幂等 | 订单、回调、注册、上传合并全部幂等键化 |
|
||||
| 游标分页 | 列表接口用 cursor 而非深 offset,避免大偏移查询 |
|
||||
| 连接管理 | 视频分片走 CDN,API 走 HTTP/2 keep-alive,PG 连接池按副本配额 |
|
||||
| 压测门禁 | 上线前通过 1 万并发播放 + 1,000 QPS 接口压测,错误率 < 0.1% |
|
||||
|
||||
## 9. 可观测性与 SLO
|
||||
|
||||
| 指标 | 目标 | 告警阈值 |
|
||||
| --- | --- | --- |
|
||||
| 接口可用性 | 99.9% | 5 分钟错误率 > 1% |
|
||||
| 接口延迟 P95(客户端热点接口) | < 50ms | > 100ms 持续 5 分钟 |
|
||||
| 首帧 P95 | < 1.5s | > 2.5s |
|
||||
| 卡顿率 | < 2% | > 4% |
|
||||
| CDN 命中率 | >= 98% | < 95% |
|
||||
| 单位播放成本 | 有基线并有月度预算 | 超预算 20% |
|
||||
| 支付成功率 | > 97% | < 95% |
|
||||
|
||||
埋点与看板分四层:接口层(QPS/延迟/错误码)、播放层(起播/卡顿/完播/ERROR_CODE)、
|
||||
业务层(注册/首播/解锁/支付)、成本层(CDN 流量/存储/短信条数)。
|
||||
链路追踪覆盖"支付"与"播放票据签发"两条关键链路。
|
||||
|
||||
## 10. 安全与合规(印尼)
|
||||
|
||||
- **合规**:Kominfo **PSE 注册**(未注册可能被整体封锁);UU 27/2022 个人数据保护(明示同意、删除权、导出权、跨境传输依据);内容分级(R13/R17/R21)与色情、宗教、政治敏感内容零容忍。
|
||||
- **应用安全**:网关 WAF;接口越权(水平 + 垂直)全量校验;密钥集中托管并支持轮换;后台敏感操作二次确认 + 审计留痕。
|
||||
- **支付安全**:服务端票据二次校验;防重放(nonce + 唯一键);金额与商品以服务端配置为准。
|
||||
- **内容保护**:签名 URL + 短时效 + 并发设备限制;第一阶段不上 DRM,但 URL 结构与接口要为第四阶段 DRM 预留。
|
||||
- **数据分级**:手机号、邮箱、设备标识、支付标识分级存储与脱敏;日志与埋点禁止写入 PII 明文。
|
||||
|
||||
## 11. 上线与扩容路径
|
||||
|
||||
1. **环境**:dev / test / staging / prod 四套,配置外置,密钥不入库不入 Git。
|
||||
2. **压测**:阶梯加压到 1 万并发播放(走真实 CDN 链路)+ 1,000 QPS 接口,记录水位与瓶颈。
|
||||
3. **灰度**:先 5% 流量,观察起播、卡顿、错误率与支付成功率,再逐步放量。
|
||||
4. **回滚**:镜像与配置双版本共存,一键回滚;数据库变更必须向后兼容(先加字段、再切读写、最后清理)。
|
||||
5. **扩容触发线**:API 副本 CPU > 60% 或 QPS 超过设计值 70%;转码队列积压 > 30 分钟;CDN 命中率 < 95%。
|
||||
6. **灾难恢复**:数据库 PITR + 每日全量备份(跨可用区),目标 RPO <= 15 分钟、RTO <= 2 小时,季度演练一次。
|
||||
|
||||
## 12. 与第一阶段工作包的对应关系
|
||||
|
||||
| 评估表模块 | 后端承担的部分 | 优先级 |
|
||||
| --- | --- | --- |
|
||||
| 01 基础与交付准备 | 服务骨架、OpenAPI 契约、ERD、CI/CD、环境与监控 | P0 |
|
||||
| 02 用户账号与登录 | 用户域、OTP 与风控、会话与令牌、注销与导出 | P0 |
|
||||
| 03 内容发现与详情 | 内容目录、搜索、分类榜单、可见性规则、缓存 | P0 |
|
||||
| 04 播放器与播放控制 | **票据与签名 URL、转码与 ABR 片源、进度与续播接口、播放事件** | P0 |
|
||||
| 05 记录、收藏与下载 | 进度存储与合并、收藏服务、下载授权与设备绑定 | P0 |
|
||||
| 06 评论、点赞与分享 | 点赞与评论服务、内容治理联动、分享落地页接口 | P1 |
|
||||
| 07 创作者中心 | 入驻与审核流、分片上传、转码流水线、媒资与状态服务 | P0 |
|
||||
| 08 平台运营后台 | RBAC 与审计、内容/用户/订单管理、大盘数据接口 | P0 |
|
||||
| 09 分集收费与支付 | 钱包与订单、票据校验、权益决策、退款与对账 | P0 |
|
||||
| 10 质量、数据与上线 | 压测与容量报告、安全测试、埋点验收、发布与 DR Runbook | P0 |
|
||||
|
||||
建议的开发顺序(后端视角):
|
||||
基础骨架与契约 → 账号与鉴权 → 内容目录与缓存 → 媒资上传与转码 → 票据与签名播放 →
|
||||
进度与收藏 → 支付与权益 → 运营后台与治理 → 压测与上线准备。
|
||||
|
||||
其中"票据 + 签名 URL + 转码"是第一阶段风险最高、最不该压缩的链路。
|
||||
@@ -0,0 +1,272 @@
|
||||
# 短剧 App 第一阶段 PRD(印尼首发)
|
||||
|
||||
> 版本 v1.0 · 依据《四阶段功能规划与工作量评估》第一阶段明细(10 个模块 / 614 净人天)
|
||||
> 配套文档:[后端服务设计](./backend-design-phase1.md) · [架构设计](./architecture-design.md)
|
||||
|
||||
## 0. 文档说明
|
||||
|
||||
本 PRD 覆盖第一阶段(M0–M5)的产品范围,即"可上线、可收费、可运营、可供给内容"的 MVP 闭环。
|
||||
第二至第四阶段(会员订阅、广告、个性化推荐、多区域部署、DRM)不在本文范围内,仅在衔接处标注预留。
|
||||
|
||||
阅读约定:`P0` 为上线阻塞项,`P1` 为可延后但不能砍项;"人天"沿用评估表的合并口径,不重复计入通用后台与基础服务。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
短剧(竖屏、单集 1–3 分钟、强钩子、连续解锁)在国内已被验证。本项目把它做到海外,首发印尼:
|
||||
人口 2.8 亿、移动端占比极高、年轻用户对短视频形态接受度高,但支付能力分散、网络质量参差、
|
||||
内容与宗教文化敏感度高,因此不能直接照搬国内产品与运营方式。
|
||||
|
||||
### 1.2 第一阶段目标
|
||||
|
||||
做出一个能真实跑起来的业务闭环,而不是功能演示:
|
||||
|
||||
1. **能看**:iOS / Android 双端可安装,播放稳定,弱网可用。
|
||||
2. **能收**:分集收费闭环走通,且完全符合 Apple / Google 的数字商品结算规则。
|
||||
3. **能供给**:创作者能自助入驻、上传、提交审核、看到数据。
|
||||
4. **能运营**:运营能编排内容、处理审核与举报、查订单与用户、看经营大盘。
|
||||
5. **能被度量**:埋点口径统一,核心指标可对账、可归因。
|
||||
|
||||
### 1.3 阶段退出标准
|
||||
|
||||
- 通过 UAT 与安全测试,高危漏洞清零;
|
||||
- 支付、播放、内容上下架、账号注销四条全链路端到端通过;
|
||||
- 商店审核材料齐备且首次提审材料可完整复现核心流程。
|
||||
|
||||
## 2. 市场与假设(M0 决策门禁)
|
||||
|
||||
以下四项未冻结前不启动研发,否则登录、支付、版权、短信、UI 与商店资料会反复返工。
|
||||
|
||||
| 决策项 | 当前假设 | 影响面 |
|
||||
| --- | --- | --- |
|
||||
| 首发市场 | 印度尼西亚 | 支付税制、短信到达率、隐私法、内容分级 |
|
||||
| 首发语言 | 印尼语(Bahasa Indonesia)为主,界面预留英文 | 文案长度、字幕、客服、ASO |
|
||||
| 内容形态 | 竖屏短剧,单集 1–3 分钟,单剧 40–80 集 | 转码阶梯、CDN 成本、存储、审核成本 |
|
||||
| 收费方式 | 金币解锁分集,走 Apple IAP / Google Play Billing | 财务模型(15%–30% 抽成 + 税费)、审核 |
|
||||
|
||||
### 2.1 印尼本地化的硬性要求
|
||||
|
||||
- **PSE 注册**:印尼通信与信息部(Kominfo)要求电子系统运营商完成 PSE 注册,未注册可能被封锁。立项即启动。
|
||||
- **数据保护**:印尼《个人数据保护法》(UU 27/2022)要求明示同意、可删除、可导出,跨境传输需合法依据。
|
||||
- **内容分级与合规**:需遵守印尼内容分级(R13/R17/R21 等)与色情、宗教、政治敏感内容的严格限制;上线前需完成内容审核供应商与规则配置。
|
||||
- **商店支付**:App 内数字内容必须走商店结算,任何引导外部支付的文案都会被拒审或下架。
|
||||
- **网络与资费**:4G 为主、数据资费敏感、部分区域带宽受限 → 默认低码率起播,清晰度手工可上调。
|
||||
- **时区与货币**:WIB(UTC+7),IDR 展示按千位分隔(`Rp 12.000`);所有时间字段按用户本地时区展示。
|
||||
|
||||
## 3. 用户与角色
|
||||
|
||||
| 角色 | 主要场景 | 关键诉求 |
|
||||
| --- | --- | --- |
|
||||
| 观众(C 端) | 找剧、刷剧、追更、解锁分集 | 起播快、不卡、剧情钩子密、价格透明 |
|
||||
| 创作者 | 入驻、上传、提交审核、看数据 | 上传稳定、审核反馈明确、数据可信 |
|
||||
| 内容审核 | 审核创作者内容与片源 | 批量高效、驳回原因可复用、留痕 |
|
||||
| 运营 | 编排内容、配置定价与活动、看大盘 | 改动即时生效、可回滚 |
|
||||
| 客服 | 处理退款、账号、播放问题 | 按用户/订单快速检索,有处理入口 |
|
||||
| 财务 | 对账、退款、异常订单 | 订单与商店账单可对账,差异可追溯 |
|
||||
| 管理员 | 账号与权限管理 | RBAC 清晰、敏感操作留痕 |
|
||||
|
||||
## 4. 范围
|
||||
|
||||
### 4.1 纳入(第一阶段)
|
||||
|
||||
| 模块 | 人天 | 优先级 |
|
||||
| --- | --- | --- |
|
||||
| 01 基础与交付准备 | 8 | P0 |
|
||||
| 02 用户账号与登录 | 35 | P0 |
|
||||
| 03 内容发现与详情 | 37 | P0 |
|
||||
| 04 播放器与播放控制 | 44 | P0 |
|
||||
| 05 记录、收藏与下载 | 71 | P0 |
|
||||
| 06 评论、点赞与分享 | 64 | P1 |
|
||||
| 07 创作者中心 | 119 | P0 |
|
||||
| 08 平台运营后台 | 100 | P0 |
|
||||
| 09 分集收费与支付 | 98 | P0 |
|
||||
| 10 质量、数据与上线 | 38 | P0 |
|
||||
|
||||
### 4.2 明确不做(留到后续阶段)
|
||||
|
||||
个性化推荐模型、会员订阅与自动续费、广告插入、创作者分成结算、多语言配音、DRM、多区域容灾、TV/Web 观看端。
|
||||
|
||||
## 5. 功能需求
|
||||
|
||||
每个模块给出:需求点 → 验收要点。验收要点直接作为 QA 的测试设计输入。
|
||||
|
||||
### 5.1 基础与交付准备(8 人天)
|
||||
|
||||
需求点:需求澄清与范围冻结;技术架构、API 契约与数据模型;双端与 Web 工程骨架;后端服务骨架、鉴权与基础中间件;
|
||||
对象存储与转码链路;短信/邮件/推送通道;崩溃与性能监控;埋点规范与数据字典;CI/CD、多环境、域名与 CDN;商店账号与签名;隐私与支付前置。
|
||||
|
||||
验收要点:契约兼容与越权校验;构建产物可复现;上传转码回调闭环;多国家短信到达与限流;事件去重与口径一致。
|
||||
|
||||
### 5.2 用户账号与登录(35 人天)
|
||||
|
||||
需求点:手机号 + 邮箱 + Apple / Google 三种登录;多国家区号与 OTP 风控;隐私同意与年龄门槛;Token 与多设备会话;
|
||||
资料(昵称/头像/地区);账号注销与数据导出;封禁、限制与申诉;登录注册限流反刷。
|
||||
|
||||
验收要点:
|
||||
|
||||
- 并发注册幂等,重复提交不产生重复账号;
|
||||
- 验证码刷取被拦截(同号/同 IP/同设备多维频控 + 图形验证码 + 熔断);
|
||||
- 跨端登录状态一致,登出后旧 Token 立即失效;
|
||||
- Sign in with Apple 提供"隐藏邮箱"路径下的账号可正常续用;
|
||||
- 注销后 15 天内数据不可恢复,导出内容完整且不含他人数据。
|
||||
|
||||
### 5.3 内容发现与详情(37 人天)
|
||||
|
||||
需求点:首页浏览流;关键词搜索(含热词与历史);分类与榜单;详情与选集;规则推荐与热度排序;地区/年龄/状态/版权过滤;加载、空态、离线与错误重试。
|
||||
|
||||
验收要点:上下架与地区过滤实时生效;分页刷新不错位不重复;深链进入返回栈正确;空结果有兜底;弱网超时不空白。
|
||||
|
||||
### 5.4 播放器与播放控制(44 人天)
|
||||
|
||||
需求点:HLS 播放与生命周期管理;播放地址签发与试看校验;快进快退与手势;0.75x–2.0x 倍速与记忆;全屏/旋转/锁屏;
|
||||
断点续播与异常恢复;自适应码率与清晰度切换;基础字幕;缓冲/断网/错误码提示;自动下一集;音频焦点与系统媒体控制;播放事件埋点。
|
||||
|
||||
验收要点(本模块是全阶段最影响口碑的一环):
|
||||
|
||||
- 首帧 P95 < 1.5s,卡顿率 < 2%,弱网(3G / 丢包 5%)可降级播放不黑屏;
|
||||
- 播放地址不可复用:签名过期后直接播放失败,越权拼接地址拿不到片源;
|
||||
- 切集不重复计数,进度不跳变;
|
||||
- 全程 2 小时连续播放无内存增长与音画不同步。
|
||||
|
||||
### 5.5 记录、收藏与下载(71 人天)
|
||||
|
||||
需求点:观看记录(列表、续播、清理);收藏(状态同步、排序、批量删除);离线下载(队列、断点续传、加密存储与离线播放);
|
||||
下载授权与设备绑定、过期与吊销;存储占用管理与仅 Wi-Fi 下载;跨端同步策略;与账号注销联动。
|
||||
|
||||
验收要点:授权、过期、吊销、换机四条路径可验证;下载文件无法被外部播放器直接提取;跨端进度冲突有确定的合并规则。
|
||||
|
||||
> 产品决策待定:第一阶段是否启用"真实离线下载"。若法务与版权未就绪,先做受控缓存(App 内缓存 + 授权有效期),
|
||||
> 该决策会影响 05 模块约 20 人天的用量。
|
||||
|
||||
### 5.6 评论、点赞与分享(64 人天,P1)
|
||||
|
||||
需求点:点赞(动画、计数、防刷);评论(发布、回复、删除、分页、审核状态);敏感词、举报、拉黑与审核联动;
|
||||
系统分享、复制链接与海报;深链与落地页;分享素材模板。
|
||||
|
||||
验收要点:计数与实际关系一致(异步最终一致但可对账);审核中的评论对他人不可见;深链在未安装场景可唤起商店。
|
||||
|
||||
### 5.7 创作者中心(119 人天,最大单模块)
|
||||
|
||||
需求点:创作者入驻(身份资料、协议、审核状态);工作台与内容列表;分片上传与断点续传;上传任务校验、合并与回调;
|
||||
剧集/分集/封面/标签管理;多码率转码与媒资管理;人工审核与驳回原因;定时发布、下架、撤回与版权到期;
|
||||
免费/收费分集与试看规则;数据看板;审核结果与系统公告;角色隔离与敏感操作审计;多语言与币种展示预留。
|
||||
|
||||
验收要点:上传中断可续传且不产生重复媒资;转码失败可重试且失败原因可见;审核状态流转无"卡死"态;
|
||||
创作者只能看到自己的数据(水平越权测试覆盖);版权到期自动下架。
|
||||
|
||||
### 5.8 平台运营后台(100 人天)
|
||||
|
||||
需求点:管理员登录、RBAC、审批与操作审计;内容/剧集/分类/标签/榜单/推荐位管理;创作者资质与违规处理;
|
||||
用户检索与客服视图;评论举报与封禁申诉;订单、退款、补发与权益处理;定价与活动配置;经营大盘;批量操作与受限导出。
|
||||
|
||||
验收要点:权限变更后刷新即生效;关键操作 100% 留痕(操作人、时间、请求与响应);导出受限且可审计。
|
||||
|
||||
### 5.9 分集收费与支付(98 人天)
|
||||
|
||||
需求点:钱包与金币;订单与权益幂等状态机;商品配置与价格分层;Apple IAP 与 Google Play Billing 集成;服务端票据校验与重放防护;
|
||||
恢复购买与跨设备权益;退款、撤销、拒付与权益回收;商店账单对账;付费弹窗与解锁联动;免费集/试看/已购判断;定价实验预留。
|
||||
|
||||
验收要点:
|
||||
|
||||
- 同一票据重复回调只发放一次权益(幂等);
|
||||
- 票据丢失、异步通知延迟时,补偿任务能在 T+1 内自动修复并留痕;
|
||||
- 恢复购买在换设备、重装、切区三种场景下权益一致;
|
||||
- 每日与商店账单自动对账,差异进入人工处理队列。
|
||||
|
||||
### 5.10 质量、数据与上线(38 人天)
|
||||
|
||||
需求点:核心用例自动化与冒烟;接口契约与集成测试;登录/内容/播放/支付/后台压测;安全测试;
|
||||
IAP/Billing 沙盒到权益的端到端验证;埋点与指标一致性验证;UAT;商店素材与提审;灰度与回滚演练;值班与事故响应;备份与灾难恢复。
|
||||
|
||||
## 6. 核心流程与状态机
|
||||
|
||||
**内容状态**:草稿 → 待审核 → 审核中 →(驳回 → 草稿)→ 已通过 → 定时发布 → 已发布 → 已下架 / 版权到期。
|
||||
任何状态下都必须能被运营强制下架,且下架对已缓存列表在 60s 内生效。
|
||||
|
||||
**订单状态**:创建 → 已支付 → 已发放权益;异常分支:支付超时关闭、票据校验失败待补偿、退款中、已退款(回收权益)。
|
||||
所有状态迁移必须幂等,重复回调不改变已终态订单。
|
||||
|
||||
**权益判定优先级**:已购 > 活动免费 > 免费集 > 试看剩余 > 金币可扣 > 需充值。
|
||||
判定结果必须由服务端返回,客户端不得自行推断可播放性。
|
||||
|
||||
**账号状态**:未注册 → 已注册 → (受限 → 申诉中 → 恢复)/ 已注销。封禁与注销必须同步清理会话与缓存。
|
||||
|
||||
## 7. 非功能需求
|
||||
|
||||
### 7.1 性能与容量
|
||||
|
||||
业务方给的目标是"日 1 万加用户同时观看视频流畅"。这里按更严的口径落地:
|
||||
|
||||
| 指标 | 目标 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 并发播放路数 | ≥ 10,000 路不劣化 | 对应 DAU 约 25–35 万;详见后端设计容量推导 |
|
||||
| 首帧时间 P95 | < 1.5s | 4G 网络、360p 起播 |
|
||||
| 卡顿率 | < 2% | 播放时长加权 |
|
||||
| 接口延迟 P95 | < 100ms(客户端热点接口 < 50ms) | 服务端耗时 |
|
||||
| 可用性 | 99.9%(第一阶段) | 月度 |
|
||||
|
||||
若实际口径是"DAU 1 万",资源需求下降约一个数量级,可先按小规格部署、保留水平扩容能力。
|
||||
|
||||
### 7.2 安全
|
||||
|
||||
播放地址签名与防盗链;接口越权校验(水平 + 垂直);支付服务端校验与重放防护;后台敏感操作二次确认与留痕;
|
||||
密钥集中托管与轮换;上线前渗透测试并将高危漏洞清零。
|
||||
|
||||
### 7.3 数据与埋点
|
||||
|
||||
统一指标字典,核心指标单一口径:播放(起播/卡顿/完播)、留存、收入、支付成功率。
|
||||
播放与收入类指标需与财务、商店账单对账,日报可追溯。
|
||||
|
||||
### 7.4 合规
|
||||
|
||||
隐私政策与用户协议随目标市场本地化;同意管理记录可审计;SDK 清单与商店 Data Safety / Privacy Labels 与实际数据流一致;
|
||||
账号注销与数据导出在承诺时限内完成。
|
||||
|
||||
## 8. 核心指标
|
||||
|
||||
- **北极星**:日均有效观看时长(有效定义:单次播放 ≥ 10s 且进度 ≥ 5%)。
|
||||
- **激活漏斗**:安装 → 首次启动 → 首页首播 → 完成 1 集 → 次日留存。
|
||||
- **商业化**:付费转化率、ARPPU、解锁集数、退款率、支付成功率。
|
||||
- **供给**:创作者上传量、审核通过率、平均审核时长、上架内容数。
|
||||
- **质量**:崩溃率、起播 P95、卡顿率、错误码分布。
|
||||
|
||||
## 9. 依赖与前置
|
||||
|
||||
以下前置项的关键路径在外部,研发无法加速,必须在 M0 并行启动(完整清单见评估表"外部前置与采购"):
|
||||
|
||||
1. 海外上架主体、DUNS / 企业验证、Apple 与 Google 组织账号;
|
||||
2. 公司域名、企业邮箱与官网(隐私政策与客服页依赖它);
|
||||
3. 隐私政策、用户协议、社区规范与版权政策;
|
||||
4. 海外短信通道(目标市场到达率与成本必须先压测);
|
||||
5. 云账号、对象存储、数据库、缓存、日志与 CDN(区域选择与预算同步确认);
|
||||
6. Apple / Google 支付账号、税务资料与商品配置;
|
||||
7. 内容版权台账(地区、期限、渠道、转授权范围必须结构化)。
|
||||
|
||||
## 10. 主要风险
|
||||
|
||||
| 风险 | 影响 | 应对 |
|
||||
| --- | --- | --- |
|
||||
| 市场/语言/内容形态未冻结 | 全链路返工 | 设为 M0 门禁,书面签署后进入变更控制 |
|
||||
| 商店账号与主体审核超期 | 无法提审 | 立项首周并行申请,准备备用主体 |
|
||||
| 支付一致性与跨账号权益 | 资损与投诉 | 幂等状态机 + 补偿任务 + 每日对账 + 客服入口 |
|
||||
| 视频与 CDN 成本失控 | 毛利被侵蚀 | 多码率策略、冷热分层、命中率与单位成本看板 |
|
||||
| 短信成本与到达率 | 无法登录 | 多供应商 + 频控 + 图形验证码 + 熔断 |
|
||||
| 内容安全与版权 | 下架或处罚 | 机审 + 人审 + 举报 + 申诉 + 结构化版权台账 |
|
||||
| 范围蔓延 | 无法形成可上线产品 | 冻结 MVP,变更需影响评估与批准 |
|
||||
|
||||
## 11. 里程碑
|
||||
|
||||
| 里程碑 | 内容 | 产出 |
|
||||
| --- | --- | --- |
|
||||
| M0 | 决策与前置 | 四项决策冻结、账号与主体、云与短信采购 |
|
||||
| M1 | 骨架与通道 | 双端与后端骨架、鉴权、存储转码链路、通道接入 |
|
||||
| M2 | 核心链路 | 账号、内容、播放器、记录收藏、创作者上传 |
|
||||
| M3 | 商业化 | 支付与权益、运营后台、内容治理 |
|
||||
| M4 | 提审准备 | 压测与安全测试、埋点验收、商店素材与提审 |
|
||||
| M5 | 上线与运营 | 灰度发布、值班与回滚演练、上线回归 |
|
||||
|
||||
## 12. 交付物清单
|
||||
|
||||
双端 App(iOS / Android)、创作者中心、运营后台、后端服务与 OpenAPI 契约、ERD、
|
||||
转码与媒资流水线、监控与埋点看板、测试与压测报告、安全测试报告、商店提审素材、发布与回滚 Runbook。
|
||||
@@ -5,27 +5,35 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.reelshort</groupId>
|
||||
<artifactId>reelshort</artifactId>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>reelshort-admin-api</artifactId>
|
||||
<name>reelshort-admin-api</name>
|
||||
<artifactId>fruitshorts-admin-api</artifactId>
|
||||
<name>fruitshorts-admin-api</name>
|
||||
<description>管理后台接入层:/admin/**</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.reelshort</groupId>
|
||||
<artifactId>reelshort-module-system</artifactId>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-system</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.reelshort</groupId>
|
||||
<artifactId>reelshort-module-content</artifactId>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-content</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.reelshort</groupId>
|
||||
<artifactId>reelshort-module-media</artifactId>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-media</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-user</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-playback</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
+4
-4
@@ -1,14 +1,14 @@
|
||||
package com.reelshort;
|
||||
package com.fruitshorts;
|
||||
|
||||
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 {
|
||||
@MapperScan("com.fruitshorts.**.dal.mapper")
|
||||
public class FruitShortsApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ReelShortApplication.class, args);
|
||||
SpringApplication.run(FruitShortsApplication.class, args);
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -1,13 +1,13 @@
|
||||
package com.reelshort.admin.controller;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.system.service.AdminUserService;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
import com.fruitshorts.system.vo.AdminUserPageReqVO;
|
||||
import com.fruitshorts.system.vo.AdminUserRespVO;
|
||||
import com.fruitshorts.system.vo.AdminUserSaveReqVO;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package com.fruitshorts.admin.controller;
|
||||
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.admin.vo.AppUserWatchHistoryVO;
|
||||
import com.fruitshorts.content.dal.entity.Drama;
|
||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||
import com.fruitshorts.content.enums.ContentStatus;
|
||||
import com.fruitshorts.content.service.DramaService;
|
||||
import com.fruitshorts.content.service.EpisodeService;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.playback.dal.entity.WatchHistory;
|
||||
import com.fruitshorts.playback.service.WatchProgressService;
|
||||
import com.fruitshorts.user.dal.entity.AppUserBanRecord;
|
||||
import com.fruitshorts.user.service.AppUserAdminService;
|
||||
import com.fruitshorts.user.vo.AppUserQueryReqVO;
|
||||
import com.fruitshorts.user.vo.AppUserAdminRespVO;
|
||||
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.PutMapping;
|
||||
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;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 后台用户管理。 */
|
||||
@RestController
|
||||
@RequestMapping("/admin/app-users")
|
||||
@RequiredArgsConstructor
|
||||
public class AppUserAdminController {
|
||||
|
||||
private final AppUserAdminService appUserAdminService;
|
||||
private final WatchProgressService watchProgressService;
|
||||
private final DramaService dramaService;
|
||||
private final EpisodeService episodeService;
|
||||
|
||||
@GetMapping("/page")
|
||||
@PreAuthorize("@ss.hasPermission('app-user:list')")
|
||||
public Result<PageResult<AppUserAdminRespVO>> page(AppUserQueryReqVO req) {
|
||||
return Result.ok(appUserAdminService.page(req));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("@ss.hasPermission('app-user:list')")
|
||||
public Result<AppUserAdminRespVO> detail(@PathVariable Long id) {
|
||||
return Result.ok(appUserAdminService.detail(id));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/ban-records")
|
||||
@PreAuthorize("@ss.hasPermission('app-user:list')")
|
||||
public Result<List<AppUserBanRecord>> banRecords(@PathVariable Long id) {
|
||||
return Result.ok(appUserAdminService.banRecords(id));
|
||||
}
|
||||
|
||||
/** 观看流水明细:客服核对"用户说看了但没记录"这类问题时用,带上剧名与集号。 */
|
||||
@GetMapping("/{id}/watch-history")
|
||||
@PreAuthorize("@ss.hasPermission('app-user:list')")
|
||||
public Result<List<AppUserWatchHistoryVO>> watchHistory(@PathVariable Long id,
|
||||
@RequestParam(defaultValue = "50") int limit) {
|
||||
List<WatchHistory> rows = watchProgressService.history(id, limit);
|
||||
if (rows.isEmpty()) {
|
||||
return Result.ok(List.of());
|
||||
}
|
||||
// 批量取剧名与集号:一屏 50 行逐条查库就是 100 次往返
|
||||
Map<Long, Drama> dramas = dramaService
|
||||
.listByIds(rows.stream().map(WatchHistory::getDramaId).distinct().toList())
|
||||
.stream().collect(Collectors.toMap(Drama::getId, item -> item, (a, b) -> a));
|
||||
Map<Long, DramaEpisode> episodes = episodeService
|
||||
.listByIds(rows.stream().map(WatchHistory::getEpisodeId).distinct().toList())
|
||||
.stream().collect(Collectors.toMap(DramaEpisode::getId, item -> item, (a, b) -> a));
|
||||
return Result.ok(rows.stream().map(row -> toVo(row, dramas, episodes)).toList());
|
||||
}
|
||||
|
||||
private AppUserWatchHistoryVO toVo(WatchHistory row, Map<Long, Drama> dramas,
|
||||
Map<Long, DramaEpisode> episodes) {
|
||||
AppUserWatchHistoryVO vo = new AppUserWatchHistoryVO();
|
||||
vo.setId(row.getId());
|
||||
vo.setDramaId(row.getDramaId());
|
||||
vo.setEpisodeId(row.getEpisodeId());
|
||||
vo.setPositionMs(row.getPositionMs());
|
||||
vo.setDurationMs(row.getDurationMs());
|
||||
vo.setSource(row.getSource());
|
||||
vo.setDeviceId(row.getDeviceId());
|
||||
vo.setWatchedAt(row.getWatchedAt());
|
||||
long position = row.getPositionMs() == null ? 0L : row.getPositionMs();
|
||||
long duration = row.getDurationMs() == null ? 0L : row.getDurationMs();
|
||||
vo.setFinished(duration > 0 && position + 1000L >= duration);
|
||||
Drama drama = dramas.get(row.getDramaId());
|
||||
if (drama != null) {
|
||||
vo.setDramaTitle(drama.getTitle());
|
||||
vo.setDramaPublished(ContentStatus.PUBLISHED.getCode().equals(drama.getStatus()));
|
||||
} else {
|
||||
vo.setDramaPublished(false);
|
||||
}
|
||||
DramaEpisode episode = episodes.get(row.getEpisodeId());
|
||||
if (episode != null) {
|
||||
vo.setEpisodeNo(episode.getEpNo());
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 状态处置:normal/limited/banned。封禁会立即吊销该用户全部会话。 */
|
||||
@PutMapping("/{id}/status")
|
||||
@PreAuthorize("@ss.hasPermission('app-user:update')")
|
||||
@OperationLog(module = "用户管理", action = "修改用户状态")
|
||||
public Result<Void> updateStatus(@PathVariable Long id,
|
||||
@RequestParam String status,
|
||||
@RequestParam(required = false) String reason) {
|
||||
appUserAdminService.updateStatus(id, status, reason, SecurityUtils.getUserId());
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -1,15 +1,15 @@
|
||||
package com.reelshort.admin.controller;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
import com.fruitshorts.framework.web.WebUtils;
|
||||
import com.fruitshorts.system.service.AuthService;
|
||||
import com.fruitshorts.system.vo.LoginReqVO;
|
||||
import com.fruitshorts.system.vo.LoginRespVO;
|
||||
import com.fruitshorts.system.vo.MenuVO;
|
||||
import com.fruitshorts.system.vo.PasswordChangeReqVO;
|
||||
import com.fruitshorts.system.vo.ProfileRespVO;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
+8
-8
@@ -1,12 +1,12 @@
|
||||
package com.reelshort.admin.controller;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.content.service.BannerService;
|
||||
import com.fruitshorts.content.vo.BannerRespVO;
|
||||
import com.fruitshorts.content.vo.BannerSaveReqVO;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
package com.reelshort.admin.controller;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.system.service.ConfigService;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
import com.fruitshorts.system.vo.ConfigItemVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
package com.reelshort.admin.controller;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.content.enums.ContentStatus;
|
||||
import com.fruitshorts.content.service.DramaCategoryService;
|
||||
import com.fruitshorts.content.vo.DramaCategoryRespVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.fruitshorts.admin.controller;
|
||||
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
import com.fruitshorts.playback.service.DownloadService;
|
||||
import com.fruitshorts.playback.vo.DownloadLicenseRespVO;
|
||||
import com.fruitshorts.playback.vo.DownloadQueryReqVO;
|
||||
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.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** 后台下载授权管理。 */
|
||||
@RestController
|
||||
@RequestMapping("/admin/downloads")
|
||||
@RequiredArgsConstructor
|
||||
public class DownloadAdminController {
|
||||
|
||||
private final DownloadService downloadService;
|
||||
|
||||
@GetMapping("/page")
|
||||
@PreAuthorize("@ss.hasPermission('download:list')")
|
||||
public Result<PageResult<DownloadLicenseRespVO>> page(DownloadQueryReqVO req) {
|
||||
return Result.ok(downloadService.page(req));
|
||||
}
|
||||
|
||||
@GetMapping("/stats")
|
||||
@PreAuthorize("@ss.hasPermission('download:list')")
|
||||
public Result<Map<String, Object>> stats() {
|
||||
return Result.ok(downloadService.stats());
|
||||
}
|
||||
|
||||
/** 吊销单条授权:用户侧下次联网即失效。 */
|
||||
@PutMapping("/{id}/revoke")
|
||||
@PreAuthorize("@ss.hasPermission('download:revoke')")
|
||||
@OperationLog(module = "下载管理", action = "吊销下载授权")
|
||||
public Result<Void> revoke(@PathVariable Long id) {
|
||||
downloadService.adminRevoke(id);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/** 手动触发过期清理,正常情况下由定时任务每天执行。 */
|
||||
@PostMapping("/cleanup")
|
||||
@PreAuthorize("@ss.hasPermission('download:revoke')")
|
||||
@OperationLog(module = "下载管理", action = "清理过期下载授权")
|
||||
public Result<Integer> cleanup() {
|
||||
return Result.ok(downloadService.expireLicenses());
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -1,11 +1,11 @@
|
||||
package com.reelshort.admin.controller;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.content.service.DramaCategoryService;
|
||||
import com.fruitshorts.content.vo.DramaCategoryRespVO;
|
||||
import com.fruitshorts.content.vo.DramaCategorySaveReqVO;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
+11
-11
@@ -1,15 +1,15 @@
|
||||
package com.reelshort.admin.controller;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.content.dal.entity.DramaReviewRecord;
|
||||
import com.fruitshorts.content.service.DramaService;
|
||||
import com.fruitshorts.content.vo.DramaPageReqVO;
|
||||
import com.fruitshorts.content.vo.DramaRespVO;
|
||||
import com.fruitshorts.content.vo.DramaSaveReqVO;
|
||||
import com.fruitshorts.content.vo.DramaStatusReqVO;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
+7
-7
@@ -1,11 +1,11 @@
|
||||
package com.reelshort.admin.controller;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.content.service.EpisodeService;
|
||||
import com.fruitshorts.content.vo.EpisodeRespVO;
|
||||
import com.fruitshorts.content.vo.EpisodeSaveReqVO;
|
||||
import com.fruitshorts.content.vo.EpisodeSortReqVO;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
+8
-8
@@ -1,12 +1,12 @@
|
||||
package com.reelshort.admin.controller;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.media.service.MediaService;
|
||||
import com.fruitshorts.media.vo.MediaAssetRespVO;
|
||||
import com.fruitshorts.media.vo.UploadCompleteReqVO;
|
||||
import com.fruitshorts.media.vo.UploadInitReqVO;
|
||||
import com.fruitshorts.media.vo.UploadInitRespVO;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
+6
-6
@@ -1,10 +1,10 @@
|
||||
package com.reelshort.admin.controller;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.system.service.MenuService;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
import com.fruitshorts.system.vo.MenuSaveReqVO;
|
||||
import com.fruitshorts.system.vo.MenuVO;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
+6
-6
@@ -1,10 +1,10 @@
|
||||
package com.reelshort.admin.controller;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.system.service.OperationLogService;
|
||||
import com.fruitshorts.system.vo.OperationLogPageReqVO;
|
||||
import com.fruitshorts.system.vo.OperationLogRespVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
package com.reelshort.admin.controller;
|
||||
package com.fruitshorts.admin.controller;
|
||||
|
||||
import com.reelshort.common.pojo.Result;
|
||||
import com.reelshort.system.service.PermissionService;
|
||||
import com.reelshort.system.vo.PermissionGroupVO;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.system.service.PermissionService;
|
||||
import com.fruitshorts.system.vo.PermissionGroupVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
+6
-6
@@ -1,10 +1,10 @@
|
||||
package com.reelshort.admin.controller;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.system.service.RoleCategoryService;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
import com.fruitshorts.system.vo.RoleCategoryRespVO;
|
||||
import com.fruitshorts.system.vo.RoleCategorySaveReqVO;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
+8
-8
@@ -1,12 +1,12 @@
|
||||
package com.reelshort.admin.controller;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.system.service.RoleService;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
import com.fruitshorts.system.vo.RolePermissionSaveReqVO;
|
||||
import com.fruitshorts.system.vo.RoleRespVO;
|
||||
import com.fruitshorts.system.vo.RoleSaveReqVO;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.fruitshorts.admin.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 后台观看流水行。
|
||||
*
|
||||
* <p>比原始流水多了剧名与集号:客服核对问题时看「闪婚后 · 第 3 集」比看
|
||||
* 「dramaId=5 / episodeId=9」有用得多。剧已下架时仍然显示剧名,只标注状态。
|
||||
*/
|
||||
@Data
|
||||
public class AppUserWatchHistoryVO {
|
||||
|
||||
private Long id;
|
||||
private Long dramaId;
|
||||
private String dramaTitle;
|
||||
/** 短剧当前是否已上线,用于识别"用户说看了但剧已下线"的情况 */
|
||||
private Boolean dramaPublished;
|
||||
private Long episodeId;
|
||||
private Integer episodeNo;
|
||||
private Long positionMs;
|
||||
private Long durationMs;
|
||||
/** 是否已看完(进度到达片尾 1 秒内) */
|
||||
private Boolean finished;
|
||||
private String source;
|
||||
private String deviceId;
|
||||
private LocalDateTime watchedAt;
|
||||
}
|
||||
+7
-7
@@ -3,14 +3,14 @@ server:
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: reelshort-admin-api
|
||||
name: fruitshorts-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}
|
||||
url: jdbc:postgresql://127.0.0.1:5432/fruitshorts
|
||||
username: ${DB_USER:fruitshorts}
|
||||
password: ${DB_PASSWORD:fruitshorts}
|
||||
jackson:
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
time-zone: Asia/Shanghai
|
||||
@@ -31,7 +31,7 @@ mybatis-plus:
|
||||
logic-delete-value: 1
|
||||
logic-not-delete-value: 0
|
||||
|
||||
reelshort:
|
||||
fruitshorts:
|
||||
media:
|
||||
# 本地媒资目录(相对应用启动目录),通过 /files/** 访问
|
||||
root-dir: ./data/uploads
|
||||
@@ -39,7 +39,7 @@ reelshort:
|
||||
chunk-size: 5242880
|
||||
max-file-size: 2147483648
|
||||
jwt:
|
||||
admin-secret: ${JWT_ADMIN_SECRET:reelshort-admin-dev-secret-change-me-in-production-32b}
|
||||
admin-secret: ${JWT_ADMIN_SECRET:fruitshorts-admin-dev-secret-change-me-in-production-32b}
|
||||
admin-ttl-hours: 12
|
||||
cors:
|
||||
allowed-origins: http://localhost:3005,http://127.0.0.1:3005
|
||||
@@ -50,4 +50,4 @@ springdoc:
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.reelshort: debug
|
||||
com.fruitshorts: debug
|
||||
@@ -5,23 +5,31 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.reelshort</groupId>
|
||||
<artifactId>reelshort</artifactId>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>reelshort-app-api</artifactId>
|
||||
<name>reelshort-app-api</name>
|
||||
<artifactId>fruitshorts-app-api</artifactId>
|
||||
<name>fruitshorts-app-api</name>
|
||||
<description>客户端接入层:/app/**</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.reelshort</groupId>
|
||||
<artifactId>reelshort-module-content</artifactId>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-content</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.reelshort</groupId>
|
||||
<artifactId>reelshort-module-media</artifactId>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-media</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-user</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-playback</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.app;
|
||||
package com.fruitshorts.app;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
@@ -6,11 +6,11 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* 客户端接口服务,只暴露 /app/** 与静态媒资 /files/**。
|
||||
* 与 reelshort-admin-api 共用同一套领域模块和数据库,但入口、鉴权和限流策略独立。
|
||||
* 与 fruitshorts-admin-api 共用同一套领域模块和数据库,但入口、鉴权和限流策略独立。
|
||||
*/
|
||||
/* 显式指定扫描根包:本类在 com.reelshort.app 下,默认扫不到 content/system/framework 模块的 Bean */
|
||||
@SpringBootApplication(scanBasePackages = "com.reelshort")
|
||||
@MapperScan("com.reelshort.**.dal.mapper")
|
||||
/* 显式指定扫描根包:本类在 com.fruitshorts.app 下,默认扫不到 content/system/framework 模块的 Bean */
|
||||
@SpringBootApplication(scanBasePackages = "com.fruitshorts")
|
||||
@MapperScan("com.fruitshorts.**.dal.mapper")
|
||||
public class AppApiApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.fruitshorts.app.controller;
|
||||
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.framework.web.WebUtils;
|
||||
import com.fruitshorts.user.service.AppAuthService;
|
||||
import com.fruitshorts.user.service.AppOtpService;
|
||||
import com.fruitshorts.user.vo.LoginReqVO;
|
||||
import com.fruitshorts.user.vo.LogoutReqVO;
|
||||
import com.fruitshorts.user.vo.OtpSendReqVO;
|
||||
import com.fruitshorts.user.vo.RefreshReqVO;
|
||||
import com.fruitshorts.user.vo.RegisterReqVO;
|
||||
import com.fruitshorts.user.vo.TokenRespVO;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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;
|
||||
|
||||
/** 客户端账号:验证码、注册、登录、刷新、登出。 */
|
||||
@RestController
|
||||
@RequestMapping("/app/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AppAuthController {
|
||||
|
||||
private final AppOtpService otpService;
|
||||
private final AppAuthService authService;
|
||||
|
||||
/** 发送验证码。频控在服务端,客户端不需要自己限流。 */
|
||||
@PostMapping("/otp")
|
||||
public Result<Void> sendOtp(@Valid @RequestBody OtpSendReqVO req, HttpServletRequest request) {
|
||||
otpService.send(req, WebUtils.getClientIp(request));
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public Result<TokenRespVO> register(@Valid @RequestBody RegisterReqVO req,
|
||||
HttpServletRequest request) {
|
||||
return Result.ok(authService.register(req, WebUtils.getClientIp(request)));
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public Result<TokenRespVO> login(@Valid @RequestBody LoginReqVO req,
|
||||
HttpServletRequest request) {
|
||||
return Result.ok(authService.login(req, WebUtils.getClientIp(request)));
|
||||
}
|
||||
|
||||
@PostMapping("/refresh")
|
||||
public Result<TokenRespVO> refresh(@Valid @RequestBody RefreshReqVO req) {
|
||||
return Result.ok(authService.refresh(req.getRefreshToken()));
|
||||
}
|
||||
|
||||
/** 登出:默认只吊销当前设备的会话,allDevices=true 时吊销全部。 */
|
||||
@PostMapping("/logout")
|
||||
public Result<Void> logout(@RequestBody(required = false) LogoutReqVO req) {
|
||||
authService.logout(SecurityUtils.getUserIdOrNull(), req == null ? new LogoutReqVO() : req);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
package com.reelshort.app.controller;
|
||||
package com.fruitshorts.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 com.fruitshorts.app.service.AppCatalogService;
|
||||
import com.fruitshorts.app.vo.AppCategoryVO;
|
||||
import com.fruitshorts.app.vo.AppFeedVO;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package com.fruitshorts.app.controller;
|
||||
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.playback.service.DownloadService;
|
||||
import com.fruitshorts.playback.vo.DownloadGrantReqVO;
|
||||
import com.fruitshorts.playback.vo.DownloadLicenseRespVO;
|
||||
import com.fruitshorts.playback.vo.DownloadPolicyRespVO;
|
||||
import com.fruitshorts.playback.vo.DownloadProgressReqVO;
|
||||
import com.fruitshorts.playback.vo.DownloadSettingReqVO;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 离线下载。
|
||||
*
|
||||
* <p>服务端只发授权与地址,分片由客户端向 CDN 拉取;进度上报用于断点续传与跨端展示。
|
||||
* 设备标识优先取请求体,其次取请求头 X-Device-Id。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/app/downloads")
|
||||
@RequiredArgsConstructor
|
||||
public class AppDownloadController {
|
||||
|
||||
private final DownloadService downloadService;
|
||||
|
||||
/** 申请下载授权(同一剧集重复申请幂等,只刷新有效期)。 */
|
||||
@PostMapping
|
||||
public Result<DownloadLicenseRespVO> grant(@Valid @RequestBody DownloadGrantReqVO req,
|
||||
HttpServletRequest request) {
|
||||
return Result.ok(downloadService.grant(SecurityUtils.getAppUserId(), req,
|
||||
request.getHeader("X-Device-Id")));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public Result<List<DownloadLicenseRespVO>> list(@RequestParam(required = false) String deviceId) {
|
||||
return Result.ok(downloadService.list(SecurityUtils.getAppUserId(), deviceId));
|
||||
}
|
||||
|
||||
@GetMapping("/policy")
|
||||
public Result<DownloadPolicyRespVO> policy(@RequestParam(required = false) String deviceId) {
|
||||
return Result.ok(downloadService.policy(SecurityUtils.getAppUserId(), deviceId));
|
||||
}
|
||||
|
||||
/** 仅 Wi-Fi 下载、清晰度上限等偏好。 */
|
||||
@PutMapping("/settings")
|
||||
public Result<Void> updateSettings(@RequestBody DownloadSettingReqVO req) {
|
||||
downloadService.updateSettings(SecurityUtils.getAppUserId(), req);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/** 断点续传前重新取一次地址:授权不变,只有地址是短时效的。 */
|
||||
@GetMapping("/{episodeId}/url")
|
||||
public Result<DownloadLicenseRespVO> refreshUrl(@PathVariable Long episodeId,
|
||||
HttpServletRequest request) {
|
||||
return Result.ok(downloadService.refreshUrl(SecurityUtils.getAppUserId(), episodeId,
|
||||
request.getHeader("X-Device-Id")));
|
||||
}
|
||||
|
||||
@PostMapping("/{episodeId}/progress")
|
||||
public Result<DownloadLicenseRespVO> reportProgress(@PathVariable Long episodeId,
|
||||
@RequestBody DownloadProgressReqVO req,
|
||||
HttpServletRequest request) {
|
||||
return Result.ok(downloadService.reportProgress(SecurityUtils.getAppUserId(), episodeId, req,
|
||||
request.getHeader("X-Device-Id")));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{episodeId}")
|
||||
public Result<Void> revoke(@PathVariable Long episodeId,
|
||||
@RequestParam(required = false) String deviceId,
|
||||
HttpServletRequest request) {
|
||||
downloadService.revoke(SecurityUtils.getAppUserId(), episodeId,
|
||||
deviceId != null ? deviceId : request.getHeader("X-Device-Id"));
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/** 清空当前设备的全部下载授权。 */
|
||||
@DeleteMapping
|
||||
public Result<Integer> clear(@RequestParam(required = false) String deviceId,
|
||||
HttpServletRequest request) {
|
||||
return Result.ok(downloadService.clear(SecurityUtils.getAppUserId(),
|
||||
deviceId != null ? deviceId : request.getHeader("X-Device-Id")));
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.fruitshorts.app.controller;
|
||||
|
||||
import com.fruitshorts.app.service.AppCatalogService;
|
||||
import com.fruitshorts.app.vo.AppDramaDetailVO;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.framework.web.WebUtils;
|
||||
import com.fruitshorts.playback.service.PlaybackService;
|
||||
import com.fruitshorts.playback.vo.PlayRespVO;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
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;
|
||||
private final PlaybackService playbackService;
|
||||
|
||||
/** 详情:包含剧集列表,客户端进详情页一次请求即可渲染 */
|
||||
@GetMapping("/dramas/{id}")
|
||||
public Result<AppDramaDetailVO> detail(@PathVariable Long id) {
|
||||
return Result.ok(appCatalogService.dramaDetail(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放票据:服务端判定权益后签发短时效播放地址。
|
||||
* 未登录也能播免费集;付费集未解锁时只返回需要多少金币。
|
||||
*/
|
||||
@GetMapping("/episodes/{id}/play")
|
||||
public Result<PlayRespVO> play(@PathVariable Long id, HttpServletRequest request) {
|
||||
return Result.ok(playbackService.play(
|
||||
SecurityUtils.getUserIdOrNull(),
|
||||
id,
|
||||
request.getHeader("X-Device-Id"),
|
||||
WebUtils.getClientIp(request)));
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.fruitshorts.app.controller;
|
||||
|
||||
import com.fruitshorts.app.service.AppCatalogService;
|
||||
import com.fruitshorts.app.vo.AppDramaCardVO;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.playback.service.FavoriteService;
|
||||
import com.fruitshorts.playback.vo.FavoriteToggleReqVO;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 收藏。 */
|
||||
@RestController
|
||||
@RequestMapping("/app/favorites")
|
||||
@RequiredArgsConstructor
|
||||
public class AppFavoriteController {
|
||||
|
||||
private final FavoriteService favoriteService;
|
||||
private final AppCatalogService appCatalogService;
|
||||
|
||||
/** 切换收藏,返回切换后的状态:true 为已收藏。 */
|
||||
@PostMapping("/toggle")
|
||||
public Result<Boolean> toggle(@Valid @RequestBody FavoriteToggleReqVO req) {
|
||||
return Result.ok(favoriteService.toggle(SecurityUtils.getAppUserId(), req.getDramaId()));
|
||||
}
|
||||
|
||||
/** 收藏列表,卡片结构与首页一致。 */
|
||||
@GetMapping
|
||||
public Result<List<AppDramaCardVO>> list() {
|
||||
return Result.ok(appCatalogService.toCards(
|
||||
favoriteService.listDramas(SecurityUtils.getAppUserId())));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{dramaId}")
|
||||
public Result<Void> remove(@PathVariable Long dramaId) {
|
||||
favoriteService.remove(SecurityUtils.getAppUserId(), dramaId);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.fruitshorts.app.controller;
|
||||
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.user.dal.entity.AppUserDevice;
|
||||
import com.fruitshorts.user.service.AppUserService;
|
||||
import com.fruitshorts.user.vo.AppUserRespVO;
|
||||
import com.fruitshorts.user.vo.DeactivateReqVO;
|
||||
import com.fruitshorts.user.vo.ProfileUpdateReqVO;
|
||||
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.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 客户端个人中心:资料、设备、注销、数据导出。 */
|
||||
@RestController
|
||||
@RequestMapping("/app/me")
|
||||
@RequiredArgsConstructor
|
||||
public class AppMeController {
|
||||
|
||||
private final AppUserService userService;
|
||||
|
||||
@GetMapping
|
||||
public Result<AppUserRespVO> me() {
|
||||
return Result.ok(userService.me(SecurityUtils.getAppUserId()));
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
public Result<AppUserRespVO> update(@Valid @RequestBody ProfileUpdateReqVO req) {
|
||||
return Result.ok(userService.updateProfile(SecurityUtils.getAppUserId(), req));
|
||||
}
|
||||
|
||||
@GetMapping("/devices")
|
||||
public Result<List<AppUserDevice>> devices() {
|
||||
return Result.ok(userService.devices(SecurityUtils.getAppUserId()));
|
||||
}
|
||||
|
||||
/** 商店与隐私法规要求的数据导出,返回账号侧数据;内容侧数据由客户端自行下载。 */
|
||||
@GetMapping("/export")
|
||||
public Result<Map<String, Object>> export() {
|
||||
return Result.ok(userService.exportAccount(SecurityUtils.getAppUserId()));
|
||||
}
|
||||
|
||||
/** 申请注销:立即登出全部设备,进入冷静期,期间可撤销。 */
|
||||
@PostMapping("/deactivate")
|
||||
public Result<LocalDateTime> deactivate(@RequestBody(required = false) DeactivateReqVO req) {
|
||||
return Result.ok(userService.requestDeactivation(SecurityUtils.getAppUserId(), req));
|
||||
}
|
||||
|
||||
@PostMapping("/deactivate/cancel")
|
||||
public Result<Void> cancelDeactivate() {
|
||||
userService.cancelDeactivation(SecurityUtils.getAppUserId());
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.fruitshorts.app.controller;
|
||||
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.playback.service.WatchProgressService;
|
||||
import com.fruitshorts.playback.vo.ProgressRespVO;
|
||||
import com.fruitshorts.playback.vo.ProgressSaveReqVO;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.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("/app/watch")
|
||||
@RequiredArgsConstructor
|
||||
public class AppWatchController {
|
||||
|
||||
private final WatchProgressService progressService;
|
||||
|
||||
/** 上报进度:客户端每 10 秒一次,切集时立即上报一次。 */
|
||||
@PostMapping("/progress")
|
||||
public Result<ProgressRespVO> save(@Valid @RequestBody ProgressSaveReqVO req) {
|
||||
return Result.ok(progressService.save(SecurityUtils.getAppUserId(), req));
|
||||
}
|
||||
|
||||
/** 某部剧的续看位置,进详情页时用它决定"续看第 N 集"。 */
|
||||
@GetMapping("/progress")
|
||||
public Result<ProgressRespVO> ofDrama(@RequestParam Long dramaId) {
|
||||
return Result.ok(progressService.ofDrama(SecurityUtils.getAppUserId(), dramaId));
|
||||
}
|
||||
|
||||
@GetMapping("/recent")
|
||||
public Result<List<ProgressRespVO>> recent(@RequestParam(defaultValue = "20") int limit) {
|
||||
return Result.ok(progressService.recent(SecurityUtils.getAppUserId(), limit));
|
||||
}
|
||||
|
||||
@DeleteMapping("/progress/{dramaId}")
|
||||
public Result<Void> remove(@PathVariable Long dramaId) {
|
||||
progressService.remove(SecurityUtils.getAppUserId(), dramaId);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@DeleteMapping("/progress")
|
||||
public Result<Void> clear() {
|
||||
progressService.clear(SecurityUtils.getAppUserId());
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
+41
-49
@@ -1,22 +1,22 @@
|
||||
package com.reelshort.app.service;
|
||||
package com.fruitshorts.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 com.fruitshorts.app.vo.AppBannerVO;
|
||||
import com.fruitshorts.app.vo.AppCategoryVO;
|
||||
import com.fruitshorts.app.vo.AppDramaCardVO;
|
||||
import com.fruitshorts.app.vo.AppDramaDetailVO;
|
||||
import com.fruitshorts.app.vo.AppEpisodeVO;
|
||||
import com.fruitshorts.app.vo.AppFeedVO;
|
||||
import com.fruitshorts.app.vo.AppSectionVO;
|
||||
import com.fruitshorts.content.dal.entity.ContentBanner;
|
||||
import com.fruitshorts.content.dal.entity.Drama;
|
||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||
import com.fruitshorts.content.enums.BannerPosition;
|
||||
import com.fruitshorts.content.service.BannerService;
|
||||
import com.fruitshorts.content.service.DramaCategoryService;
|
||||
import com.fruitshorts.content.service.DramaService;
|
||||
import com.fruitshorts.content.service.EpisodeService;
|
||||
import com.fruitshorts.media.service.PlaybackSigner;
|
||||
import com.fruitshorts.content.vo.DramaCategoryRespVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -41,6 +41,7 @@ public class AppCatalogService {
|
||||
private final EpisodeService episodeService;
|
||||
private final BannerService bannerService;
|
||||
private final DramaCategoryService dramaCategoryService;
|
||||
private final PlaybackSigner playbackSigner;
|
||||
|
||||
public AppFeedVO homeFeed() {
|
||||
Map<Long, String> categoryNames = categoryNameMap();
|
||||
@@ -65,6 +66,17 @@ public class AppCatalogService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 供收藏、观看记录等列表复用卡片结构:字段与首页卡片完全一致,
|
||||
* 客户端可以用同一套渲染逻辑,不用为每个列表各写一份。
|
||||
*/
|
||||
public List<AppDramaCardVO> toCards(List<Drama> dramas) {
|
||||
if (dramas == null || dramas.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return cards(dramas, categoryNameMap());
|
||||
}
|
||||
|
||||
public AppDramaDetailVO dramaDetail(Long dramaId) {
|
||||
Drama drama = dramaService.getPublished(dramaId);
|
||||
List<DramaEpisode> episodes = episodeService.listPublishedByDrama(dramaId);
|
||||
@@ -86,35 +98,6 @@ public class AppCatalogService {
|
||||
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();
|
||||
}
|
||||
@@ -189,12 +172,21 @@ public class AppCatalogService {
|
||||
vo.setIsFree(free);
|
||||
vo.setLocked(!free);
|
||||
vo.setUnlockCoin(episode.getUnlockCoin());
|
||||
if (free && episode.getVideoUrl() != null && !episode.getVideoUrl().isBlank()) {
|
||||
vo.setPlayUrl(episode.getVideoUrl());
|
||||
if (free) {
|
||||
// 播放地址统一在这里签名:客户端拿不到可长期复用的裸地址
|
||||
vo.setPlayUrl(playbackSigner.sign(playableSource(episode)));
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 有 HLS 多码率就用 HLS,否则退回原文件。 */
|
||||
private String playableSource(DramaEpisode episode) {
|
||||
if (episode.getHlsUrl() != null && !episode.getHlsUrl().isBlank()) {
|
||||
return episode.getHlsUrl();
|
||||
}
|
||||
return episode.getVideoUrl() == null ? "" : episode.getVideoUrl();
|
||||
}
|
||||
|
||||
private Map<Long, String> categoryNameMap() {
|
||||
Map<Long, String> names = new HashMap<>();
|
||||
dramaCategoryService.listBasic().forEach(category -> names.put(category.getId(), category.getName()));
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.app.vo;
|
||||
package com.fruitshorts.app.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.app.vo;
|
||||
package com.fruitshorts.app.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.app.vo;
|
||||
package com.fruitshorts.app.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.app.vo;
|
||||
package com.fruitshorts.app.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.app.vo;
|
||||
package com.fruitshorts.app.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.app.vo;
|
||||
package com.fruitshorts.app.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.app.vo;
|
||||
package com.fruitshorts.app.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
+13
-7
@@ -3,14 +3,14 @@ server:
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: reelshort-app-api
|
||||
name: fruitshorts-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}
|
||||
url: jdbc:postgresql://127.0.0.1:5432/fruitshorts
|
||||
username: ${DB_USER:fruitshorts}
|
||||
password: ${DB_PASSWORD:fruitshorts}
|
||||
jackson:
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
time-zone: Asia/Shanghai
|
||||
@@ -26,18 +26,24 @@ mybatis-plus:
|
||||
logic-delete-value: 1
|
||||
logic-not-delete-value: 0
|
||||
|
||||
reelshort:
|
||||
fruitshorts:
|
||||
download:
|
||||
# 这些值会通过 /app/downloads/policy 下发给客户端,改这里不用发版
|
||||
max-per-device: 20
|
||||
license-days: 30
|
||||
wifi-only-default: true
|
||||
max-quality: AUTO
|
||||
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-secret: ${JWT_ADMIN_SECRET:fruitshorts-admin-dev-secret-change-me-in-production-32b}
|
||||
admin-ttl-hours: 12
|
||||
cors:
|
||||
allowed-origins: "*"
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.reelshort: info
|
||||
com.fruitshorts: info
|
||||
@@ -5,12 +5,12 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.reelshort</groupId>
|
||||
<artifactId>reelshort</artifactId>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>reelshort-common</artifactId>
|
||||
<name>reelshort-common</name>
|
||||
<artifactId>fruitshorts-common</artifactId>
|
||||
<name>fruitshorts-common</name>
|
||||
<description>通用模块:统一响应、分页、异常、工具</description>
|
||||
</project>
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.common.exception;
|
||||
package com.fruitshorts.common.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.common.exception;
|
||||
package com.fruitshorts.common.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.common.pojo;
|
||||
package com.fruitshorts.common.pojo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.common.pojo;
|
||||
package com.fruitshorts.common.pojo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
package com.reelshort.common.pojo;
|
||||
package com.fruitshorts.common.pojo;
|
||||
|
||||
import com.reelshort.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
@@ -5,19 +5,19 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.reelshort</groupId>
|
||||
<artifactId>reelshort</artifactId>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>reelshort-framework</artifactId>
|
||||
<name>reelshort-framework</name>
|
||||
<artifactId>fruitshorts-framework</artifactId>
|
||||
<name>fruitshorts-framework</name>
|
||||
<description>技术框架:安全、鉴权、持久层、Web 配置</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.reelshort</groupId>
|
||||
<artifactId>reelshort-common</artifactId>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-common</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.framework.config;
|
||||
package com.fruitshorts.framework.config;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.fruitshorts.framework.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/** 打开定时任务:目前用于分区维护与后续的对账、计数聚合。 */
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
public class SchedulingConfig {
|
||||
}
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
package com.reelshort.framework.log;
|
||||
package com.fruitshorts.framework.log;
|
||||
|
||||
import com.reelshort.framework.security.LoginUser;
|
||||
import com.reelshort.framework.security.SecurityUtils;
|
||||
import com.reelshort.framework.web.WebUtils;
|
||||
import com.fruitshorts.framework.security.LoginUser;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.framework.web.WebUtils;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.framework.log;
|
||||
package com.fruitshorts.framework.log;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
package com.reelshort.framework.log;
|
||||
package com.fruitshorts.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 com.fruitshorts.framework.security.LoginUser;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.framework.web.WebUtils;
|
||||
import jakarta.servlet.ServletRequest;
|
||||
import jakarta.servlet.ServletResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.framework.log;
|
||||
package com.fruitshorts.framework.log;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.framework.log;
|
||||
package com.fruitshorts.framework.log;
|
||||
|
||||
/**
|
||||
* 操作日志落库的扩展点,由业务模块实现(框架层不依赖具体存储)。
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package com.fruitshorts.framework.partition;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 分区自动维护。
|
||||
*
|
||||
* <p>声明式分区不会自己长出来:只建 default 分区的话,数据全堆在 default 里,
|
||||
* 分区就失去意义,而且往后拆分代价很高。所以启动时和每天凌晨各跑一次:
|
||||
* <ol>
|
||||
* <li>确保当前月与未来 N 个月的分区存在;</li>
|
||||
* <li>按配置清理过期分区(默认关闭,只建不删)。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>维护函数在 sql/partition-maintenance.sql,这里只负责按时调用。
|
||||
* 函数不存在(例如环境还没执行脚本)时只告警不阻断启动。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PartitionMaintainer {
|
||||
|
||||
/** 分区表与各自的保留月数;保留月数 <= 0 表示不清理。 */
|
||||
private static final List<PartitionedTable> TABLES = List.of(
|
||||
new PartitionedTable("watch_history", 24),
|
||||
new PartitionedTable("playback_ticket", 6),
|
||||
new PartitionedTable("order_event", 36));
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PartitionProperties properties;
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void onStartup() {
|
||||
if (properties.isEnabled()) {
|
||||
maintain();
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(cron = "${fruitshorts.partition.cron:0 17 3 * * *}")
|
||||
public void scheduled() {
|
||||
if (properties.isEnabled()) {
|
||||
maintain();
|
||||
}
|
||||
}
|
||||
|
||||
public void maintain() {
|
||||
for (PartitionedTable table : TABLES) {
|
||||
try {
|
||||
Integer created = jdbcTemplate.queryForObject(
|
||||
"select ensure_monthly_partitions(?, current_date, ?)",
|
||||
Integer.class, table.name(), properties.getMonthsAhead() + 1);
|
||||
if (created != null && created > 0) {
|
||||
log.info("分区维护:{} 新建 {} 个分区", table.name(), created);
|
||||
}
|
||||
if (properties.isRetentionEnabled() && table.retentionMonths() > 0) {
|
||||
Integer dropped = jdbcTemplate.queryForObject(
|
||||
"select drop_partitions_before(?, (current_date - make_interval(months => ?))::date)",
|
||||
Integer.class, table.name(), table.retentionMonths());
|
||||
if (dropped != null && dropped > 0) {
|
||||
log.warn("分区维护:{} 清理了 {} 个过期分区", table.name(), dropped);
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
// 表还没建或函数没装:不阻断启动,但要说清楚怎么办
|
||||
log.warn("分区维护跳过 {}:{}(确认已执行 sql/partition-maintenance.sql)",
|
||||
table.name(), ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record PartitionedTable(String name, int retentionMonths) {
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.fruitshorts.framework.partition;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** 分区维护配置。 */
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "fruitshorts.partition")
|
||||
public class PartitionProperties {
|
||||
|
||||
/** 是否启用自动维护。关掉之后需要人工执行 sql/partition-maintenance.sql。 */
|
||||
private boolean enabled = true;
|
||||
|
||||
/** 预建未来几个月的分区。默认 3 个月,跨月时即使服务停了一周也不会漏。 */
|
||||
private int monthsAhead = 3;
|
||||
|
||||
/**
|
||||
* 是否自动清理过期分区。
|
||||
* 默认关闭:删数据是不可逆操作,第一阶段先只建不删,等保留策略与合规确认后再打开。
|
||||
*/
|
||||
private boolean retentionEnabled = false;
|
||||
|
||||
/** 每日维护时间(cron)。默认凌晨 3:17,避开印尼晚高峰。 */
|
||||
private String cron = "0 17 3 * * *";
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.fruitshorts.framework.security;
|
||||
|
||||
/**
|
||||
* C 端账号状态校验端口。
|
||||
*
|
||||
* <p>access token 是无状态的,签发出去以后无法收回。封禁、注销、改密这类操作
|
||||
* 如果只吊销 refresh token,用户最长还能继续用 2 小时,这不满足"处置立即生效"的要求。
|
||||
* 所以每个带 C 端身份的请求都要确认一次账号是否仍然可用。
|
||||
*
|
||||
* <p>框架只依赖这个接口,实现放在用户模块;接入 Redis 后在这里加一层缓存,
|
||||
* 把每次请求的库查询降为内存命中。
|
||||
*/
|
||||
public interface AppUserStatusChecker {
|
||||
|
||||
/** 返回 true 表示账号可继续访问。 */
|
||||
boolean isActive(Long userId);
|
||||
}
|
||||
+19
-2
@@ -1,10 +1,11 @@
|
||||
package com.reelshort.framework.security;
|
||||
package com.fruitshorts.framework.security;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
@@ -26,6 +27,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
private static final String PREFIX = "Bearer ";
|
||||
|
||||
private final JwtTokenProvider tokenProvider;
|
||||
private final ObjectProvider<AppUserStatusChecker> statusCheckerProvider;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
@@ -34,7 +36,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
if (StringUtils.hasText(header) && header.startsWith(PREFIX)) {
|
||||
String token = header.substring(PREFIX.length()).trim();
|
||||
try {
|
||||
LoginUser loginUser = tokenProvider.parseAdminToken(token);
|
||||
// typ 决定用哪把密钥验签:管理端 token 无法冒充客户端身份,反之亦然
|
||||
LoginUser loginUser = tokenProvider.parse(token);
|
||||
if (isInactiveAppUser(loginUser)) {
|
||||
// 账号已被封禁/注销:当作未登录处理,后续 401 会引导客户端重新登录
|
||||
SecurityContextHolder.clearContext();
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
List<SimpleGrantedAuthority> authorities = loginUser.permissions() == null
|
||||
? List.of()
|
||||
: loginUser.permissions().stream().map(SimpleGrantedAuthority::new).toList();
|
||||
@@ -48,4 +57,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private boolean isInactiveAppUser(LoginUser loginUser) {
|
||||
if (!SecurityUtils.isAppUser(loginUser)) {
|
||||
return false;
|
||||
}
|
||||
AppUserStatusChecker checker = statusCheckerProvider.getIfAvailable();
|
||||
return checker != null && !checker.isActive(loginUser.userId());
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.fruitshorts.framework.security;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "fruitshorts.jwt")
|
||||
public class JwtProperties {
|
||||
|
||||
/** 管理端签名密钥,长度必须 >= 32 字节。生产环境通过环境变量注入。 */
|
||||
private String adminSecret = "fruitshorts-admin-dev-secret-change-me-in-production-32b";
|
||||
|
||||
/** 管理端 token 有效期(小时)。 */
|
||||
private int adminTtlHours = 12;
|
||||
|
||||
/**
|
||||
* 客户端签名密钥。与管理端物理隔离:管理端 token 拿到客户端接口上无法通过验签,
|
||||
* 反之亦然。生产环境必须通过环境变量注入,不要沿用默认值。
|
||||
*/
|
||||
private String appSecret = "fruitshorts-app-dev-secret-change-me-in-production-32bytes";
|
||||
|
||||
/** 客户端 access token 有效期(分钟)。 */
|
||||
private int appAccessTtlMinutes = 120;
|
||||
|
||||
/** 客户端 refresh token 有效期(天)。 */
|
||||
private int appRefreshTtlDays = 30;
|
||||
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
package com.fruitshorts.framework.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtTokenProvider {
|
||||
|
||||
private static final String CLAIM_USER_ID = "userId";
|
||||
private static final String CLAIM_ROLE_CODE = "roleCode";
|
||||
private static final String CLAIM_PERMISSIONS = "permissions";
|
||||
private static final String CLAIM_TOKEN_TYPE = "typ";
|
||||
private static final String CLAIM_DEVICE_ID = "did";
|
||||
|
||||
public static final String TYPE_ADMIN = "admin";
|
||||
public static final String TYPE_APP = "app";
|
||||
/** C 端登录态统一挂在登录载体上,权限为空,避免误入后台鉴权链路。 */
|
||||
public static final String APP_ROLE_CODE = "APP_USER";
|
||||
|
||||
private final JwtProperties properties;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
public String createAdminToken(LoginUser loginUser) {
|
||||
Date now = new Date();
|
||||
Date expiration = new Date(now.getTime() + Duration.ofHours(properties.getAdminTtlHours()).toMillis());
|
||||
return Jwts.builder()
|
||||
.subject(loginUser.username())
|
||||
.claim(CLAIM_USER_ID, loginUser.userId())
|
||||
.claim(CLAIM_ROLE_CODE, loginUser.roleCode())
|
||||
.claim(CLAIM_PERMISSIONS, loginUser.permissions())
|
||||
.claim(CLAIM_TOKEN_TYPE, TYPE_ADMIN)
|
||||
.issuedAt(now)
|
||||
.expiration(expiration)
|
||||
.signWith(adminKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
/** 客户端 access token:只带用户身份与设备,不带任何后台权限。 */
|
||||
public String createAppToken(Long userId, String uid, String deviceId) {
|
||||
Date now = new Date();
|
||||
Date expiration = new Date(now.getTime()
|
||||
+ Duration.ofMinutes(properties.getAppAccessTtlMinutes()).toMillis());
|
||||
return Jwts.builder()
|
||||
.subject(uid)
|
||||
.claim(CLAIM_USER_ID, userId)
|
||||
.claim(CLAIM_ROLE_CODE, APP_ROLE_CODE)
|
||||
.claim(CLAIM_PERMISSIONS, List.of())
|
||||
.claim(CLAIM_TOKEN_TYPE, TYPE_APP)
|
||||
.claim(CLAIM_DEVICE_ID, deviceId == null ? "" : deviceId)
|
||||
.issuedAt(now)
|
||||
.expiration(expiration)
|
||||
.signWith(appKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public LoginUser parseAppToken(String token) {
|
||||
try {
|
||||
Claims claims = Jwts.parser()
|
||||
.verifyWith(appKey())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
Number userId = claims.get(CLAIM_USER_ID, Number.class);
|
||||
return new LoginUser(
|
||||
userId == null ? null : userId.longValue(),
|
||||
claims.getSubject(),
|
||||
APP_ROLE_CODE,
|
||||
(List<String>) claims.get(CLAIM_PERMISSIONS, List.class));
|
||||
} catch (Exception ex) {
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 token 自带的 typ 选择验签密钥。读 typ 时不验签,只用于选路。 */
|
||||
public LoginUser parse(String token) {
|
||||
return TYPE_APP.equals(peekType(token)) ? parseAppToken(token) : parseAdminToken(token);
|
||||
}
|
||||
|
||||
public String deviceIdOf(String token) {
|
||||
try {
|
||||
Claims claims = Jwts.parser()
|
||||
.verifyWith(appKey())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
String deviceId = claims.get(CLAIM_DEVICE_ID, String.class);
|
||||
return deviceId == null ? "" : deviceId;
|
||||
} catch (Exception ex) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private String peekType(String token) {
|
||||
try {
|
||||
String[] parts = token.split("\\.");
|
||||
if (parts.length < 2) {
|
||||
return TYPE_ADMIN;
|
||||
}
|
||||
byte[] payload = Base64.getUrlDecoder().decode(parts[1]);
|
||||
JsonNode node = objectMapper.readTree(payload);
|
||||
JsonNode type = node.get(CLAIM_TOKEN_TYPE);
|
||||
return type == null ? TYPE_ADMIN : type.asText();
|
||||
} catch (Exception ex) {
|
||||
return TYPE_ADMIN;
|
||||
}
|
||||
}
|
||||
|
||||
/** refresh token 是不透明随机串,只在库里存哈希,这里不参与 JWT。 */
|
||||
public Duration appRefreshTtl() {
|
||||
return Duration.ofDays(properties.getAppRefreshTtlDays());
|
||||
}
|
||||
|
||||
public long accessTtlSeconds() {
|
||||
return Duration.ofMinutes(properties.getAppAccessTtlMinutes()).toSeconds();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public LoginUser parseAdminToken(String token) {
|
||||
try {
|
||||
Claims claims = Jwts.parser()
|
||||
.verifyWith(adminKey())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
Number userId = claims.get(CLAIM_USER_ID, Number.class);
|
||||
return new LoginUser(
|
||||
userId == null ? null : userId.longValue(),
|
||||
claims.getSubject(),
|
||||
claims.get(CLAIM_ROLE_CODE, String.class),
|
||||
(List<String>) claims.get(CLAIM_PERMISSIONS, List.class));
|
||||
} catch (Exception ex) {
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
private SecretKey adminKey() {
|
||||
return keyOf(properties.getAdminSecret(), "fruitshorts.jwt.admin-secret");
|
||||
}
|
||||
|
||||
private SecretKey appKey() {
|
||||
return keyOf(properties.getAppSecret(), "fruitshorts.jwt.app-secret");
|
||||
}
|
||||
|
||||
private SecretKey keyOf(String secret, String property) {
|
||||
byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length < 32) {
|
||||
throw new IllegalStateException(property + " 长度必须不少于 32 字节");
|
||||
}
|
||||
return Keys.hmacShaKeyFor(bytes);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.framework.security;
|
||||
package com.fruitshorts.framework.security;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.framework.security;
|
||||
package com.fruitshorts.framework.security;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.framework.security;
|
||||
package com.fruitshorts.framework.security;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
package com.reelshort.framework.security;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.log.AccessDeniedRecorder;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
+16
-5
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.framework.security;
|
||||
package com.fruitshorts.framework.security;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -27,9 +27,20 @@ public class SecurityConfig {
|
||||
|
||||
private static final String[] PUBLIC_PATHS = {
|
||||
"/admin/auth/login",
|
||||
/* 客户端接口:浏览与播放暂不要求登录,登录体系(手机号/邮箱)在 C 端鉴权落地后收紧 */
|
||||
"/app/**",
|
||||
/* 本地媒资:图片与视频用 <img>/<video> 直接访问,无法带请求头 */
|
||||
/*
|
||||
* 客户端接口按「默认需要登录」收敛:只有下面列出的浏览类接口允许匿名。
|
||||
* 新增接口如果忘了加白名单,会返回 401 而不是悄悄裸奔。
|
||||
*/
|
||||
"/app/auth/**",
|
||||
"/app/home/**",
|
||||
"/app/discover/**",
|
||||
"/app/categories/**",
|
||||
"/app/banners/**",
|
||||
"/app/dramas/**",
|
||||
"/app/episodes/*/play",
|
||||
"/app/config/**",
|
||||
/* 媒资:封面与视频用 <img>/<video> 直接访问,无法带请求头。
|
||||
片源的防盗链由 fruitshorts.media.signature-required 控制。 */
|
||||
"/files/**",
|
||||
"/doc.html",
|
||||
"/swagger-ui/**",
|
||||
@@ -41,7 +52,7 @@ public class SecurityConfig {
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
private final RestAuthEntryPoint restAuthEntryPoint;
|
||||
|
||||
@Value("${reelshort.cors.allowed-origins:http://localhost:3005}")
|
||||
@Value("${fruitshorts.cors.allowed-origins:http://localhost:3005}")
|
||||
private String allowedOrigins;
|
||||
|
||||
@Bean
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.fruitshorts.framework.security;
|
||||
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
public final class SecurityUtils {
|
||||
|
||||
private SecurityUtils() {
|
||||
}
|
||||
|
||||
public static LoginUser getLoginUser() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && authentication.getPrincipal() instanceof LoginUser loginUser) {
|
||||
return loginUser;
|
||||
}
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
public static Long getUserId() {
|
||||
return getLoginUser().userId();
|
||||
}
|
||||
|
||||
public static String getUsername() {
|
||||
return getLoginUser().username();
|
||||
}
|
||||
|
||||
/** 允许匿名访问的接口用它取当前用户,未登录返回 null。 */
|
||||
public static LoginUser getLoginUserOrNull() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && authentication.getPrincipal() instanceof LoginUser loginUser) {
|
||||
return loginUser;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Long getUserIdOrNull() {
|
||||
LoginUser loginUser = getLoginUserOrNull();
|
||||
return loginUser == null ? null : loginUser.userId();
|
||||
}
|
||||
|
||||
/** 真机上未登录用户也会带一个设备标识,用于免费集限流与风控。 */
|
||||
public static boolean isAppUser(LoginUser loginUser) {
|
||||
return loginUser != null && "APP_USER".equals(loginUser.roleCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端接口取当前登录用户。
|
||||
* 管理端 token 打到客户端接口时直接拒绝:两个体系的用户 ID 不在同一张表,混用没有意义。
|
||||
*/
|
||||
public static Long getAppUserId() {
|
||||
LoginUser loginUser = getLoginUserOrNull();
|
||||
if (!isAppUser(loginUser)) {
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED, "请先登录");
|
||||
}
|
||||
return loginUser.userId();
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
package com.reelshort.framework.web;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.log.AccessDeniedRecorder;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.framework.web;
|
||||
package com.fruitshorts.framework.web;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.framework.web;
|
||||
package com.fruitshorts.framework.web;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -5,23 +5,23 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.reelshort</groupId>
|
||||
<artifactId>reelshort</artifactId>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>reelshort-module-content</artifactId>
|
||||
<name>reelshort-module-content</name>
|
||||
<artifactId>fruitshorts-module-content</artifactId>
|
||||
<name>fruitshorts-module-content</name>
|
||||
<description>内容域:短剧、剧集、审核发布</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.reelshort</groupId>
|
||||
<artifactId>reelshort-framework</artifactId>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-framework</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.reelshort</groupId>
|
||||
<artifactId>reelshort-module-system</artifactId>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-system</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.content.dal.entity;
|
||||
package com.fruitshorts.content.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
+22
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.content.dal.entity;
|
||||
package com.fruitshorts.content.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
@@ -30,6 +30,27 @@ public class Drama {
|
||||
private Integer unlockCoin;
|
||||
/** 是否热门:1 是,0 否 */
|
||||
private Integer isHot;
|
||||
/** 创作者归属,0 表示平台自制 */
|
||||
private Long creatorId;
|
||||
private String tags;
|
||||
/** 内容分级:ALL/R13/R17/R21(印尼) */
|
||||
private String rating;
|
||||
/** 可播区域,逗号分隔 ISO 国家码,空=不限 */
|
||||
private String regionCodes;
|
||||
private String copyrightOwner;
|
||||
private LocalDateTime copyrightStartAt;
|
||||
/** 版权到期时间,到期自动下架 */
|
||||
private LocalDateTime copyrightEndAt;
|
||||
/** 预留:多语言字幕(第三阶段) */
|
||||
private String subtitleLangs;
|
||||
private String trailerUrl;
|
||||
/** 预留:推荐权重(第三阶段) */
|
||||
private Integer recommendWeight;
|
||||
/** 预留:会员专属(第二阶段) */
|
||||
private Integer isVipOnly;
|
||||
/** 创作者内容的审核状态 */
|
||||
private String auditStatus;
|
||||
private String auditNote;
|
||||
private String status;
|
||||
private LocalDateTime publishAt;
|
||||
private LocalDateTime publishedAt;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.content.dal.entity;
|
||||
package com.fruitshorts.content.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
+11
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.content.dal.entity;
|
||||
package com.fruitshorts.content.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
@@ -25,6 +25,16 @@ public class DramaEpisode {
|
||||
private String videoUrl;
|
||||
private Integer isFree;
|
||||
private Integer unlockCoin;
|
||||
/** 转码状态:none/queued/running/success/failed */
|
||||
private String transcodeStatus;
|
||||
/** 多码率 master playlist 地址,转码完成后写入 */
|
||||
private String hlsUrl;
|
||||
/** 试看秒数,0 表示不限制 */
|
||||
private Integer previewSeconds;
|
||||
private String subtitleUrl;
|
||||
private String subtitleLang;
|
||||
/** 预留:会员专属(第二阶段) */
|
||||
private Integer isVipOnly;
|
||||
private String status;
|
||||
private LocalDateTime releaseAt;
|
||||
private Long playCount;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.content.dal.entity;
|
||||
package com.fruitshorts.content.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
package com.reelshort.content.dal.mapper;
|
||||
package com.fruitshorts.content.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.reelshort.content.dal.entity.ContentBanner;
|
||||
import com.fruitshorts.content.dal.entity.ContentBanner;
|
||||
|
||||
public interface ContentBannerMapper extends BaseMapper<ContentBanner> {
|
||||
}
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
package com.reelshort.content.dal.mapper;
|
||||
package com.fruitshorts.content.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.reelshort.content.dal.entity.DramaCategory;
|
||||
import com.fruitshorts.content.dal.entity.DramaCategory;
|
||||
|
||||
public interface DramaCategoryMapper extends BaseMapper<DramaCategory> {
|
||||
}
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
package com.reelshort.content.dal.mapper;
|
||||
package com.fruitshorts.content.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.reelshort.content.dal.entity.DramaEpisode;
|
||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||
|
||||
public interface DramaEpisodeMapper extends BaseMapper<DramaEpisode> {
|
||||
}
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
package com.reelshort.content.dal.mapper;
|
||||
package com.fruitshorts.content.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.reelshort.content.dal.entity.Drama;
|
||||
import com.fruitshorts.content.dal.entity.Drama;
|
||||
|
||||
public interface DramaMapper extends BaseMapper<Drama> {
|
||||
}
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
package com.reelshort.content.dal.mapper;
|
||||
package com.fruitshorts.content.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.reelshort.content.dal.entity.DramaReviewRecord;
|
||||
import com.fruitshorts.content.dal.entity.DramaReviewRecord;
|
||||
|
||||
public interface DramaReviewRecordMapper extends BaseMapper<DramaReviewRecord> {
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.content.enums;
|
||||
package com.fruitshorts.content.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.content.enums;
|
||||
package com.fruitshorts.content.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.content.enums;
|
||||
package com.fruitshorts.content.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
+12
-12
@@ -1,19 +1,19 @@
|
||||
package com.reelshort.content.service;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.content.dal.entity.ContentBanner;
|
||||
import com.fruitshorts.content.dal.entity.Drama;
|
||||
import com.fruitshorts.content.dal.mapper.ContentBannerMapper;
|
||||
import com.fruitshorts.content.dal.mapper.DramaMapper;
|
||||
import com.fruitshorts.content.enums.BannerLinkType;
|
||||
import com.fruitshorts.content.enums.BannerPosition;
|
||||
import com.fruitshorts.content.vo.BannerRespVO;
|
||||
import com.fruitshorts.content.vo.BannerSaveReqVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
+9
-9
@@ -1,14 +1,14 @@
|
||||
package com.reelshort.content.service;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.content.dal.entity.Drama;
|
||||
import com.fruitshorts.content.dal.entity.DramaCategory;
|
||||
import com.fruitshorts.content.dal.mapper.DramaMapper;
|
||||
import com.fruitshorts.content.dal.mapper.DramaCategoryMapper;
|
||||
import com.fruitshorts.content.vo.DramaCategoryRespVO;
|
||||
import com.fruitshorts.content.vo.DramaCategorySaveReqVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
+32
-14
@@ -1,21 +1,21 @@
|
||||
package com.reelshort.content.service;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.content.dal.entity.Drama;
|
||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||
import com.fruitshorts.content.dal.entity.DramaReviewRecord;
|
||||
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
|
||||
import com.fruitshorts.content.dal.mapper.DramaMapper;
|
||||
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
|
||||
import com.fruitshorts.content.enums.ContentStatus;
|
||||
import com.fruitshorts.content.vo.DramaPageReqVO;
|
||||
import com.fruitshorts.content.vo.DramaRespVO;
|
||||
import com.fruitshorts.content.vo.DramaSaveReqVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -190,6 +190,24 @@ public class DramaService {
|
||||
return drama;
|
||||
}
|
||||
|
||||
/** C 端:按 ID 批量取已上线短剧(收藏与观看记录用),返回顺序由调用方决定。 */
|
||||
public List<Drama> listPublishedByIds(List<Long> ids) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return dramaMapper.selectList(Wrappers.<Drama>lambdaQuery()
|
||||
.in(Drama::getId, ids)
|
||||
.eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode()));
|
||||
}
|
||||
|
||||
/** 按 ID 批量取短剧,不限状态:后台查观看流水时要能看到已下线短剧的名字。 */
|
||||
public List<Drama> listByIds(List<Long> ids) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return dramaMapper.selectList(Wrappers.<Drama>lambdaQuery().in(Drama::getId, ids));
|
||||
}
|
||||
|
||||
/** 剧集数量变化后同步冗余字段。 */
|
||||
public void syncTotalEpisodes(Long dramaId) {
|
||||
Long count = dramaEpisodeMapper.selectCount(
|
||||
+17
-8
@@ -1,13 +1,13 @@
|
||||
package com.reelshort.content.service;
|
||||
package com.fruitshorts.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 com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
|
||||
import com.fruitshorts.content.enums.ContentStatus;
|
||||
import com.fruitshorts.content.vo.EpisodeRespVO;
|
||||
import com.fruitshorts.content.vo.EpisodeSaveReqVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -84,6 +84,15 @@ public class EpisodeService {
|
||||
return episode;
|
||||
}
|
||||
|
||||
/** 按 ID 批量取剧集,不限状态:后台查观看流水时用。 */
|
||||
public List<DramaEpisode> listByIds(List<Long> ids) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return dramaEpisodeMapper.selectList(
|
||||
Wrappers.<DramaEpisode>lambdaQuery().in(DramaEpisode::getId, ids));
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long create(EpisodeSaveReqVO req) {
|
||||
dramaService.getExisting(req.getDramaId());
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.content.vo;
|
||||
package com.fruitshorts.content.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.content.vo;
|
||||
package com.fruitshorts.content.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.content.vo;
|
||||
package com.fruitshorts.content.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.content.vo;
|
||||
package com.fruitshorts.content.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
package com.reelshort.content.vo;
|
||||
package com.fruitshorts.content.vo;
|
||||
|
||||
import com.reelshort.common.pojo.PageParam;
|
||||
import com.fruitshorts.common.pojo.PageParam;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.content.vo;
|
||||
package com.fruitshorts.content.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.content.vo;
|
||||
package com.fruitshorts.content.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.content.vo;
|
||||
package com.fruitshorts.content.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.content.vo;
|
||||
package com.fruitshorts.content.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.reelshort.content.vo;
|
||||
package com.fruitshorts.content.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
Loaded 100 of 251 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user