Author SHA1 Message Date
Zhou Weidong df2da25833 chore(merge): 合并用户系统协议与播放改动 2026-10-23 12:34:26 +08:00
Zhou Weidong 1ea4cfeba3 test(media): 按物理删除校验重转码清理 2026-10-23 12:34:13 +08:00
Zhou Weidong ce6e3ef70a docs(api): 同步搜索协议播放与收藏契约 2026-10-23 12:34:13 +08:00
Zhou Weidong 87e20c31a9 feat(security): 允许匿名搜索与打开协议页 2026-10-23 12:34:13 +08:00
Zhou Weidong 22edf34ff1 feat(system): 支持后台维护三语协议 2026-10-23 12:34:13 +08:00
Zhou Weidong 16ba79a336 feat(interaction): 支持批量取消收藏 2026-10-23 12:34:13 +08:00
Zhou Weidong 9c25b1b397 fix(playback): 恢复已删除的观看进度 2026-10-23 12:34:13 +08:00
Zhou Weidong 5a80e6693f feat(playback): 播放票据返回各清晰度地址 2026-10-23 12:34:13 +08:00
Zhou Weidong ab58641bbe feat(user): 展示用户最近使用的系统 2026-10-23 12:34:13 +08:00
Zhou Weidong 45e3d84f59 docs(api): 补充分类与剧单管理接口文档 2026-10-22 22:17:58 +08:00
Zhou Weidong a3ff636e45 chore(sql): 补充转码设置菜单初始化补丁 2026-10-22 22:17:58 +08:00
Zhou Weidong 7b360ddccd feat(content): 新增运营分类与剧单分类管理 2026-10-22 22:17:58 +08:00
Zhou Weidong 758c7521bb Merge remote-tracking branch 'origin/develop' into develop 2026-10-22 18:21:05 +08:00
Zhou Weidong e730cfaa7d fix(media): 重转码物理清理旧产物避免唯一键冲突 2026-10-22 18:21:00 +08:00
Zhou Weidong 60a87a8d15 docs(client): 回填首页与我的视觉改版记录 2026-10-22 17:08:05 +08:00
Zhou Weidong 0b519b8ca1 docs(nav): 回填客户端四 Tab 导航记录 2026-10-22 16:22:07 +08:00
Zhou Weidong e6b4a747ce merge: 修复转码 rendition 幂等与失败落库 2026-10-22 15:37:01 +08:00
Zhou Weidong f009f75c03 docs(media): 回填转码修复记录 2026-10-22 15:36:51 +08:00
Zhou Weidong 91b621fe75 fix(media): 清理旧 rendition 并确保失败状态落库 2026-10-22 15:36:35 +08:00
Zhou Weidong 5d1a157085 merge: 修复转码任务认领与媒资校验 2026-10-22 15:27:58 +08:00
Zhou Weidong 5552caa6c1 fix(media): 修正转码任务认领与媒资 id 校验 2026-10-22 15:27:51 +08:00
Zhou Weidong ad84704d28 test(media): 覆盖转码认领与非法媒资 id 2026-10-22 15:27:37 +08:00
Zhou Weidong 08eddc9ed2 merge: 修复转码错误截断与进度上报 2026-10-22 15:13:50 +08:00
Zhou Weidong 660afaf66d fix(media): 转码错误信息截断并上报进度 2026-10-22 15:12:05 +08:00
Zhou Weidong f9bc6eee6d merge: 修复转码媒资 id 精度与存量兜底 2026-10-22 14:59:46 +08:00
Zhou Weidong 5ef5dd8eaf fix(media): 修正转码媒资 id 解析与存量分集补登记 2026-10-22 14:59:16 +08:00
Zhou Weidong a35b02dd18 merge: 修复转码保持原始宽高比 2026-10-22 14:31:30 +08:00
Zhou Weidong 04d25cd23d fix(media): 转码按短边等比定档并回写实际尺寸 2026-10-22 14:31:22 +08:00
Zhou Weidong 217ff24f1c test(media): 覆盖转码保持原始宽高比 2026-10-22 14:30:08 +08:00
Zhou Weidong 40a884cc85 merge: 补齐转码产物查询接口 2026-10-22 14:14:21 +08:00
Zhou Weidong 345408d5e7 feat(media): 新增清晰度与帧图查询接口 2026-10-22 14:14:13 +08:00
Zhou Weidong 090f149b31 test(media): 覆盖转码产物查询接口 2026-10-22 14:10:15 +08:00
112 changed files with 5436 additions and 148 deletions

No files matched your search

+210 -2
View File
@@ -170,7 +170,7 @@ app.register.max-per-device-per-day
| GET | `/app/feed?types=ANIME,LIVE&categoryId=7&cursor=&size=20` | 推荐流;支持内容类型与分类叠加过滤,游标翻页 | | GET | `/app/feed?types=ANIME,LIVE&categoryId=7&cursor=&size=20` | 推荐流;支持内容类型与分类叠加过滤,游标翻页 |
| GET | `/app/categories` | 分类列表 | | GET | `/app/categories` | 分类列表 |
| GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) | | GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) |
| GET | `/app/search?keyword=&pageIndex=&pageSize=` | 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 | | GET | `/app/search?keyword=&pageIndex=&pageSize=` | **匿名**。搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 |
> 卡片(`AppDramaCardVO`)统一新增 `language` 字段(取值如 `zh-CN` / `id`), > 卡片(`AppDramaCardVO`)统一新增 `language` 字段(取值如 `zh-CN` / `id`),
> 首页、发现页、分类、收藏、搜索等所有下发卡片的场景都带该字段,客户端可据此做语言筛选。 > 首页、发现页、分类、收藏、搜索等所有下发卡片的场景都带该字段,客户端可据此做语言筛选。
@@ -264,7 +264,11 @@ app.register.max-per-device-per-day
"accessible": true, "accessible": true,
"accessType": "FREE", "accessType": "FREE",
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...", "playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...",
"qualities": ["AUTO"], "qualities": ["AUTO", "720p", "1080p"],
"tracks": [
{"quality": "AUTO", "playUrl": "/files/transcoded/88/master.m3u8?e=1760000000&s=9f2c..."},
{"quality": "720p", "playUrl": "/files/transcoded/88/720p/index.m3u8?e=1760000000&s=a1b2..."}
],
"thumbnailsUrl": "/files/transcoded/88/thumbnails/thumbnails.vtt?e=1760000000&s=8a1d...", "thumbnailsUrl": "/files/transcoded/88/thumbnails/thumbnails.vtt?e=1760000000&s=8a1d...",
"unlockCoin": 0, "unlockCoin": 0,
"previewSeconds": 0, "previewSeconds": 0,
@@ -296,6 +300,7 @@ app.register.max-per-device-per-day
| POST | `/app/favorites/toggle` | 收藏/取消收藏,返回切换后状态 | | POST | `/app/favorites/toggle` | 收藏/取消收藏,返回切换后状态 |
| GET | `/app/favorites` | 收藏分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 | | GET | `/app/favorites` | 收藏分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 |
| DELETE | `/app/favorites/{dramaId}` | 取消收藏 | | DELETE | `/app/favorites/{dramaId}` | 取消收藏 |
| POST | `/app/favorites/remove` | 批量取消收藏,请求体 `{ "dramaIds": ["12", "15"] }` |
进度合并规则:同一集内只前进不后退;换集直接采纳新集;`finished` 只在接近片尾或客户端显式上报时置位。 进度合并规则:同一集内只前进不后退;换集直接采纳新集;`finished` 只在接近片尾或客户端显式上报时置位。
这样用户换设备不会看到进度倒退。 这样用户换设备不会看到进度倒退。
@@ -354,6 +359,7 @@ app.register.max-per-device-per-day
"dramaCover": "/files/drama_cover/12.jpg", "dramaCover": "/files/drama_cover/12.jpg",
"episodeNumber": 6, "episodeNumber": 6,
"totalEpisodes": 68, "totalEpisodes": 68,
"viewCount": "1800",
"positionMs": "80000", "positionMs": "80000",
"durationMs": "120000", "durationMs": "120000",
"finished": false, "finished": false,
@@ -375,6 +381,7 @@ app.register.max-per-device-per-day
| `dramaCover` | string / null | 短剧封面 | | `dramaCover` | string / null | 短剧封面 |
| `episodeNumber` | int / null | 当前续看集号 `ep_no` | | `episodeNumber` | int / null | 当前续看集号 `ep_no` |
| `totalEpisodes` | int / null | 短剧总集数 | | `totalEpisodes` | int / null | 短剧总集数 |
| `viewCount` | Long(JSON 字符串)/ null | 短剧热度,取 `drama.view_count`。`playCount` 是用户自己的播放次数 |
### 1.6 离线下载(已实现) ### 1.6 离线下载(已实现)
@@ -543,6 +550,17 @@ GET /app/notices?pageIndex=1&pageSize=20
`expire_at` 为空或未过期的公告;按 `publish_at desc, id desc` 排序。 `expire_at` 为空或未过期的公告;按 `publish_at desc, id desc` 排序。
**表里没有数据时返回 `total=0, list=[]`(HTTP 与 code 都是成功),不是 404。** **表里没有数据时返回 `total=0, list=[]`(HTTP 与 code 都是成功),不是 404。**
#### 1.9.2a 协议公开页(匿名 HTML)
客户端按当前界面语言打开,不走 `{code,message,data}` 包装。正文在后台「协议设置」用 Markdown 维护,页面不另加标题。
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/privacy/{lang}` | 隐私协议。`lang` 为 `zh` / `en` / `id` |
| GET | `/terms/{lang}` | 服务条款。语言同上 |
响应 `Content-Type: text/html;charset=UTF-8`。语言或路径不在上表时 HTTP 404。库里还没有该语言时返回内置缺省文案,不返回空页。
#### 1.9.3 `POST /app/creator/apply`(登录) #### 1.9.3 `POST /app/creator/apply`(登录)
```json ```json
@@ -604,6 +622,8 @@ GET /app/notices?pageIndex=1&pageSize=20
| 角色分类 | PUT · DELETE | `/admin/role-categories/{id}` | `system:role-category` | | 角色分类 | PUT · DELETE | `/admin/role-categories/{id}` | `system:role-category` |
| 系统配置 | GET | `/admin/configs` | `system:role` / `system:menu` | | 系统配置 | GET | `/admin/configs` | `system:role` / `system:menu` |
| 系统配置 | PUT | `/admin/configs` | `system:role` | | 系统配置 | PUT | `/admin/configs` | `system:role` |
| 协议 | GET | `/admin/legal-documents` | `system:role` / `system:menu` |
| 协议 | PUT | `/admin/legal-documents` | `system:role` |
| 操作日志 | GET | `/admin/operation-logs/page` · `/{id}` | `log:list` | | 操作日志 | GET | `/admin/operation-logs/page` · `/{id}` | `log:list` |
| 字典 | GET | `/admin/dicts/drama-category` · `/content-status` | `drama:list` | | 字典 | GET | `/admin/dicts/drama-category` · `/content-status` | `drama:list` |
@@ -1074,6 +1094,194 @@ GET /app/notices?pageIndex=1&pageSize=20
**筛选与错误码**:`title` 必填(1–128);`content` 最长 2000;公告不存在 → `200002 公告不存在`; **筛选与错误码**:`title` 必填(1–128);`content` 最长 2000;公告不存在 → `200002 公告不存在`;
`status` 只接受 0/1,其他值 → `400`。 `status` 只接受 0/1,其他值 → `400`。
### 2.13 运营分类管理(已实现)
> 「分类」是运营自定义的分组,与短剧**类型**(`/admin/drama-categories`,写 `drama_category`)是两套独立概念。
> 分类存在 `drama_classification`,通过 `drama_classification_drama` 关联任意多部短剧。
> 后台页面:菜单 **分类管理**(`/content/category-dramas` → `CategoryDramaView.vue`)。
**接口清单**(前缀 `/admin/drama-classifications`)
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/drama-classifications` | `drama:list` | 分类列表(带关联短剧数量) |
| POST | `/admin/drama-classifications` | `drama:create` | 新建分类,返回新分类 id |
| PUT | `/admin/drama-classifications/{id}` | `drama:update` | 编辑分类 |
| DELETE | `/admin/drama-classifications/{id}` | `drama:delete` | 删除分类(级联删关联,短剧本身保留) |
| GET | `/admin/drama-classifications/{id}/dramas` | `drama:list` | 分类下已关联的短剧(分页) |
| GET | `/admin/drama-classifications/{id}/dramas/candidates` | `drama:list` | 尚未关联到该分类的短剧(分页,供多选) |
| POST | `/admin/drama-classifications/{id}/dramas` | `drama:update` | 批量关联短剧,返回实际新增数量 |
| DELETE | `/admin/drama-classifications/{id}/dramas/{dramaId}` | `drama:update` | 从分类移出短剧(幂等) |
**列表响应**
```json
{
"code": 0,
"message": "ok",
"data": [
{
"id": "2112807182243336193",
"name": "本周必看",
"sort": 1,
"status": 1,
"dramaCount": 12,
"updatedAt": "2026-10-22T19:41:02"
}
]
}
```
排序规则固定为 `sort asc, id desc`;`dramaCount` 是关联表按分类聚合的实时数量。
时间字段为 `LocalDateTime` 的 ISO 字符串(`yyyy-MM-ddTHH:mm:ss`,秒级),前端按需替换 `T` 为空格。
**新建 / 编辑请求体**
```json
{ "name": "本周必看", "sort": 1, "status": 1 }
```
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `name` | 是 | 去首尾空格后保存,全局唯一;`@NotBlank` 提示「请填写分类名称」 |
| `sort` | 否 | 新建不传时取 `现有条数 + 1`;编辑不传(null)时保持原值 |
| `status` | 否 | `1` 启用 / `0` 停用;新建默认 `1`,编辑不传保持原值 |
**关联短剧**
```json
POST /admin/drama-classifications/2112807182243336193/dramas
{ "dramaIds": ["2112807182243336194", "2112807182243336195"] }
```
返回 `data` 为实际新增条数(整数)。已关联的、以及不存在的 `dramaId` 会被静默跳过,不报错;
全部跳过时返回 `0`。`dramaIds` 为空数组 → `400 请选择短剧`。
**短剧列表项(`GET /{id}/dramas` 与 `/dramas/candidates` 共用)**
```json
{
"total": 12,
"list": [
{
"id": "2112807182243336194",
"title": "闪婚后首富老公竟是我老板",
"cover": "https://.../cover.jpg",
"status": "published",
"totalEpisodes": 80,
"sort": 100,
"updatedAt": "2026-10-22T18:00:00"
}
]
}
```
- 查询参数:`keyword`(按 `title` 模糊匹配,可空)、`pageIndex`(默认 1)、`pageSize`(默认 10,上限 100)
- 排序:`sort desc, id desc`
- `status` 取值:`draft` 草稿 / `reviewing` 审核中 / `approved` 待发布 / `published` 已上线 / `offline` 已下线
- `cover` 取自短剧 `cover_url`
**错误码**
| 场景 | 返回 |
| --- | --- |
| 分类名称重复(新建或改名撞已有) | `200001 分类名称已存在` |
| 分类 id 不存在(编辑/删除/查询/关联/移出) | `200002 分类不存在` |
| `name` 为空 | `400 请填写分类名称` |
| `dramaIds` 为空 | `400 请选择短剧` |
> 所有写操作带 `@OperationLog(module = "内容管理")` 操作日志。ID 下发口径见 **§0**。
### 2.14 剧单管理(已实现)
> 剧单使用**自己的一套分类**(`drama_playlist_category`),与「分类管理」和「类型」都没有关系。
> **没有「剧单」这一层实体**:分类直接挂短剧,关联表 `drama_playlist_category_drama(category_id, drama_id)`。
> 后台页面:菜单 **剧单管理**(`/content/drama-playlists` → `DramaPlaylistView.vue`)。
**接口清单**(前缀 `/admin/drama-playlist-categories`)
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/drama-playlist-categories` | `drama:list` | 剧单分类列表(带短剧数量) |
| POST | `/admin/drama-playlist-categories` | `drama:create` | 新建剧单分类,返回新分类 id |
| PUT | `/admin/drama-playlist-categories/{id}` | `drama:update` | 编辑剧单分类 |
| DELETE | `/admin/drama-playlist-categories/{id}` | `drama:delete` | 删除剧单分类(级联删关联,短剧本身保留) |
| GET | `/admin/drama-playlist-categories/{id}/dramas` | `drama:list` | 分类下已关联的短剧(分页) |
| GET | `/admin/drama-playlist-categories/{id}/dramas/candidates` | `drama:list` | 尚未关联到该分类的短剧(分页,供多选) |
| POST | `/admin/drama-playlist-categories/{id}/dramas` | `drama:update` | 批量把短剧加入分类,返回实际新增数量 |
| DELETE | `/admin/drama-playlist-categories/{id}/dramas/{dramaId}` | `drama:update` | 从分类移出短剧(幂等) |
**列表响应**
```json
{
"code": 0,
"message": "ok",
"data": [
{
"id": "2112807182243336196",
"name": "高甜先婚后爱",
"description": "站内投放位使用,按周更新",
"sort": 1,
"status": 1,
"dramaCount": 8
}
]
}
```
排序规则 `sort asc, id desc`;`dramaCount` 为该分类下已关联短剧数量。
**新建 / 编辑请求体**
```json
{
"name": "高甜先婚后爱",
"description": "站内投放位使用,按周更新",
"sort": 1,
"status": 1
}
```
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `name` | 是 | 去首尾空格后保存,全局唯一;`@NotBlank` 提示「请填写剧单分类名称」 |
| `description` | 否 | 最长 255 字符(超长 → `400 描述最多 255 个字符`);新建不传存空串,编辑不传保持原值 |
| `sort` | 否 | 新建不传取 `现有条数 + 1`;编辑不传保持原值 |
| `status` | 否 | `1` 启用 / `0` 停用;新建默认 `1` |
**关联短剧**
```json
POST /admin/drama-playlist-categories/2112807182243336196/dramas
{ "dramaIds": ["2112807182243336194"] }
```
返回实际新增条数;已关联或不存在的短剧自动跳过;`dramaIds` 为空 → `400 请选择短剧`。
`GET /{id}/dramas` 与 `GET /{id}/dramas/candidates` 的查询参数和响应结构与 **§2.13** 完全一致
(`keyword` / `pageIndex` / `pageSize`,响应 `{ total, list }`,列表项含
`id` / `title` / `cover` / `status` / `totalEpisodes` / `sort` / `updatedAt`)。
**错误码**
| 场景 | 返回 |
| --- | --- |
| 剧单分类名称重复 | `200001 剧单分类名称已存在` |
| 剧单分类 id 不存在 | `200002 剧单分类不存在` |
| `name` 为空 | `400 请填写剧单分类名称` |
| `description` 超过 255 | `400 描述最多 255 个字符` |
| `dramaIds` 为空 | `400 请选择短剧` |
**与「分类管理」的差异**
| 项 | 运营分类(2.13) | 剧单分类(2.14) |
| --- | --- | --- |
| 数据表 | `drama_classification` + `drama_classification_drama` | `drama_playlist_category` + `drama_playlist_category_drama` |
| 描述字段 | 无 | 有(`description`,≤255) |
| 列表返回 | 含 `updatedAt` | 含 `description`,不含 `updatedAt` |
| 菜单 | 分类管理(`/content/category-dramas`) | 剧单管理(`/content/drama-playlists`) |
| 用途 | 内容侧分组 | 运营侧投放/剧单位分组 |
## 3. 契约与实现状态对照 ## 3. 契约与实现状态对照
> 最后更新:2026-10-16(`develop` 分支)。未完成项汇总见仓库根 `README.md`「尚未完成」。 > 最后更新:2026-10-16(`develop` 分支)。未完成项汇总见仓库根 `README.md`「尚未完成」。
@@ -0,0 +1,38 @@
# 转码产物查询接口
## 目标
- 媒体维度查询 rendition 与帧图产物。
- 分集维度聚合转码任务、rendition、帧图状态。
- `/admin/dashboard/transcode` 改为明确 VO,OpenAPI 不再出现匿名 Object。
## 设计
1. `MediaTranscodeOutputService` 统一读取媒体、任务、rendition 与 thumbnail。
2. rendition 按高度、视频码率、质量倒序,id 正序保证稳定。
3. thumbnail 优先读取 `thumbnails.vtt`,解析 cue 后分页;无记录返回 `EMPTY`,
记录失败返回 `FAILED`,不把缺产物当服务错误。
4. 分集聚合对不存在、无视频、无媒资、无任务返回明确 status 和空列表。
5. dashboard 使用强类型 queue/failed task VO,保留现有字段语义并补 total。
## 接口
- `GET /admin/media/{id}/renditions`
- `GET /admin/media/{id}/thumbnails?pageIndex=1&pageSize=100`
- `GET /admin/episodes/{id}/transcode`
- `GET /admin/dashboard/transcode`(响应类型由 Map 收敛为 VO)
缩略图响应内嵌 `PageResult<MediaThumbnailFrameVO>`,同时返回 sprite/VTT 元数据;
空产物 status 为 `EMPTY`,无绑定分集为 `NO_EPISODE`,损坏 VTT 为 `INVALID`。
## TDD
- 先提交媒体有/无产物、不存在、VTT 解析与分页边界测试。
- 再提交接口、VO、聚合服务和 dashboard 类型收敛实现。
- 门禁:`mvn -pl fruitshorts-module-media test`。
## 红线
- 不改 `/app` 契约、specs、ReelShortWeb、Flutter。
- 不改 P1 入队与手动重转逻辑。
- 不启停服务,不操作真机。
@@ -0,0 +1,24 @@
# 转码任务认领与媒资 ID 校验
## 现场结论
- `media_asset.id=21` 确实存在,source_url 对应分集 id=13。
- 分集 13 的 video_asset_id=0,旧手动触发只拿媒资,不知道分集绑定,
因而创建了 media_asset_id=21、episode_id=0 的 QUEUED 任务。
- claim 只筛选 episode_id>0,因此该任务永久跳过且没有原因日志。
## 修复
1. POST /admin/media/{id}/transcode 严格只按 media_asset.id 查询;
不再把候选 id 当分集 id 兜底或补登记。
2. 媒资存在时按 source_url 反查分集;找到后绑定 drama_id/episode_id。
3. 找不到分集时拒绝建任务,避免生成永远无法认领的 episode_id=0 记录。
4. claim 增加 media_asset_id/source_url 条件,并记录被跳过任务 id 与原因。
5. RUNNING 无更新阈值收敛到 5 分钟,覆盖 worker 重启后的孤儿任务。
6. 一次性 patch 将 media_asset_id 不存在且 episode_id=0 的 QUEUED/RUNNING 标记 FAILED。
## 验证
- 非法 media id 拒绝、未绑定分集拒绝、合法 URL 自动绑定。
- claim 跳过原因、进度更新、僵尸任务恢复。
- `mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test`。
@@ -0,0 +1,28 @@
# 转码媒资 ID 精度与存量兜底修复
## 取证
- 示例分集 `2113150431755636737` 的 `video_asset_id` 为
`2113150398670966786`,与 `media_asset.id` 完全一致。
- JS `Number('2113150398670966786')` 序列化为 `2113150398670966800`,
超出安全整数后发生精度丢失,导致手动转码查询了错误 ID。
## 修复
1. 聚合响应的 episodeId/mediaAssetId/taskId 使用字符串。
2. 手动转码仍由后端 Long 接收精确路径 ID。
3. 媒资不存在时通过 content 模块的端口按候选 ID 查找分集:
- 分集 id;
- video_asset_id;
- transcode_task.id。
4. 找到分集后按 video_url/source_url/objectKey 反查媒资;
文件存在但媒资缺失时补登记并回填分集 video_asset_id。
5. 无可用视频时保持明确空态/错误语义。
## 验证
- 超 2^53 ID 字符串契约。
- 聚合无媒资空态。
- video_url 反查命中。
- 文件存在时补登记。
- `mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test`。
@@ -0,0 +1,27 @@
# 转码保持原始宽高比
## 目标
- 横屏源按档位短边作为高度,竖屏源按档位短边作为宽度。
- 源短边小于档位短边时按源短边输出,不放大。
- 输出宽高均为偶数,SAR 固定为 1。
- rendition 的 width/height 使用 ffprobe 探测到的实际值。
## 实施
1. ffprobe 读取源显示方向尺寸,旋转 90/270 度时交换宽高。
2. `effectiveShort = min(sourceShort, rungShort)` 并向下取偶数。
3. 横屏使用 `scale=-2:<effectiveShort>:flags=lanczos,setsar=1`;
竖屏使用 `scale=<effectiveShort>:-2:flags=lanczos,setsar=1`。
4. 转码完成后 ffprobe variant playlist,写入实际输出尺寸。
5. master playlist 的 RESOLUTION 同步使用实际尺寸。
## 不放大策略
选用“用源尺寸输出”:高于源短边的档位仍保留原 quality 标识和码率,
但分辨率封顶到源短边,避免所有档位都被跳过导致无产物。
## 验证
- 先补横屏、竖屏、小源码片、偶数、SAR=1 与实际尺寸回写测试。
- 门禁:`mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test`。
@@ -0,0 +1,29 @@
# 转码错误截断与进度上报
## 问题
- ffmpeg 错误输出超过 `error_msg varchar(500)`,失败回写 UPDATE 再次失败,
任务停在 RUNNING/0。
- worker 未解析 ffmpeg progress,前端只能看到 0。
- 缺少 RUNNING 超时收口。
## 修复
1. SQL patch 将 transcode_task/media_thumbnail 的 error_msg 改为 TEXT,可重复执行。
2. 新增统一错误消息截断,最多保留 500 字符并附总长度;即使补丁尚未执行,
也不会超过旧 varchar(500) 限制。
3. ffmpeg 使用 `-progress` 临时文件,worker 节流为约 1 秒写 transcode_task.progress。
4. rendition 阶段映射 0-90%,封面/缩略图 90-99%,成功后 100。
5. 失败保留最后进度;RUNNING 超过 10 分钟无 updated_at 变化标记 FAILED。
6. 手动重转复用最新任务,SUCCESS/FAILED/CANCELED 原地重置为 QUEUED、progress=0。
7. 成功写 rendition 前替换同媒资旧产物,避免重复重转唯一键冲突。
## 风险
- 进度写入只允许 RUNNING 状态,避免覆盖已完成/失败状态。
- 历史僵尸任务由 worker 下一轮自动收口,无需手工 SQL。
## 验证
- 错误截断、进度更新、僵尸重置、重复触发幂等测试。
- `mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test`。
@@ -0,0 +1,24 @@
# 转码 rendition 幂等与失败落库
## 根因
- 成功事务只按 media_asset_id 删除旧 rendition。
- 同一分集替换视频后 media_asset_id 改变、episode_id 不变,
旧行未被删除,触发 uk_rendition(episode_id, quality, format)。
- rendition 插入异常后任务未被可靠置为 FAILED。
## 修复
1. 成功事务删除条件分组为:
`media_asset_id = 本次媒体 OR (episode_id > 0 AND episode_id = 本次分集)`。
2. 缩略图登记按 episode_id 删除旧行后重新插入。
3. 主流程异常调用 failFinal,直接写 FAILED、错误、finished_at,保留 progress。
4. failFinal 自身异常在 worker 中 catch 并打印 ERROR,不静默。
5. error 截断同时保留头部与尾部,长度仍 <= 500。
## 验证
- 不同 media_asset_id、同 episode_id 的重复转码清理条件。
- thumbnail 旧行替换。
- 失败落库字段与尾段错误保留。
- `mvn -q -DskipTests=false test -pl ... -am`。
+587
View File
@@ -25314,3 +25314,590 @@ $ mvn -pl fruitshorts-admin-api -am test
后台控制器改动连同既有 admin 契约、权限、服务测试全部通过。 后台控制器改动连同既有 admin 契约、权限、服务测试全部通过。
未启停服务、未执行数据库迁移、未操作真机。 未启停服务、未执行数据库迁移、未操作真机。
## 2026-10-22 转码产物查询接口 P2
- 分支:`feature/media-transcode-output-api`
- 基线:`develop` / `origin/develop` = `f66eae5`
- 隔离工作树:`/private/tmp/fruitserver-media-output-api`
- 计划:`docs/plans/2026-10-22-media-transcode-output-api.md`
### TDD 红测
先补媒体清晰度、帧图分页、VTT 解析、后台聚合与 dashboard 强类型契约测试,
生产代码保持未实现。
```text
$ mvn -pl fruitshorts-module-media test
[INFO] Compiling 3 source files with javac [debug release 17] to target/test-classes
[ERROR] MediaTranscodeOutputServiceTest.java:[37,13] 找不到符号
符号: 类 MediaTranscodeOutputService
[INFO] fruitshorts-module-media ........................... FAILURE
[INFO] BUILD FAILURE
```
解读:失败原因正是查询服务尚未实现。`fruitshorts-module-media` 新增
`spring-boot-starter-test` test scope,使指定门禁能实际执行本次单元测试。
下一步实现 VTT 解析、稳定排序、分页和 admin 聚合 VO 后转绿。
### 实现
- 新增 `MediaTranscodeOutputService`:媒体存在性校验、rendition 稳定倒序、
VTT 读取与 cue 分页、签名后的 sprite/VTT/HLS 地址。
- 新增 `ThumbnailVttParser`:解析多 sprite 文件、毫秒时间与 xywh 矩形。
- `GET /admin/media/{id}/renditions` 返回强类型清晰度列表;媒体不存在使用
`DATA_NOT_FOUND`。
- `GET /admin/media/{id}/thumbnails` 默认 `pageIndex=1/pageSize=100`,
`pageSize` 上限 500;无记录返回 `EMPTY`,不抛错。
- `GET /admin/episodes/{id}/transcode` 聚合任务、rendition 与缩略图;
分集不存在、无视频、无媒资、无任务分别返回明确 status。
- `/admin/dashboard/transcode` 改为 `TranscodeDashboardRespVO`,queue 增加 total;
failedTasks 也使用明确任务 VO。
- 新响应 VO 均带 `@Schema`,Controller 使用 `Result<T>` / `PageResult<T>`,
新接口不再返回 Map/Object。
### 测试
```text
$ mvn -pl fruitshorts-module-media test
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
补充 admin-api 与依赖模块全量测试:
```text
$ mvn -pl fruitshorts-admin-api -am test
[INFO] Tests run: 51, Failures: 0, Errors: 0, Skipped: 0
[INFO] fruitshorts-admin-api .............................. SUCCESS
[INFO] BUILD SUCCESS
```
解读:清晰度排序、VTT 多 sprite 解析、帧图分页与 500 上限、空产物状态、
媒体不存在、分集不存在/无视频、dashboard 强类型响应均通过。
最终指定门禁再次运行仍为 8/8 通过。
未启停服务、未执行数据库变更、未修改 `/app`、Web、Flutter、specs 或 API 文档。
补跑全仓回归:
```text
$ mvn test
[INFO] fruitshorts-admin-api .............................. SUCCESS
[INFO] fruitshorts-app-api ................................ SUCCESS
[INFO] fruitshorts-transcode-worker ....................... SUCCESS
[INFO] BUILD SUCCESS
```
解读:admin 51、app 109、worker 32 个测试均通过,P1 入队/重转与 worker
链路未回归。
## 2026-10-22 转码保持原始宽高比
- 分支:`feature/transcode-preserve-aspect`
- 基线:`develop` / `origin/develop` = `40a884c`
- 隔离工作树:`/private/tmp/fruitserver-preserve-aspect`
- 计划:`docs/plans/2026-10-22-transcode-preserve-aspect.md`
按 TDD 先扩展真实 ffmpeg smoke,覆盖横屏、竖屏、源码短边小于档位、
输出偶数尺寸、SAR=1、ffprobe 实际尺寸与 master RESOLUTION。
生产代码暂不修改,下一步记录红测输出。
### TDD 红测
```text
$ mvn -pl fruitshorts-transcode-worker -am \
-Dtest=FfmpegTranscodeRunnerSmokeTest \
-Dsurefire.failIfNoSpecifiedTests=false test
[ERROR] should_keep_portrait_orientation_and_even_dimensions:
expected: 202
but was: 640
[ERROR] should_not_upscale_when_source_short_edge_is_below_rung:
expected: "1:1"
but was: "1280:1281"
[ERROR] Tests run: 4, Failures: 2, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE
```
解读:竖屏实际输出为 202x360,却把名义 640x360 回写到 rendition;
小源码片仍被放大,且未固定 SAR。横屏基础尺寸用例已通过,说明测试片生成、
ffprobe 探测与 HLS 链路本身正常。
### 实现
- ffprobe 读取源视频 width/height;存在 90/270 度旋转元数据时按最终显示方向交换宽高。
- 每档短边值取 `min(档位 width/height, 源显示短边)`,再向下取偶数。
- 横屏使用 `scale=-2:<short>:flags=lanczos,setsar=1`;
竖屏使用 `scale=<short>:-2:flags=lanczos,setsar=1`,只等比缩放。
- 不放大策略统一为“高于源短边的档位仍保留 quality 与码率,分辨率封顶到源短边”,
因而不会出现全部档位被跳过、无 rendition 可播的情况。
- 每档完成后 ffprobe 读取 variant HLS 的实际 width/height,写入
`ProducedRendition`,worker 继续原样回写 `media_rendition`;
master playlist 的 RESOLUTION 也使用实际尺寸。
### 定向测试
```text
$ mvn -pl fruitshorts-transcode-worker -am \
-Dtest=FfmpegTranscodeRunnerSmokeTest \
-Dsurefire.failIfNoSpecifiedTests=false test
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
解读:横屏、竖屏、不放大、偶数尺寸、SAR=1、ffprobe 实际尺寸与
master RESOLUTION 均通过真实 ffmpeg 验证。
### 最终门禁
```text
$ mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] Tests run: 35, Failures: 0, Errors: 0, Skipped: 0
[INFO] fruitshorts-module-media ........................... SUCCESS
[INFO] fruitshorts-transcode-worker ....................... SUCCESS
[INFO] BUILD SUCCESS
```
解读:P2 查询测试、worker 入队/失败回写测试和新增宽高比 smoke 全部通过。
未启停服务、未执行数据库变更、未改前端/App/specs 或 P2 接口字段。
## 2026-10-22 转码媒资 ID 精度与存量兜底
- 分支:`feature/transcode-media-id-fix`
- 基线:`develop` / `origin/develop` = `a35b02d`
### 现场取证
数据库只读查询确认:
```text
drama_episode.id = 2113150431755636737
drama_episode.video_asset_id = 2113150398670966786
media_asset.id = 2113150398670966786
transcode_task.media_asset_id= 2113150398670966786
```
JS 精度复现:
```text
raw: 2113150398670966786
Number(raw): 2113150398670966800
serialized: 2113150398670966800
same: false
```
解读:错误来自前端 Number 转换后的 Snowflake ID 精度丢失;后端兜底仍需覆盖
存量分集 video_asset_id 为空或误传分集/任务 ID 的情况。
### TDD 红测
```text
$ mvn -pl fruitshorts-transcode-worker -am \
-Dtest=MediaServiceTranscodePipelineTest \
-Dsurefire.failIfNoSpecifiedTests=false test
[ERROR] 找不到符号: 类 EpisodeVideoSourcePort
[INFO] BUILD FAILURE
$ mvn -pl fruitshorts-admin-api -am \
-Dtest=EpisodeTranscodeAdminServiceTest,EpisodeTranscodeContractTest \
-Dsurefire.failIfNoSpecifiedTests=false test
[ERROR] String 无法转换为 Long
[ERROR] 找不到符号: 方法 mediaAssetExists(Long)
[INFO] BUILD FAILURE
```
解读:红测先锁定字符串契约和跨域分集来源端口,生产代码尚未实现。
### 实现
- `EpisodeVideoSourcePort` 由 content 域 `EpisodeService` 实现,可按分集 id 或
`video_asset_id` 找到分集来源,并回填 `video_asset_id`。
- 手动转码先按 media_asset.id 锁定;未命中时通过端口找到分集,
按 video_url → source_url/objectKey 反查媒资。
- 仍缺失但本地文件真实存在时自动补登记 `media_asset`,再调用原转码入队。
- 聚合接口先按 video_url 解析媒资,失败才校验 `video_asset_id`;
episodeId/mediaAssetId/taskId 均显式转字符串。
- 无媒资空态返回 `NO_MEDIA_ASSET` 和可读 errorMsg。
### 定向测试
```text
$ mvn -pl fruitshorts-transcode-worker -am \
-Dtest=MediaServiceTranscodePipelineTest \
-Dsurefire.failIfNoSpecifiedTests=false test
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
$ mvn -pl fruitshorts-admin-api -am \
-Dtest=EpisodeTranscodeAdminServiceTest,EpisodeTranscodeContractTest \
-Dsurefire.failIfNoSpecifiedTests=false test
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
### 最终门禁
```text
$ mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] Tests run: 37, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
$ mvn -pl fruitshorts-admin-api -am test
[INFO] Tests run: 53, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
解读:精度字符串、空态、video_url 反查命中、文件存在补登记及原有转码链全部通过。
## 2026-10-22 转码错误截断与进度上报
- 分支:`feature/transcode-progress-fix`
- 基线:`develop` / `origin/develop` = `f9bc6ee`
- 计划:`docs/plans/2026-10-22-transcode-progress-fix.md`
现场根因已确认:ffmpeg 错误信息超过 error_msg varchar(500),回写失败使任务
停留在 RUNNING/0;同时现有 runner 未解析 ffmpeg progress,缺少 RUNNING 超时自愈。
### TDD 红测
```text
$ mvn -pl fruitshorts-transcode-worker -am \
-Dtest=TranscodeServiceTriggerTest,TranscodeWorkerTest,FfmpegTranscodeRunnerSmokeTest \
-Dsurefire.failIfNoSpecifiedTests=false test
[ERROR] TranscodeRequest 找不到 progressListener
[ERROR] 找不到符号: 方法 updateProgress(Long,int)
[ERROR] 找不到符号: 方法 recoverStaleRunningTasks()
[INFO] BUILD FAILURE
```
解读:红测先锁定进度回调、进度落库和僵尸自愈接口,生产代码尚未实现。
### 实现
- error_msg 写库前统一截断为最多 500 字符,附 `truncated,total=...`;
即使数据库补丁尚未执行,也不会超过旧 varchar(500)。
- ffmpeg 使用临时 `-progress` 文件,每 500ms 解析 out_time,
worker 最多每秒写一次 transcode_task.progress;只更新 RUNNING 状态。
- rendition 阶段映射 0-90%,封面/缩略图 90-99%,成功事务置 100;
失败保留最后进度。
- RUNNING 且 updated_at 超过 10 分钟未变化时自动标记 FAILED。
- 手动重转复用最新任务,SUCCESS/FAILED/CANCELED 均原任务重置为 QUEUED、progress=0。
- 成功写 rendition 前删除同媒资旧产物,避免重复重转唯一键冲突。
- 聚合 VO 和任务 VO 增加 updatedAt,供前端展示最近更新时间。
### SQL 补丁
文件:`sql/patch-2026-10-22-transcode-error-msg.sql`
PM 按项目现有 patch 方式执行(本任务未执行数据库变更),例如使用项目当前
PostgreSQL 连接串:
```text
$ psql "$DB_URL" -f sql/patch-2026-10-22-transcode-error-msg.sql
```
脚本只将 `transcode_task.error_msg` 与 `media_thumbnail.error_msg`
改为 TEXT,可重复执行。
### 定向测试
```text
$ mvn -pl fruitshorts-transcode-worker -am \
-Dtest=TranscodeServiceTriggerTest,TranscodeWorkerTest,FfmpegTranscodeRunnerSmokeTest \
-Dsurefire.failIfNoSpecifiedTests=false test
[INFO] Tests run: 16, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
### 最终门禁
```text
$ mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] Tests run: 39, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
$ mvn -pl fruitshorts-admin-api -am test
[INFO] Tests run: 53, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
解读:真实 ffmpeg smoke 已有进度回调且达到 90% 阶段点;长错误截断、
进度更新、僵尸任务收口、重复触发复用均通过。
## 2026-10-22 转码任务认领与媒资 ID 校验
- 分支:`feature/transcode-claim-fix`
- 基线:`develop` / `origin/develop` = `08eddc9`
- 计划:`docs/plans/2026-10-22-transcode-claim-fix.md`
### 现场补充取证
```text
transcode_task id=2113167358829924354
media_asset_id=21, episode_id=0, status=QUEUED,
source_url=/files/episode/202610/f1b105e185f2405d838c1066313a7ff9.mp4
media_asset id=21 存在且 source_url 相同
drama_episode id=13 使用同一 video_url,但 video_asset_id=0
```
解读:21 不是不存在的媒资,而是未绑定分集的合法媒资;旧手动触发生成的
episode_id=0 任务被 claim 条件静默跳过。
### TDD 红测
```text
$ mvn -pl fruitshorts-transcode-worker -am \
-Dtest=MediaServiceTranscodePipelineTest,TranscodeClaimAndStorageTest \
-Dsurefire.failIfNoSpecifiedTests=false test
[ERROR] 找不到符号: resolveByVideoUrl(String)
[ERROR] trigger(Long,String,Long,Long) 不存在
[INFO] BUILD FAILURE
```
解读:红测先锁定“按视频 URL 绑定分集”与严格媒资 id 触发接口。
### 实现
- POST /admin/media/{id}/transcode 只按 `media_asset.id` 查询,不再把路径 id
当成分集 id 兜底,也不再自动生成替代媒资。
- 媒资存在后按 source_url 查找使用该视频的分集;未找到直接返回
`BAD_REQUEST: 媒资未绑定分集视频`,不创建 episode_id=0 任务。
- 找到分集后先回填 video_asset_id,再向 TranscodeService 传
mediaAssetId/dramaId/episodeId/sourceUrl。
- QUEUED/RUNNING 任务复用时会补齐 drama/episode 绑定,避免历史零绑任务继续卡住。
- claim 查询增加 media_asset_id>0、episode_id>0、source_url 非空条件;
对不可认领任务按 id 去重打印一次 WARN 和具体原因。
- RUNNING 无 updated_at 变化阈值从 10 分钟收紧到 5 分钟,覆盖 worker 重启遗留。
- 认领、每档完成、整体成功/失败均有明确日志;ffmpeg 进度仍每秒节流写库。
### 数据修复脚本
文件:`sql/patch-2026-10-22-transcode-task-id-fix.sql`
脚本将 QUEUED/RUNNING 且 `episode_id=0` 或 media_asset_id 不存在于
media_asset 的任务标记 FAILED 并写明原因,可重复执行。本任务未执行数据库变更。
### 定向测试
```text
$ mvn -pl fruitshorts-transcode-worker -am \
-Dtest=MediaServiceTranscodePipelineTest,TranscodeClaimAndStorageTest,TranscodeServiceTriggerTest \
-Dsurefire.failIfNoSpecifiedTests=false test
[INFO] Tests run: 22, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
### 最终门禁
```text
$ mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] Tests run: 39, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
$ mvn -pl fruitshorts-admin-api -am test
[INFO] Tests run: 53, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
解读:非法/未绑定媒资拒绝、合法 URL 自动绑定、claim 跳过日志、
进度上报、重复触发幂等与重启僵尸收口全部通过。
## 2026-10-22 rendition 唯一键冲突与失败落库
- 分支:`feature/fix-transcode-rendition-upsert`
- 基线:`develop` / `origin/develop` = `5d1a157`
- 计划:`docs/plans/2026-10-22-transcode-rendition-upsert.md`
运行时证据显示同分集复用新媒资时旧 rendition 未删除,触发
`uk_rendition(episode_id, quality, format)`;异常后任务停留在 RUNNING。
### 实现
- `TranscodeService.succeed` 在同一事务内删除:
`(media_asset_id = 本次媒体 OR episode_id = 本次分集)` 的旧 rendition,
再插入本次产物。
- `media_thumbnail` 表没有 media_asset_id 列,因此按 episode_id 删除旧行后重新插入;
覆盖 sprite/VTT 时间轴记录,避免同集唯一键冲突。
- 新增 `failFinal`:主流程异常立即写 status=FAILED、截断后的 error_msg、
finished_at,不修改 progress。
- worker 对 failFinal 与媒资失败状态写入分别 catch,失败时打印 ERROR 堆栈,不让异常逃逸。
- 错误截断同时保留头部和尾部关键信息,长度 <= 500。
### 测试
```text
$ mvn -pl fruitshorts-transcode-worker -am \
-Dtest=TranscodeClaimAndStorageTest,TranscodeServiceTriggerTest,TranscodeWorkerTest \
-Dsurefire.failIfNoSpecifiedTests=false test
[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
### 最终门禁
```text
$ mvn -q -DskipTests=false test \
-pl fruitshorts-module-media,fruitshorts-transcode-worker,fruitshorts-admin-api -am
(退出码 0,BUILD SUCCESS)
```
解读:同分集不同 media_asset_id 的旧 rendition 会被清理,thumbnail 不重复,
插入异常后任务最终 FAILED 且错误非空;现有认领、worker 与查询测试全部通过。
提交:`91b621f fix(media): 清理旧 rendition 并确保失败状态落库`。
## 2026-10-22 ReelShort 客户端四 Tab 底部导航
- 仓库:ReelShort Flutter 客户端
- 分支:`feature/client-bottom-nav-4tabs`
- 基线:`develop` / `origin/develop` = `ec3cb8f`
- 计划:客户端仓库 `docs/plans/2026-10-22-client-bottom-nav-4tabs.md`
- 实现提交:`91e869d feat(nav): 底部导航改为首页推荐追剧我的`
- 留痕提交:`729c226 docs(nav): 回填四 Tab 底部导航记录`
- 客户端合并:`667fdc5 merge: 合并四 Tab 底部导航`
### 问题
原底栏为首页 / 剧场 / 创作 / 我的,选中态使用品牌橙;追剧若通过 push 收藏页,
会叠加第二层底栏并产生返回栈。
### 改动文件
- 导航基础:`lib/core/widgets/app_root_tab.dart`、
`lib/core/widgets/app_bottom_nav.dart`、`lib/core/theme/app_colors.dart`、
`app_dimens.dart`、`app_theme.dart`。
- 根页面:`home_view.dart`、`home_logic.dart`、`discover_view.dart`、
`discover_types.dart`、`mine_view.dart`、`mine_types.dart`、
`player_logic.dart`。
- 收藏嵌入:`favorites_page.dart` 抽出 `FavoritesContent`;独立
`FavoritesPage` 保留 Scaffold,首页 `IndexedStack` 第三槽直接嵌入内容。
- 文案:三份 ARB 与重新生成的 `app_localizations*.dart`。
- 测试:导航、发现、我的、播放器及主题相关测试。
首页、推荐、追剧、我的均使用同一根 `IndexedStack` 和同一底栏。追剧内容内嵌,
无返回按钮、无嵌套 Scaffold、无第二层导航且 `Navigator.canPop == false`;
创作从底栏移除,但“我的”页 `mine-creator-center` 入口保留。
底栏纯黑,选中图标和文字均纯白 `#FFFFFF`,未选中 `#8A8A8E`。
### 门禁命令与原始输出
```text
$ dart analyze lib test
Analyzing lib, test...
No issues found!
```
```text
$ flutter test --no-pub
04:38 +644: All tests passed!
```
解读:四 Tab 顺序与文案、追剧当前页切换、单一底栏、无返回栈、创作入口保留
均有测试覆盖;全量 644 项通过。`pubspec.lock` 未提交,未启动或停止服务,
未操作真机。
## 2026-10-22 ReelShort 客户端底栏布局补充修复
- 分支:`feature/client-bottom-nav-layout`
- 基线:`develop` / `origin/develop` = `667fdc5`
- 实现提交:`81787dc style(nav): 对齐四 Tab 底栏布局规格`
- 留痕提交:`5362f97 docs(nav): 回填底栏布局规格记录`
- 客户端合并:`6dea9d4 merge: 对齐四 Tab 底栏布局规格`
补充收敛 `AppRootBottomNav`,四个根 tab 不再由页面各自拼装 item。
内容高度 56 + SafeArea,图标 26,图标/文字间距 4,文字 11、字距 0,
选中 w600、未选中 w400;纯黑背景无分割线,选中 `#FFFFFF`,
未选中修正为 `#8E8E93`。四列均为 `Expanded` 等分。
```text
$ flutter analyze
Analyzing reelshort-bottom-nav...
No issues found! (ran in 5.2s)
$ flutter test
04:43 +645: All tests passed!
```
解读:底栏尺寸、安全区、颜色、无分割线和共享导航实例均有测试覆盖。
`pubspec.lock` 未提交,未操作真机。
## 2026-10-22 ReelShort 客户端首页三列网格
- 分支:`feature/home-grid-layout`
- 基线:`develop` / `origin/develop` = `6dea9d4`
- 实现提交:`dd96792 feat(home): 首页改为三列探索网格`
- 留痕提交:`1bb227e docs(home): 回填首页网格改版记录`
- 客户端合并:`47bf645 merge: 首页切换三列探索网格`
### 参考图对照
- 顶部:深灰搜索框 + 皇冠 VIP + 礼物钱包入口;钱包角标仅使用真实余额。
- 一级分类顺序:精选、新剧、分类、榜单;古装/社群仅在 `/app/categories`
名称匹配时追加,匹配不到隐藏。
- 二级标签:热门、爽剧、甜宠、打脸虐渣、虐恋;按分类/题材可用性显示,
右侧三横线进入现有 `FilterPage`。
- 内容:三列、间距 8、封面 5:6、圆角 8;卡片包含热剧/新剧/漫剧角标、
播放量、两行标题和最多两个题材标签。
- 分页继续使用 `HomeFeedController` 的 10 条游标分页、下拉刷新和底部加载。
- 原 `PlayerPage(homeMode: true)` 保留并迁到推荐 tab,播放器代码与测试未删除。
### 改动文件
`home_view.dart`、`widgets/home_grid_header.dart`、`widgets/home_feed_grid.dart`、
`widgets/home_feed_card.dart`、`widgets/home_drama_classification.dart`、
`widgets/home_failure_view.dart`、`Drama` 模型、`AppColors/AppDimens`、三语 ARB
及对应生成的 l10n 文件、首页/导航/发现/播放器测试。
```text
$ flutter analyze
Analyzing reelshort-bottom-nav...
No issues found! (ran in 2.7s)
$ flutter test
02:32 +651: All tests passed!
```
解读:三列网格、分类顺序、动态隐藏、角标、卡片元数据、分页大小、
下一页追加、搜索入口和卡片播放均有测试覆盖。`home_view.dart` 为 478 行,
其余新增组件均低于 500 行。`pubspec.lock` 未提交,未操作真机。
## 2026-10-22 ReelShort 客户端「我的」视觉统一
- 分支:`feature/mine-visual-style`
- 基线:`develop` / `origin/develop` = `47bf645`
- 实现提交:`83129ac feat(mine): 统一我的页深色视觉风格`
- 留痕提交:`e40f477 docs(mine): 回填我的页视觉统一记录`
- 客户端合并:`0ef2438 merge: 统一我的页深色视觉风格`
### Token 说明
- 复用首页:`appBackground #000000`、`contentCard #171717`、
`contentSurface #1C1C1E`、`contentBorder rgba(255,255,255,0.10)`。
- 新增:`textPrimary #FFFFFF`、`textSecondary #8E8E93`、
`mineCardRadius 16`、`mineRowHeight 52`、页头标题 21、区块标题 15。
- 橙色仅保留充值、加入 VIP 等主操作。
### 改动与行为
`mine_view.dart`、`widgets/mine_header.dart`、`widgets/mine_menu.dart` 及公共
`AppColors/AppDimens`、测试完成视觉收敛。页面底色纯黑,头像卡、三宫格、
金币卡、会员卡、菜单统一深灰 16 圆角、1px 描边、无阴影/渐变/发光;
菜单 52 行高且仅行间分割。区块顺序、全部入口、跳转和交互保持不变。
```text
$ flutter analyze
Analyzing reelshort-bottom-nav...
No issues found!
$ flutter test
00:56 +651: All tests passed!
```
解读:背景、卡片 token、描边/圆角、三宫格等宽、菜单行高、入口保留及收藏页
跳转均有测试覆盖。`pubspec.lock` 未提交,未操作真机。
@@ -4,6 +4,7 @@ package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result; import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.admin.service.AppSessionSystemService;
import com.fruitshorts.admin.vo.AppUserWatchHistoryVO; import com.fruitshorts.admin.vo.AppUserWatchHistoryVO;
import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode; import com.fruitshorts.content.dal.entity.DramaEpisode;
@@ -13,6 +14,7 @@ import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.framework.log.OperationLog; import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.playback.dal.entity.WatchHistory; import com.fruitshorts.playback.dal.entity.WatchHistory;
import com.fruitshorts.user.dal.entity.AppUserSession;
import com.fruitshorts.playback.service.WatchProgressService; import com.fruitshorts.playback.service.WatchProgressService;
import com.fruitshorts.user.dal.entity.AppUserBanRecord; import com.fruitshorts.user.dal.entity.AppUserBanRecord;
import com.fruitshorts.user.service.AppUserAdminService; import com.fruitshorts.user.service.AppUserAdminService;
@@ -44,6 +46,7 @@ public class AppUserAdminController {
private final WatchProgressService watchProgressService; private final WatchProgressService watchProgressService;
private final DramaService dramaService; private final DramaService dramaService;
private final EpisodeService episodeService; private final EpisodeService episodeService;
private final AppSessionSystemService sessionSystem;
@Operation(summary = "App 用户分页查询") @Operation(summary = "App 用户分页查询")
@GetMapping("/page") @GetMapping("/page")
@@ -83,11 +86,15 @@ public class AppUserAdminController {
Map<Long, DramaEpisode> episodes = episodeService Map<Long, DramaEpisode> episodes = episodeService
.listByIds(rows.stream().map(WatchHistory::getEpisodeId).distinct().toList()) .listByIds(rows.stream().map(WatchHistory::getEpisodeId).distinct().toList())
.stream().collect(Collectors.toMap(DramaEpisode::getId, item -> item, (a, b) -> a)); .stream().collect(Collectors.toMap(DramaEpisode::getId, item -> item, (a, b) -> a));
return Result.ok(rows.stream().map(row -> toVo(row, dramas, episodes)).toList()); List<AppUserSession> sessions = sessionSystem.groupByUser(List.of(id))
.getOrDefault(id, List.of());
return Result.ok(rows.stream()
.map(row -> toVo(row, dramas, episodes, sessionSystem.match(sessions, row.getDeviceId())))
.toList());
} }
private AppUserWatchHistoryVO toVo(WatchHistory row, Map<Long, Drama> dramas, private AppUserWatchHistoryVO toVo(WatchHistory row, Map<Long, Drama> dramas,
Map<Long, DramaEpisode> episodes) { Map<Long, DramaEpisode> episodes, AppUserSession session) {
AppUserWatchHistoryVO vo = new AppUserWatchHistoryVO(); AppUserWatchHistoryVO vo = new AppUserWatchHistoryVO();
vo.setId(row.getId()); vo.setId(row.getId());
vo.setDramaId(row.getDramaId()); vo.setDramaId(row.getDramaId());
@@ -100,6 +107,10 @@ public class AppUserAdminController {
long position = row.getPositionMs() == null ? 0L : row.getPositionMs(); long position = row.getPositionMs() == null ? 0L : row.getPositionMs();
long duration = row.getDurationMs() == null ? 0L : row.getDurationMs(); long duration = row.getDurationMs() == null ? 0L : row.getDurationMs();
vo.setFinished(duration > 0 && position + 1000L >= duration); vo.setFinished(duration > 0 && position + 1000L >= duration);
if (session != null) {
vo.setPlatform(session.getPlatform());
vo.setOsVersion(session.getOsVersion());
}
Drama drama = dramas.get(row.getDramaId()); Drama drama = dramas.get(row.getDramaId());
if (drama != null) { if (drama != null) {
vo.setDramaTitle(drama.getTitle()); vo.setDramaTitle(drama.getTitle());
@@ -3,6 +3,7 @@
package com.fruitshorts.admin.controller; package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.DashboardService; import com.fruitshorts.admin.service.DashboardService;
import com.fruitshorts.admin.vo.TranscodeDashboardRespVO;
import com.fruitshorts.common.pojo.Result; import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@@ -40,7 +41,7 @@ public class DashboardAdminController {
@Operation(summary = "转码看板(队列积压与失败任务)") @Operation(summary = "转码看板(队列积压与失败任务)")
@GetMapping("/transcode") @GetMapping("/transcode")
@PreAuthorize("@ss.hasPermission('dashboard:view')") @PreAuthorize("@ss.hasPermission('dashboard:view')")
public Result<Map<String, Object>> transcode() { public Result<TranscodeDashboardRespVO> transcode() {
return Result.ok(dashboardService.transcode()); return Result.ok(dashboardService.transcode());
} }
} }
@@ -0,0 +1,115 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.service.DramaClassificationService;
import com.fruitshorts.content.vo.DramaBriefVO;
import com.fruitshorts.content.vo.DramaClassificationRespVO;
import com.fruitshorts.content.vo.DramaClassificationSaveReqVO;
import com.fruitshorts.content.vo.DramaIdsReqVO;
import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 运营分类管理。分类与短剧类型(drama-categories)是两套独立概念,
* 分类可自定义增删,并关联任意多部短剧。
*/
@Tag(name = "后台-运营分类")
@RestController
@RequestMapping("/admin/drama-classifications")
@RequiredArgsConstructor
public class DramaClassificationController {
private final DramaClassificationService classificationService;
@Operation(summary = "分类列表(带关联数量)")
@GetMapping
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<DramaClassificationRespVO>> list() {
return Result.ok(classificationService.list());
}
@Operation(summary = "新建分类")
@PostMapping
@PreAuthorize("@ss.hasPermission('drama:create')")
@OperationLog(module = "内容管理", action = "新增分类")
public Result<Long> create(@Valid @RequestBody DramaClassificationSaveReqVO req) {
return Result.ok(classificationService.create(req));
}
@Operation(summary = "编辑分类")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "编辑分类")
public Result<Void> update(@PathVariable Long id,
@Valid @RequestBody DramaClassificationSaveReqVO req) {
classificationService.update(id, req);
return Result.ok();
}
@Operation(summary = "删除分类")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:delete')")
@OperationLog(module = "内容管理", action = "删除分类")
public Result<Void> delete(@PathVariable Long id) {
classificationService.delete(id);
return Result.ok();
}
@Operation(summary = "分类下已关联的短剧")
@GetMapping("/{id}/dramas")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<PageResult<DramaBriefVO>> dramas(
@PathVariable Long id,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") int pageIndex,
@RequestParam(defaultValue = "10") int pageSize) {
return Result.ok(classificationService.pageDramas(id, keyword, pageIndex, pageSize));
}
@Operation(summary = "尚未关联到该分类的短剧")
@GetMapping("/{id}/dramas/candidates")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<PageResult<DramaBriefVO>> candidates(
@PathVariable Long id,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") int pageIndex,
@RequestParam(defaultValue = "10") int pageSize) {
return Result.ok(classificationService.pageCandidates(id, keyword, pageIndex, pageSize));
}
@Operation(summary = "批量关联短剧")
@PostMapping("/{id}/dramas")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "分类关联短剧")
public Result<Integer> bindDramas(@PathVariable Long id,
@Valid @RequestBody DramaIdsReqVO req) {
return Result.ok(classificationService.bindDramas(id, req.getDramaIds()));
}
@Operation(summary = "移除分类下的短剧")
@DeleteMapping("/{id}/dramas/{dramaId}")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "分类移除短剧")
public Result<Void> unbindDrama(@PathVariable Long id, @PathVariable Long dramaId) {
classificationService.unbindDrama(id, dramaId);
return Result.ok();
}
}
@@ -0,0 +1,115 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.service.DramaPlaylistCategoryService;
import com.fruitshorts.content.vo.DramaBriefVO;
import com.fruitshorts.content.vo.DramaIdsReqVO;
import com.fruitshorts.content.vo.DramaPlaylistCategoryRespVO;
import com.fruitshorts.content.vo.DramaPlaylistCategorySaveReqVO;
import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 剧单分类管理:分类直接关联短剧,没有「剧单」这一层。
* 与「类型」「分类管理」均无关。
*/
@Tag(name = "后台-剧单分类")
@RestController
@RequestMapping("/admin/drama-playlist-categories")
@RequiredArgsConstructor
public class DramaPlaylistCategoryController {
private final DramaPlaylistCategoryService categoryService;
@Operation(summary = "剧单分类列表")
@GetMapping
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<DramaPlaylistCategoryRespVO>> list() {
return Result.ok(categoryService.list());
}
@Operation(summary = "新建剧单分类")
@PostMapping
@PreAuthorize("@ss.hasPermission('drama:create')")
@OperationLog(module = "内容管理", action = "新增剧单分类")
public Result<Long> create(@Valid @RequestBody DramaPlaylistCategorySaveReqVO req) {
return Result.ok(categoryService.create(req));
}
@Operation(summary = "编辑剧单分类")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "编辑剧单分类")
public Result<Void> update(@PathVariable Long id,
@Valid @RequestBody DramaPlaylistCategorySaveReqVO req) {
categoryService.update(id, req);
return Result.ok();
}
@Operation(summary = "删除剧单分类")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:delete')")
@OperationLog(module = "内容管理", action = "删除剧单分类")
public Result<Void> delete(@PathVariable Long id) {
categoryService.delete(id);
return Result.ok();
}
@Operation(summary = "分类下已关联的短剧")
@GetMapping("/{id}/dramas")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<PageResult<DramaBriefVO>> dramas(
@PathVariable Long id,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") int pageIndex,
@RequestParam(defaultValue = "10") int pageSize) {
return Result.ok(categoryService.pageDramas(id, keyword, pageIndex, pageSize));
}
@Operation(summary = "尚未关联到该分类的短剧")
@GetMapping("/{id}/dramas/candidates")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<PageResult<DramaBriefVO>> candidates(
@PathVariable Long id,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") int pageIndex,
@RequestParam(defaultValue = "10") int pageSize) {
return Result.ok(categoryService.pageCandidates(id, keyword, pageIndex, pageSize));
}
@Operation(summary = "批量关联短剧")
@PostMapping("/{id}/dramas")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "剧单分类关联短剧")
public Result<Integer> bindDramas(@PathVariable Long id,
@Valid @RequestBody DramaIdsReqVO req) {
return Result.ok(categoryService.bindDramas(id, req.getDramaIds()));
}
@Operation(summary = "移除分类下的短剧")
@DeleteMapping("/{id}/dramas/{dramaId}")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "剧单分类移除短剧")
public Result<Void> unbindDrama(@PathVariable Long id, @PathVariable Long dramaId) {
categoryService.unbindDrama(id, dramaId);
return Result.ok();
}
}
@@ -0,0 +1,32 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.EpisodeTranscodeAdminService;
import com.fruitshorts.admin.vo.EpisodeTranscodeRespVO;
import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 后台剧集转码聚合查询。 */
@Tag(name = "后台-剧集转码")
@RestController
@RequestMapping("/admin/episodes")
@RequiredArgsConstructor
public class EpisodeTranscodeController {
private final EpisodeTranscodeAdminService service;
@Operation(summary = "查询分集转码聚合信息")
@GetMapping("/{id}/transcode")
@PreAuthorize("@ss.hasPermission('episode:list')")
public Result<EpisodeTranscodeRespVO> detail(@PathVariable Long id) {
return Result.ok(service.detail(id));
}
}
@@ -0,0 +1,47 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.system.service.LegalDocumentService;
import com.fruitshorts.system.vo.LegalDocumentVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 后台维护服务条款和隐私协议。公开页面由客户端服务按语言输出。
*/
@Tag(name = "后台-协议")
@RestController
@RequestMapping("/admin/legal-documents")
@RequiredArgsConstructor
public class LegalDocumentAdminController {
private final LegalDocumentService legalDocumentService;
@Operation(summary = "协议列表(服务条款、隐私协议,各含中英印尼语)")
@GetMapping
@PreAuthorize("@ss.hasAnyPermission('system:role','system:menu')")
public Result<List<LegalDocumentVO>> list() {
return Result.ok(legalDocumentService.list());
}
@Operation(summary = "保存协议")
@PutMapping
@PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "修改服务条款或隐私协议")
public Result<Void> save(@RequestBody List<LegalDocumentVO> items) {
legalDocumentService.save(items);
return Result.ok();
}
}
@@ -0,0 +1,46 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.media.service.MediaTranscodeOutputService;
import com.fruitshorts.media.vo.MediaRenditionRespVO;
import com.fruitshorts.media.vo.MediaThumbnailRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 后台媒资转码产物只读查询。 */
@Tag(name = "后台-媒资转码产物")
@RestController
@RequestMapping("/admin/media")
@RequiredArgsConstructor
public class MediaTranscodeOutputController {
private final MediaTranscodeOutputService outputService;
@Operation(summary = "查询媒资清晰度产物")
@GetMapping("/{id}/renditions")
@PreAuthorize("@ss.hasPermission('episode:upload')")
public Result<List<MediaRenditionRespVO>> renditions(@PathVariable Long id) {
return Result.ok(outputService.renditions(id));
}
@Operation(summary = "分页查询媒资帧图产物")
@GetMapping("/{id}/thumbnails")
@PreAuthorize("@ss.hasPermission('episode:upload')")
public Result<MediaThumbnailRespVO> thumbnails(
@PathVariable Long id,
@RequestParam(defaultValue = "1") Integer pageIndex,
@RequestParam(defaultValue = "100") Integer pageSize) {
return Result.ok(outputService.thumbnails(id, pageIndex, pageSize));
}
}
@@ -0,0 +1,62 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.user.dal.entity.AppUserSession;
import com.fruitshorts.user.dal.mapper.AppUserSessionMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 按用户最近会话解析系统。同一设备优先,否则用仍有效的最近一次登录。
*/
@Service
@RequiredArgsConstructor
public class AppSessionSystemService {
private final AppUserSessionMapper sessionMapper;
/** 按用户分组,组内按最近活跃倒序。 */
public Map<Long, List<AppUserSession>> groupByUser(Collection<Long> userIds) {
Map<Long, List<AppUserSession>> grouped = new HashMap<>();
if (userIds == null || userIds.isEmpty()) {
return grouped;
}
List<AppUserSession> sessions = sessionMapper.selectList(Wrappers.<AppUserSession>lambdaQuery()
.in(AppUserSession::getUserId, userIds)
.orderByDesc(AppUserSession::getLastActiveAt)
.orderByDesc(AppUserSession::getId));
for (AppUserSession session : sessions) {
grouped.computeIfAbsent(session.getUserId(), key -> new ArrayList<>()).add(session);
}
return grouped;
}
/** 观看记录优先匹配同一设备,其次取未吊销会话。 */
public AppUserSession match(List<AppUserSession> sessions, String deviceId) {
if (sessions == null || sessions.isEmpty()) {
return null;
}
if (StringUtils.hasText(deviceId)) {
for (AppUserSession session : sessions) {
if (deviceId.equals(session.getDeviceId())) {
return session;
}
}
}
for (AppUserSession session : sessions) {
if (session.getRevokedAt() == null) {
return session;
}
}
return sessions.get(0);
}
}
@@ -3,6 +3,7 @@
package com.fruitshorts.admin.service; package com.fruitshorts.admin.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.admin.vo.TranscodeDashboardRespVO;
import com.fruitshorts.commerce.dal.entity.CoinLedger; import com.fruitshorts.commerce.dal.entity.CoinLedger;
import com.fruitshorts.commerce.dal.entity.Entitlement; import com.fruitshorts.commerce.dal.entity.Entitlement;
import com.fruitshorts.commerce.dal.entity.Orders; import com.fruitshorts.commerce.dal.entity.Orders;
@@ -21,6 +22,8 @@ import com.fruitshorts.interaction.dal.entity.ContentReport;
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper; import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
import com.fruitshorts.interaction.dal.mapper.ContentReportMapper; import com.fruitshorts.interaction.dal.mapper.ContentReportMapper;
import com.fruitshorts.media.service.TranscodeService; import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.media.vo.TranscodeQueueStatsVO;
import com.fruitshorts.media.vo.TranscodeTaskRespVO;
import com.fruitshorts.playback.dal.entity.WatchHistory; import com.fruitshorts.playback.dal.entity.WatchHistory;
import com.fruitshorts.playback.dal.entity.WatchProgress; import com.fruitshorts.playback.dal.entity.WatchProgress;
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper; import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
@@ -101,12 +104,14 @@ public class DashboardService {
} }
/** 转码看板:队列积压与失败明细。 */ /** 转码看板:队列积压与失败明细。 */
public Map<String, Object> transcode() { public TranscodeDashboardRespVO transcode() {
Map<String, Object> data = new LinkedHashMap<>(); TranscodeDashboardRespVO data = new TranscodeDashboardRespVO();
data.put("generatedAt", LocalDateTime.now()); data.setGeneratedAt(LocalDateTime.now());
data.put("queue", transcodeService.stats()); data.setQueue(transcodeService.stats());
data.put("failedTasks", transcodeService.failedTasks(20)); data.setFailedTasks(transcodeService.failedTasks(20).stream()
data.put("notes", List.of( .map(TranscodeTaskRespVO::from)
.toList());
data.setNotes(List.of(
"queue 为累计口径(不限时间),反映当前积压与历史成败分布", "queue 为累计口径(不限时间),反映当前积压与历史成败分布",
"failedTasks 最多返回 20 条,按 id 倒序")); "failedTasks 最多返回 20 条,按 id 倒序"));
return data; return data;
@@ -205,12 +210,12 @@ public class DashboardService {
private Map<String, Object> costMetrics(LocalDateTime sevenDaysAgo) { private Map<String, Object> costMetrics(LocalDateTime sevenDaysAgo) {
Map<String, Object> cost = new LinkedHashMap<>(); Map<String, Object> cost = new LinkedHashMap<>();
Map<String, Object> queue = transcodeService.stats(); TranscodeQueueStatsVO queue = transcodeService.stats();
cost.put("transcodeQueued", queue.get("queued")); cost.put("transcodeQueued", queue.getQueued());
cost.put("transcodeRunning", queue.get("running")); cost.put("transcodeRunning", queue.getRunning());
cost.put("transcodeFailed", queue.get("failed")); cost.put("transcodeFailed", queue.getFailed());
cost.put("transcodeSuccess", queue.get("success")); cost.put("transcodeSuccess", queue.getSuccess());
cost.put("renditions", queue.get("renditions")); cost.put("renditions", queue.getRenditions());
cost.put("otpSent7d", appUserOtpMapper.selectCount(Wrappers.<AppUserOtp>lambdaQuery() cost.put("otpSent7d", appUserOtpMapper.selectCount(Wrappers.<AppUserOtp>lambdaQuery()
.ge(AppUserOtp::getCreatedAt, sevenDaysAgo))); .ge(AppUserOtp::getCreatedAt, sevenDaysAgo)));
cost.put("cdnTrafficBytes", null); cost.put("cdnTrafficBytes", null);
@@ -0,0 +1,103 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.service;
import com.fruitshorts.admin.vo.EpisodeTranscodeRespVO;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.media.service.MediaTranscodeOutputService;
import com.fruitshorts.media.vo.MediaThumbnailRespVO;
import com.fruitshorts.media.vo.TranscodeTaskRespVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Locale;
/** 聚合剧集与媒资转码产物,供后台编辑页一次读取。 */
@Service
@RequiredArgsConstructor
public class EpisodeTranscodeAdminService {
private static final String STATUS_EPISODE_NOT_FOUND = "EPISODE_NOT_FOUND";
private static final String STATUS_NO_VIDEO = "NO_VIDEO";
private static final String STATUS_NO_MEDIA_ASSET = "NO_MEDIA_ASSET";
private static final String STATUS_NO_TASK = "NO_TASK";
private static final String REASON_EPISODE_NOT_FOUND = "分集不存在或已删除";
private static final String REASON_NO_VIDEO = "分集未配置视频";
private static final String REASON_NO_MEDIA_ASSET = "未找到分集视频对应的媒资";
private static final int THUMBNAIL_PAGE_INDEX = 1;
private static final int THUMBNAIL_PAGE_SIZE = 100;
private final DramaEpisodeMapper episodeMapper;
private final MediaTranscodeOutputService outputService;
public EpisodeTranscodeRespVO detail(Long episodeId) {
DramaEpisode episode = episodeId == null ? null : episodeMapper.selectById(episodeId);
if (episode == null) {
return empty(episodeId, STATUS_EPISODE_NOT_FOUND, REASON_EPISODE_NOT_FOUND);
}
boolean hasVideoAsset = episode.getVideoAssetId() != null
&& episode.getVideoAssetId() > 0;
if (!StringUtils.hasText(episode.getVideoUrl()) && !hasVideoAsset) {
return empty(episodeId, STATUS_NO_VIDEO, REASON_NO_VIDEO);
}
Long mediaAssetId = outputService.resolveMediaAssetId(episode.getVideoUrl());
if ((mediaAssetId == null || mediaAssetId <= 0) && hasVideoAsset
&& outputService.mediaAssetExists(episode.getVideoAssetId())) {
mediaAssetId = episode.getVideoAssetId();
}
if (mediaAssetId == null || mediaAssetId <= 0) {
return empty(episodeId, STATUS_NO_MEDIA_ASSET, REASON_NO_MEDIA_ASSET);
}
EpisodeTranscodeRespVO result = new EpisodeTranscodeRespVO();
result.setEpisodeId(idString(episodeId));
result.setMediaAssetId(idString(mediaAssetId));
result.setRenditions(outputService.renditionsForMediaAssetId(mediaAssetId));
result.setThumbnails(outputService.thumbnailForEpisode(
episodeId, mediaAssetId, THUMBNAIL_PAGE_INDEX, THUMBNAIL_PAGE_SIZE));
TranscodeTaskRespVO task = outputService.latestTask(mediaAssetId);
if (task == null) {
result.setStatus(taskStatusOf(episode));
result.setProgress(0);
result.setRetryCount(0);
result.setErrorMsg("");
result.setUpdatedAt(episode.getUpdatedAt());
return result;
}
result.setTaskId(idString(task.getId()));
result.setStatus(task.getStatus());
result.setProgress(task.getProgress());
result.setRetryCount(task.getRetryCount());
result.setErrorMsg(task.getErrorMsg());
result.setUpdatedAt(task.getUpdatedAt());
return result;
}
private EpisodeTranscodeRespVO empty(Long episodeId, String status, String reason) {
EpisodeTranscodeRespVO result = new EpisodeTranscodeRespVO();
result.setEpisodeId(idString(episodeId));
result.setStatus(status);
result.setProgress(0);
result.setRetryCount(0);
result.setErrorMsg(reason);
result.setRenditions(List.of());
result.setThumbnails(MediaThumbnailRespVO.empty(
null, episodeId, MediaThumbnailRespVO.STATUS_EMPTY,
THUMBNAIL_PAGE_INDEX, THUMBNAIL_PAGE_SIZE));
return result;
}
private String idString(Long id) {
return id == null ? null : String.valueOf(id);
}
private String taskStatusOf(DramaEpisode episode) {
return StringUtils.hasText(episode.getTranscodeStatus())
? episode.getTranscodeStatus().toUpperCase(Locale.ROOT)
: STATUS_NO_TASK;
}
}
@@ -17,6 +17,7 @@ import com.fruitshorts.content.enums.ContentStatus;
import com.fruitshorts.playback.dal.entity.WatchHistory; import com.fruitshorts.playback.dal.entity.WatchHistory;
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper; import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
import com.fruitshorts.user.dal.entity.AppUser; import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.entity.AppUserSession;
import com.fruitshorts.user.dal.mapper.AppUserMapper; import com.fruitshorts.user.dal.mapper.AppUserMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -46,6 +47,7 @@ public class WatchHistoryAdminService {
private final AppUserMapper appUserMapper; private final AppUserMapper appUserMapper;
private final DramaMapper dramaMapper; private final DramaMapper dramaMapper;
private final DramaEpisodeMapper dramaEpisodeMapper; private final DramaEpisodeMapper dramaEpisodeMapper;
private final AppSessionSystemService sessionSystem;
/** 按后台筛选条件分页查询观看记录。 */ /** 按后台筛选条件分页查询观看记录。 */
public PageResult<AdminWatchHistoryVO> page(WatchHistoryQueryReqVO req) { public PageResult<AdminWatchHistoryVO> page(WatchHistoryQueryReqVO req) {
@@ -125,10 +127,13 @@ public class WatchHistoryAdminService {
Map<Long, AppUser> users = loadUsers(rows); Map<Long, AppUser> users = loadUsers(rows);
Map<Long, Drama> dramas = loadDramas(rows); Map<Long, Drama> dramas = loadDramas(rows);
Map<Long, DramaEpisode> episodes = loadEpisodes(rows); Map<Long, DramaEpisode> episodes = loadEpisodes(rows);
Map<Long, List<AppUserSession>> sessions = sessionSystem.groupByUser(
rows.stream().map(WatchHistory::getUserId).distinct().toList());
List<AdminWatchHistoryVO> list = new ArrayList<>(rows.size()); List<AdminWatchHistoryVO> list = new ArrayList<>(rows.size());
for (WatchHistory row : rows) { for (WatchHistory row : rows) {
list.add(toVo(row, users.get(row.getUserId()), dramas.get(row.getDramaId()), list.add(toVo(row, users.get(row.getUserId()), dramas.get(row.getDramaId()),
episodes.get(row.getEpisodeId()))); episodes.get(row.getEpisodeId()),
sessionSystem.match(sessions.get(row.getUserId()), row.getDeviceId())));
} }
return list; return list;
} }
@@ -161,7 +166,7 @@ public class WatchHistoryAdminService {
} }
private AdminWatchHistoryVO toVo(WatchHistory row, AppUser user, Drama drama, private AdminWatchHistoryVO toVo(WatchHistory row, AppUser user, Drama drama,
DramaEpisode episode) { DramaEpisode episode, AppUserSession session) {
AdminWatchHistoryVO vo = new AdminWatchHistoryVO(); AdminWatchHistoryVO vo = new AdminWatchHistoryVO();
vo.setId(row.getId()); vo.setId(row.getId());
vo.setUserId(row.getUserId()); vo.setUserId(row.getUserId());
@@ -172,6 +177,10 @@ public class WatchHistoryAdminService {
vo.setSource(row.getSource()); vo.setSource(row.getSource());
vo.setDeviceId(row.getDeviceId()); vo.setDeviceId(row.getDeviceId());
vo.setWatchedAt(row.getWatchedAt()); vo.setWatchedAt(row.getWatchedAt());
if (session != null) {
vo.setPlatform(session.getPlatform());
vo.setOsVersion(session.getOsVersion());
}
long position = row.getPositionMs() == null ? 0L : row.getPositionMs(); long position = row.getPositionMs() == null ? 0L : row.getPositionMs();
long duration = row.getDurationMs() == null ? 0L : row.getDurationMs(); long duration = row.getDurationMs() == null ? 0L : row.getDurationMs();
vo.setFinished(duration > 0L && position + FINISHED_TOLERANCE_MS >= duration); vo.setFinished(duration > 0L && position + FINISHED_TOLERANCE_MS >= duration);
@@ -29,5 +29,9 @@ public class AdminWatchHistoryVO {
private Boolean finished; private Boolean finished;
private String source; private String source;
private String deviceId; private String deviceId;
/** ANDROID / IOS / WEB,来自该次设备的登录会话。 */
private String platform;
/** 系统版本,例如 Android 16。 */
private String osVersion;
private LocalDateTime watchedAt; private LocalDateTime watchedAt;
} }
@@ -28,5 +28,9 @@ public class AppUserWatchHistoryVO {
private Boolean finished; private Boolean finished;
private String source; private String source;
private String deviceId; private String deviceId;
/** ANDROID / IOS / WEB,来自该次设备的登录会话。 */
private String platform;
/** 系统版本。 */
private String osVersion;
private LocalDateTime watchedAt; private LocalDateTime watchedAt;
} }
@@ -0,0 +1,28 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
import com.fruitshorts.media.vo.MediaRenditionRespVO;
import com.fruitshorts.media.vo.MediaThumbnailRespVO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/** 后台剧集编辑页一次性获取的转码状态。 */
@Data
@Schema(description = "分集转码聚合信息")
public class EpisodeTranscodeRespVO {
private String episodeId;
private String mediaAssetId;
private String taskId;
private String status;
private Integer progress;
private Integer retryCount;
private String errorMsg;
private LocalDateTime updatedAt;
private List<MediaRenditionRespVO> renditions = List.of();
private MediaThumbnailRespVO thumbnails;
}
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
import com.fruitshorts.media.vo.TranscodeQueueStatsVO;
import com.fruitshorts.media.vo.TranscodeTaskRespVO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/** 后台转码看板强类型响应。 */
@Data
@Schema(description = "转码看板")
public class TranscodeDashboardRespVO {
private LocalDateTime generatedAt;
private TranscodeQueueStatsVO queue;
private List<TranscodeTaskRespVO> failedTasks = List.of();
private List<String> notes = List.of();
}
@@ -0,0 +1,52 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.DashboardService;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.admin.vo.TranscodeDashboardRespVO;
import com.fruitshorts.media.vo.TranscodeQueueStatsVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class DashboardTranscodeContractTest {
private DashboardService dashboardService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
dashboardService = mock(DashboardService.class);
mockMvc = ControllerContractSupport.mockMvcOf(
new DashboardAdminController(dashboardService));
}
@Test
void should_return_typed_queue_and_failed_tasks() throws Exception {
TranscodeQueueStatsVO queue = new TranscodeQueueStatsVO(
10L, 2L, 1L, 5L, 1L, 1L, 20L);
TranscodeDashboardRespVO vo = new TranscodeDashboardRespVO();
vo.setQueue(queue);
vo.setFailedTasks(List.of());
vo.setNotes(List.of("queue total"));
given(dashboardService.transcode()).willReturn(vo);
mockMvc.perform(get("/admin/dashboard/transcode"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.queue.total").value(10))
.andExpect(jsonPath("$.data.queue.queued").value(2))
.andExpect(jsonPath("$.data.queue.running").value(1))
.andExpect(jsonPath("$.data.queue.success").value(5))
.andExpect(jsonPath("$.data.queue.failed").value(1))
.andExpect(jsonPath("$.data.failedTasks").isArray());
}
}
@@ -0,0 +1,69 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.EpisodeTranscodeAdminService;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.admin.vo.EpisodeTranscodeRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class EpisodeTranscodeContractTest {
private EpisodeTranscodeAdminService service;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
service = mock(EpisodeTranscodeAdminService.class);
mockMvc = ControllerContractSupport.mockMvcOf(
new EpisodeTranscodeController(service));
}
@Test
void should_return_aggregated_episode_transcode_state() throws Exception {
String episodeId = "2113150431755636737";
String mediaAssetId = "2113150398670966786";
String taskId = "2113150398935207938";
EpisodeTranscodeRespVO vo = new EpisodeTranscodeRespVO();
vo.setEpisodeId(episodeId);
vo.setMediaAssetId(mediaAssetId);
vo.setTaskId(taskId);
vo.setStatus("SUCCESS");
vo.setProgress(100);
vo.setUpdatedAt(LocalDateTime.of(2026, 10, 22, 15, 10));
given(service.detail(Long.parseLong(episodeId))).willReturn(vo);
mockMvc.perform(get("/admin/episodes/" + episodeId + "/transcode"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.episodeId").value(episodeId))
.andExpect(jsonPath("$.data.mediaAssetId").value(mediaAssetId))
.andExpect(jsonPath("$.data.taskId").value(taskId))
.andExpect(jsonPath("$.data.status").value("SUCCESS"))
.andExpect(jsonPath("$.data.progress").value(100))
.andExpect(jsonPath("$.data.updatedAt").value("2026-10-22T15:10:00"));
}
@Test
void should_return_explicit_empty_state_when_episode_has_no_video() throws Exception {
EpisodeTranscodeRespVO vo = new EpisodeTranscodeRespVO();
vo.setEpisodeId("31");
vo.setStatus("NO_VIDEO");
given(service.detail(31L)).willReturn(vo);
mockMvc.perform(get("/admin/episodes/31/transcode"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("NO_VIDEO"))
.andExpect(jsonPath("$.data.renditions").isEmpty());
}
}
@@ -0,0 +1,90 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.media.service.MediaTranscodeOutputService;
import com.fruitshorts.media.vo.MediaRenditionRespVO;
import com.fruitshorts.media.vo.MediaThumbnailRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class MediaTranscodeOutputContractTest {
private MediaTranscodeOutputService outputService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
outputService = mock(MediaTranscodeOutputService.class);
mockMvc = ControllerContractSupport.mockMvcOf(
new MediaTranscodeOutputController(outputService));
}
@Test
void should_return_typed_rendition_list() throws Exception {
MediaRenditionRespVO vo = new MediaRenditionRespVO();
vo.setQuality("1080p");
vo.setQualityName("1080P");
vo.setResolution("1920x1080");
vo.setVideoBitrateKbps(5000);
vo.setVideoCodec("H.264");
vo.setAudioCodec("AAC");
vo.setContainer("HLS");
vo.setHlsUrl("/files/master.m3u8");
vo.setSizeBytes(4096L);
vo.setStatus("ready");
given(outputService.renditions(88L)).willReturn(List.of(vo));
mockMvc.perform(get("/admin/media/88/renditions"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data[0].quality").value("1080p"))
.andExpect(jsonPath("$.data[0].resolution").value("1920x1080"))
.andExpect(jsonPath("$.data[0].videoBitrateKbps").value(5000))
.andExpect(jsonPath("$.data[0].hlsUrl").value("/files/master.m3u8"));
}
@Test
void should_return_not_found_for_missing_media() throws Exception {
given(outputService.renditions(404L))
.willThrow(new ServiceException(ErrorCode.DATA_NOT_FOUND, "媒资不存在"));
mockMvc.perform(get("/admin/media/404/renditions"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.DATA_NOT_FOUND.getCode()))
.andExpect(jsonPath("$.message").value("媒资不存在"));
}
@Test
void should_return_empty_thumbnail_status_without_error() throws Exception {
MediaThumbnailRespVO vo = new MediaThumbnailRespVO();
vo.setStatus("EMPTY");
vo.setPageIndex(1);
vo.setPageSize(100);
vo.setFrames(PageResult.empty());
given(outputService.thumbnails(88L, 1, 100)).willReturn(vo);
mockMvc.perform(get("/admin/media/88/thumbnails"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("EMPTY"))
.andExpect(jsonPath("$.data.frames.total").value(0))
.andExpect(jsonPath("$.data.frames.list").isEmpty());
verify(outputService).thumbnails(88L, 1, 100);
}
}
@@ -0,0 +1,48 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.service;
import com.fruitshorts.user.dal.entity.AppUserSession;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/** 观看记录匹配登录系统。 */
class AppSessionSystemServiceTest {
private final AppSessionSystemService service = new AppSessionSystemService(null);
@Test
void should_use_same_device_session_when_watch_history_has_device() {
AppUserSession older = session("device-1", "ANDROID", "14", null);
AppUserSession newer = session("device-2", "IOS", "18", null);
AppUserSession matched = service.match(List.of(newer, older), "device-1");
assertThat(matched.getPlatform()).isEqualTo("ANDROID");
assertThat(matched.getOsVersion()).isEqualTo("14");
}
@Test
void should_use_active_session_when_device_does_not_match() {
AppUserSession revoked = session("device-9", "WEB", "1", LocalDateTime.now());
AppUserSession active = session("device-2", "IOS", "18", null);
AppUserSession matched = service.match(List.of(revoked, active), "missing");
assertThat(matched.getPlatform()).isEqualTo("IOS");
}
private AppUserSession session(String deviceId, String platform, String osVersion,
LocalDateTime revokedAt) {
AppUserSession session = new AppUserSession();
session.setDeviceId(deviceId);
session.setPlatform(platform);
session.setOsVersion(osVersion);
session.setRevokedAt(revokedAt);
return session;
}
}
@@ -0,0 +1,116 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.service;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.media.service.MediaTranscodeOutputService;
import com.fruitshorts.media.vo.MediaThumbnailRespVO;
import com.fruitshorts.media.vo.TranscodeTaskRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
class EpisodeTranscodeAdminServiceTest {
private static final Long EPISODE_ID = 2113150431755636737L;
private static final Long MEDIA_ASSET_ID = 2113150398670966786L;
private static final Long TASK_ID = 2113150398935207938L;
private DramaEpisodeMapper episodeMapper;
private MediaTranscodeOutputService outputService;
private EpisodeTranscodeAdminService service;
@BeforeEach
void setUp() {
episodeMapper = mock(DramaEpisodeMapper.class);
outputService = mock(MediaTranscodeOutputService.class);
service = new EpisodeTranscodeAdminService(episodeMapper, outputService);
}
@Test
void should_return_episode_not_found_state_without_aggregating_media() {
given(episodeMapper.selectById(404L)).willReturn(null);
var result = service.detail(404L);
assertThat(result.getStatus()).isEqualTo("EPISODE_NOT_FOUND");
assertThat(result.getEpisodeId()).isEqualTo("404");
assertThat(result.getRenditions()).isEmpty();
verifyNoInteractions(outputService);
}
@Test
void should_return_no_video_state_when_episode_video_url_is_blank() {
DramaEpisode episode = new DramaEpisode();
episode.setId(31L);
episode.setVideoUrl("");
given(episodeMapper.selectById(31L)).willReturn(episode);
var result = service.detail(31L);
assertThat(result.getStatus()).isEqualTo("NO_VIDEO");
assertThat(result.getRenditions()).isEmpty();
verifyNoInteractions(outputService);
}
@Test
void should_return_exact_snowflake_media_asset_id_as_string() {
DramaEpisode episode = episode(MEDIA_ASSET_ID);
given(episodeMapper.selectById(EPISODE_ID)).willReturn(episode);
given(outputService.resolveMediaAssetId(episode.getVideoUrl())).willReturn(null);
given(outputService.mediaAssetExists(MEDIA_ASSET_ID)).willReturn(true);
given(outputService.renditionsForMediaAssetId(MEDIA_ASSET_ID)).willReturn(List.of());
given(outputService.thumbnailForEpisode(
EPISODE_ID, MEDIA_ASSET_ID, 1, 100))
.willReturn(MediaThumbnailRespVO.empty(
MEDIA_ASSET_ID, EPISODE_ID,
MediaThumbnailRespVO.STATUS_EMPTY, 1, 100));
TranscodeTaskRespVO task = new TranscodeTaskRespVO();
task.setId(TASK_ID);
task.setMediaAssetId(MEDIA_ASSET_ID);
task.setStatus("SUCCESS");
task.setProgress(100);
task.setUpdatedAt(LocalDateTime.of(2026, 10, 22, 15, 10));
given(outputService.latestTask(MEDIA_ASSET_ID)).willReturn(task);
var result = service.detail(EPISODE_ID);
assertThat(result.getEpisodeId()).isEqualTo("2113150431755636737");
assertThat(result.getMediaAssetId()).isEqualTo("2113150398670966786");
assertThat(result.getTaskId()).isEqualTo("2113150398935207938");
assertThat(result.getUpdatedAt()).isEqualTo(task.getUpdatedAt());
}
@Test
void should_return_explicit_empty_state_when_episode_media_is_missing() {
DramaEpisode episode = episode(MEDIA_ASSET_ID);
given(episodeMapper.selectById(EPISODE_ID)).willReturn(episode);
given(outputService.mediaAssetExists(MEDIA_ASSET_ID)).willReturn(false);
given(outputService.resolveMediaAssetId(episode.getVideoUrl())).willReturn(null);
var result = service.detail(EPISODE_ID);
assertThat(result.getMediaAssetId()).isNull();
assertThat(result.getStatus()).isEqualTo("NO_MEDIA_ASSET");
assertThat(result.getErrorMsg()).isNotBlank();
assertThat(result.getRenditions()).isEmpty();
}
private DramaEpisode episode(Long videoAssetId) {
DramaEpisode episode = new DramaEpisode();
episode.setId(EPISODE_ID);
episode.setDramaId(7L);
episode.setDurationSec(120);
episode.setVideoAssetId(videoAssetId);
episode.setVideoUrl("/files/episode/202610/video.mp4");
return episode;
}
}
@@ -58,7 +58,7 @@ class WatchHistoryAdminServiceTest {
dramaMapper = mock(DramaMapper.class); dramaMapper = mock(DramaMapper.class);
dramaEpisodeMapper = mock(DramaEpisodeMapper.class); dramaEpisodeMapper = mock(DramaEpisodeMapper.class);
service = new WatchHistoryAdminService(watchHistoryMapper, appUserMapper, dramaMapper, service = new WatchHistoryAdminService(watchHistoryMapper, appUserMapper, dramaMapper,
dramaEpisodeMapper); dramaEpisodeMapper, mock(AppSessionSystemService.class));
} }
@Test @Test
@@ -68,7 +68,7 @@ public class AppAuthController {
@Operation(summary = "刷新令牌(旧 refresh token 立即失效)") @Operation(summary = "刷新令牌(旧 refresh token 立即失效)")
@PostMapping("/refresh") @PostMapping("/refresh")
public Result<TokenRespVO> refresh(@Valid @RequestBody RefreshReqVO req) { public Result<TokenRespVO> refresh(@Valid @RequestBody RefreshReqVO req) {
return Result.ok(authService.refresh(req.getRefreshToken())); return Result.ok(authService.refresh(req.getRefreshToken(), req.getPlatform(), req.getOsVersion()));
} }
/** 登出:默认只吊销当前设备的会话,allDevices=true 时吊销全部。 */ /** 登出:默认只吊销当前设备的会话,allDevices=true 时吊销全部。 */
@@ -9,6 +9,7 @@ import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.playback.service.FavoriteService; import com.fruitshorts.playback.service.FavoriteService;
import com.fruitshorts.playback.vo.FavoriteRemoveReqVO;
import com.fruitshorts.playback.vo.FavoriteToggleReqVO; import com.fruitshorts.playback.vo.FavoriteToggleReqVO;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** 客户端搜索:按标题或简介模糊匹配已上线短剧。 */ /** 客户端搜索:按标题或简介模糊匹配已上线短剧。不需要登录。 */
@Tag(name = "客户端-搜索") @Tag(name = "客户端-搜索")
@RestController @RestController
@RequestMapping("/app") @RequestMapping("/app")
@@ -0,0 +1,44 @@
// Author: fruit.com.cn
package com.fruitshorts.app.controller;
import com.fruitshorts.system.service.LegalDocumentService;
import com.fruitshorts.system.service.LegalPages;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
/**
* 协议公开页。客户端按当前语言打开 /privacy/zh 或 /terms/en,不需要登录。
*/
@RestController
@RequiredArgsConstructor
public class LegalPageController {
private static final MediaType HTML = MediaType.parseMediaType("text/html;charset=UTF-8");
private final LegalDocumentService legalDocumentService;
/** 隐私协议。语言只接受 zh、en、id。 */
@GetMapping("/" + LegalPages.PATH_PRIVACY + "/{lang}")
public ResponseEntity<String> privacy(@PathVariable String lang) {
return page(LegalPages.PATH_PRIVACY, lang);
}
/** 服务条款。语言只接受 zh、en、id。 */
@GetMapping("/" + LegalPages.PATH_TERMS + "/{lang}")
public ResponseEntity<String> terms(@PathVariable String lang) {
return page(LegalPages.PATH_TERMS, lang);
}
private ResponseEntity<String> page(String path, String lang) {
String html = legalDocumentService.render(path, lang);
if (html == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok().contentType(HTML).body(html);
}
}
@@ -0,0 +1,33 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.system.service.LegalPageHtml;
import com.fruitshorts.system.service.LegalPages;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LegalPageHtmlTest {
@Test
void should_render_markdown_without_an_extra_title() {
String html = LegalPageHtml.render("zh", "第一行\n\n<script>alert(1)</script>\n\n**粗体**");
assertTrue(html.contains("<html lang=\"zh\">"));
assertFalse(html.contains("<h1>"));
assertTrue(html.contains("<p>第一行</p>"));
assertTrue(html.contains("&lt;script&gt;"));
assertTrue(html.contains("<strong>粗体</strong>"));
}
@Test
void should_map_public_paths_to_document_codes() {
assertEquals(LegalPages.PRIVACY, LegalPages.docCodeOfPath("privacy"));
assertEquals(LegalPages.TERMS, LegalPages.docCodeOfPath("terms"));
assertNull(LegalPages.docCodeOfPath("other"));
}
}
@@ -5,6 +5,7 @@ import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode; import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.DramaService; import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.media.dal.entity.MediaRendition;
import com.fruitshorts.media.service.PlaybackSigner; import com.fruitshorts.media.service.PlaybackSigner;
import com.fruitshorts.media.service.ThumbnailUrlResolver; import com.fruitshorts.media.service.ThumbnailUrlResolver;
import com.fruitshorts.media.service.TranscodeService; import com.fruitshorts.media.service.TranscodeService;
@@ -147,6 +148,30 @@ class PlaybackServiceTest {
verify(signer).sign("/files/episode/15/master.m3u8"); verify(signer).sign("/files/episode/15/master.m3u8");
} }
@Test
void should_sign_each_ready_rendition_when_episode_has_multiple_qualities() {
DramaEpisode episode = episode(16L, 1, 0, "/files/episode/origin.mp4");
episode.setHlsUrl("/files/transcoded/16/master.m3u8");
given(episodeService.getPublished(16L)).willReturn(episode);
given(signer.sign(anyString())).willAnswer(invocation -> invocation.getArgument(0) + "?s=1");
MediaRendition ready = new MediaRendition();
ready.setQuality("720p");
ready.setPlayUrl("/files/transcoded/16/720p/index.m3u8");
ready.setStatus("ready");
MediaRendition failed = new MediaRendition();
failed.setQuality("1080p");
failed.setPlayUrl("/files/transcoded/16/1080p/index.m3u8");
failed.setStatus("failed");
given(transcodeService.listRenditions(16L)).willReturn(List.of(ready, failed));
PlayRespVO resp = playbackService.play(null, 16L, "device-9", "203.0.113.8");
assertThat(resp.getTracks()).extracting(track -> track.getQuality())
.containsExactly("AUTO", "720p");
assertThat(resp.getTracks().get(1).getPlayUrl())
.isEqualTo("/files/transcoded/16/720p/index.m3u8?s=1");
}
private DramaEpisode episode(Long id, int isFree, int unlockCoin, String videoUrl) { private DramaEpisode episode(Long id, int isFree, int unlockCoin, String videoUrl) {
DramaEpisode episode = new DramaEpisode(); DramaEpisode episode = new DramaEpisode();
episode.setId(id); episode.setId(id);
@@ -15,6 +15,7 @@ import com.fruitshorts.playback.dal.entity.WatchProgress;
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper; import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
import com.fruitshorts.playback.dal.mapper.WatchProgressMapper; import com.fruitshorts.playback.dal.mapper.WatchProgressMapper;
import com.fruitshorts.playback.vo.ProgressRespVO; import com.fruitshorts.playback.vo.ProgressRespVO;
import com.fruitshorts.playback.vo.ProgressSaveReqVO;
import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -28,6 +29,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -108,6 +110,30 @@ class WatchProgressServiceTest {
return progress; return progress;
} }
@Test
void should_revive_deleted_progress_when_same_drama_is_reported_again() {
DramaEpisode episode = episode(88L, 1);
episode.setDramaId(5L);
given(episodeService.getPublished(88L)).willReturn(episode);
given(dramaService.getPublished(5L)).willReturn(drama(5L, "剧名", 10));
WatchProgress hidden = progress(5L, 88L);
hidden.setId(9L);
hidden.setUserId(7L);
hidden.setDeleted(1);
given(progressMapper.selectAny(7L, 5L)).willReturn(hidden);
given(progressMapper.selectById(9L)).willReturn(hidden);
ProgressSaveReqVO req = new ProgressSaveReqVO();
req.setEpisodeId(88L);
req.setPositionMs(1_000L);
req.setDurationMs(2_000L);
progressService.save(7L, req);
verify(progressMapper, never()).insert(any(WatchProgress.class));
verify(progressMapper).updateAny(hidden);
assertThat(hidden.getDeleted()).isZero();
}
private Drama drama(Long id, String title, int totalEpisodes) { private Drama drama(Long id, String title, int totalEpisodes) {
Drama drama = new Drama(); Drama drama = new Drama();
drama.setId(id); drama.setId(id);
@@ -40,11 +40,15 @@ public class SecurityConfig {
"/app/categories/**", "/app/categories/**",
"/app/banners/**", "/app/banners/**",
"/app/dramas/**", "/app/dramas/**",
"/app/search",
"/app/episodes/*/play", "/app/episodes/*/play",
"/app/config/**", "/app/config/**",
/* 启动配置与公告:客户端登录前就要拿(POST /app/creator/apply 等仍要登录) */ /* 启动配置与公告:客户端登录前就要拿(POST /app/creator/apply 等仍要登录) */
"/app/config", "/app/config",
"/app/notices", "/app/notices",
/* 服务条款与隐私协议:登录前就要能打开,按语言返回 HTML */
"/privacy/**",
"/terms/**",
/* 埋点上报:游客浏览也要记录,匿名可访问 */ /* 埋点上报:游客浏览也要记录,匿名可访问 */
"/app/events", "/app/events",
/* 媒资:封面与视频用 <img>/<video> 直接访问,无法带请求头。 /* 媒资:封面与视频用 <img>/<video> 直接访问,无法带请求头。
@@ -0,0 +1,24 @@
// Author: fruit.com.cn
package com.fruitshorts.content.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 运营分类:与短剧类型(drama_category)是两套独立概念。 */
@Data
@TableName("drama_classification")
public class DramaClassification {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String name;
private Integer sort;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
package com.fruitshorts.content.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 分类与短剧的关联关系(多对多)。 */
@Data
@TableName("drama_classification_drama")
public class DramaClassificationDrama {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long classificationId;
private Long dramaId;
private LocalDateTime createdAt;
}
@@ -0,0 +1,26 @@
// Author: fruit.com.cn
package com.fruitshorts.content.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 剧单专属分类:只服务于剧单分组,与运营分类(drama_classification)无关。 */
@Data
@TableName("drama_playlist_category")
public class DramaPlaylistCategory {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String name;
/** 分类描述,便于运营说明该分类的用途 */
private String description;
private Integer sort;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
package com.fruitshorts.content.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 剧单分类与短剧的关联关系(分类直接挂短剧,没有剧单层)。 */
@Data
@TableName("drama_playlist_category_drama")
public class DramaPlaylistCategoryDrama {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long categoryId;
private Long dramaId;
private LocalDateTime createdAt;
}
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
package com.fruitshorts.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.content.dal.entity.DramaClassificationDrama;
public interface DramaClassificationDramaMapper extends BaseMapper<DramaClassificationDrama> {
}
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
package com.fruitshorts.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.content.dal.entity.DramaClassification;
public interface DramaClassificationMapper extends BaseMapper<DramaClassification> {
}
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
package com.fruitshorts.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.content.dal.entity.DramaPlaylistCategoryDrama;
public interface DramaPlaylistCategoryDramaMapper extends BaseMapper<DramaPlaylistCategoryDrama> {
}
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
package com.fruitshorts.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.content.dal.entity.DramaPlaylistCategory;
public interface DramaPlaylistCategoryMapper extends BaseMapper<DramaPlaylistCategory> {
}
@@ -0,0 +1,204 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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.DramaClassification;
import com.fruitshorts.content.dal.entity.DramaClassificationDrama;
import com.fruitshorts.content.dal.mapper.DramaClassificationDramaMapper;
import com.fruitshorts.content.dal.mapper.DramaClassificationMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.vo.DramaBriefVO;
import com.fruitshorts.content.vo.DramaClassificationRespVO;
import com.fruitshorts.content.vo.DramaClassificationSaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 运营分类服务。分类是独立于「短剧类型(drama_category)」的一套概念,
* 由运营自定义增删,并通过关联表挂载任意多部短剧。
*/
@Service
@RequiredArgsConstructor
public class DramaClassificationService {
private final DramaClassificationMapper classificationMapper;
private final DramaClassificationDramaMapper classificationDramaMapper;
private final DramaMapper dramaMapper;
public List<DramaClassificationRespVO> list() {
Map<Long, Integer> counts = countDramas();
return classificationMapper.selectList(Wrappers.<DramaClassification>lambdaQuery()
.orderByAsc(DramaClassification::getSort)
.orderByDesc(DramaClassification::getId))
.stream().map(item -> {
DramaClassificationRespVO vo = new DramaClassificationRespVO();
vo.setId(item.getId());
vo.setName(item.getName());
vo.setSort(item.getSort());
vo.setStatus(item.getStatus());
vo.setUpdatedAt(item.getUpdatedAt());
vo.setDramaCount(counts.getOrDefault(item.getId(), 0));
return vo;
}).toList();
}
public Long create(DramaClassificationSaveReqVO req) {
ensureNameAvailable(req.getName(), null);
DramaClassification entity = new DramaClassification();
entity.setName(req.getName().trim());
Long count = classificationMapper.selectCount(null);
entity.setSort(req.getSort() == null
? (count == null ? 0 : count.intValue()) + 1
: req.getSort());
entity.setStatus(req.getStatus() == null ? 1 : req.getStatus());
classificationMapper.insert(entity);
return entity.getId();
}
public void update(Long id, DramaClassificationSaveReqVO req) {
DramaClassification entity = getExisting(id);
ensureNameAvailable(req.getName(), id);
entity.setName(req.getName().trim());
if (req.getSort() != null) {
entity.setSort(req.getSort());
}
if (req.getStatus() != null) {
entity.setStatus(req.getStatus());
}
classificationMapper.updateById(entity);
}
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
getExisting(id);
classificationDramaMapper.delete(Wrappers.<DramaClassificationDrama>lambdaQuery()
.eq(DramaClassificationDrama::getClassificationId, id));
classificationMapper.deleteById(id);
}
/** 分类下已关联的短剧(分页)。 */
public PageResult<DramaBriefVO> pageDramas(Long id, String keyword,
int pageIndex, int pageSize) {
return pageDramas(id, keyword, pageIndex, pageSize, true);
}
/** 尚未关联到该分类的短剧(分页),供「添加短剧」多选。 */
public PageResult<DramaBriefVO> pageCandidates(Long id, String keyword,
int pageIndex, int pageSize) {
return pageDramas(id, keyword, pageIndex, pageSize, false);
}
/** 批量关联短剧;已关联的自动跳过。 */
@Transactional(rollbackFor = Exception.class)
public int bindDramas(Long id, List<Long> dramaIds) {
getExisting(id);
if (dramaIds == null || dramaIds.isEmpty()) {
return 0;
}
int added = 0;
for (Long dramaId : dramaIds) {
if (dramaId == null || dramaMapper.selectById(dramaId) == null) {
continue;
}
Long exists = classificationDramaMapper.selectCount(
Wrappers.<DramaClassificationDrama>lambdaQuery()
.eq(DramaClassificationDrama::getClassificationId, id)
.eq(DramaClassificationDrama::getDramaId, dramaId));
if (exists != null && exists > 0) {
continue;
}
DramaClassificationDrama relation = new DramaClassificationDrama();
relation.setClassificationId(id);
relation.setDramaId(dramaId);
classificationDramaMapper.insert(relation);
added++;
}
return added;
}
/** 移出分类;短剧本身不受影响。 */
public void unbindDrama(Long id, Long dramaId) {
getExisting(id);
classificationDramaMapper.delete(Wrappers.<DramaClassificationDrama>lambdaQuery()
.eq(DramaClassificationDrama::getClassificationId, id)
.eq(DramaClassificationDrama::getDramaId, dramaId));
}
private PageResult<DramaBriefVO> pageDramas(Long id, String keyword,
int pageIndex, int pageSize, boolean bound) {
getExisting(id);
Page<Drama> page = new Page<>(Math.max(1, pageIndex), Math.max(1, Math.min(pageSize, 100)));
LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>lambdaQuery()
.like(StringUtils.hasText(keyword), Drama::getTitle, keyword)
.orderByDesc(Drama::getSort)
.orderByDesc(Drama::getId);
String subQuery = "SELECT drama_id FROM drama_classification_drama WHERE classification_id = " + id;
if (bound) {
wrapper.inSql(Drama::getId, subQuery);
} else {
wrapper.notInSql(Drama::getId, subQuery);
}
IPage<Drama> result = dramaMapper.selectPage(page, wrapper);
return new PageResult<>(result.getTotal(),
result.getRecords().stream().map(this::toBrief).toList());
}
private Map<Long, Integer> countDramas() {
Map<Long, Integer> counts = new HashMap<>();
classificationDramaMapper.selectMaps(new QueryWrapper<DramaClassificationDrama>()
.select("classification_id AS cid", "COUNT(*) AS cnt")
.groupBy("classification_id"))
.forEach(row -> {
Object cid = row.get("cid");
Object cnt = row.get("cnt");
if (cid instanceof Number c && cnt instanceof Number n) {
counts.put(c.longValue(), n.intValue());
}
});
return counts;
}
private DramaClassification getExisting(Long id) {
DramaClassification entity = id == null ? null : classificationMapper.selectById(id);
if (entity == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "分类不存在");
}
return entity;
}
private void ensureNameAvailable(String name, Long excludeId) {
Long count = classificationMapper.selectCount(Wrappers.<DramaClassification>lambdaQuery()
.eq(DramaClassification::getName, name.trim())
.ne(excludeId != null, DramaClassification::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "分类名称已存在");
}
}
private DramaBriefVO toBrief(Drama drama) {
DramaBriefVO vo = new DramaBriefVO();
vo.setId(drama.getId());
vo.setTitle(drama.getTitle());
vo.setCover(drama.getCoverUrl());
vo.setStatus(drama.getStatus());
vo.setTotalEpisodes(drama.getTotalEpisodes());
vo.setSort(drama.getSort());
vo.setUpdatedAt(drama.getUpdatedAt());
return vo;
}
}
@@ -0,0 +1,208 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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.DramaPlaylistCategory;
import com.fruitshorts.content.dal.entity.DramaPlaylistCategoryDrama;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaPlaylistCategoryDramaMapper;
import com.fruitshorts.content.dal.mapper.DramaPlaylistCategoryMapper;
import com.fruitshorts.content.vo.DramaBriefVO;
import com.fruitshorts.content.vo.DramaPlaylistCategoryRespVO;
import com.fruitshorts.content.vo.DramaPlaylistCategorySaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 剧单分类服务:分类直接关联短剧,没有「剧单」这一层。
* 这套分类与「类型」「分类管理」都不是同一套数据。
*/
@Service
@RequiredArgsConstructor
public class DramaPlaylistCategoryService {
private final DramaPlaylistCategoryMapper categoryMapper;
private final DramaPlaylistCategoryDramaMapper categoryDramaMapper;
private final DramaMapper dramaMapper;
public List<DramaPlaylistCategoryRespVO> list() {
Map<Long, Integer> counts = countDramas();
return categoryMapper.selectList(Wrappers.<DramaPlaylistCategory>lambdaQuery()
.orderByAsc(DramaPlaylistCategory::getSort)
.orderByDesc(DramaPlaylistCategory::getId))
.stream().map(item -> {
DramaPlaylistCategoryRespVO vo = new DramaPlaylistCategoryRespVO();
vo.setId(item.getId());
vo.setName(item.getName());
vo.setDescription(item.getDescription());
vo.setSort(item.getSort());
vo.setStatus(item.getStatus());
vo.setDramaCount(counts.getOrDefault(item.getId(), 0));
return vo;
}).toList();
}
public Long create(DramaPlaylistCategorySaveReqVO req) {
ensureNameAvailable(req.getName(), null);
DramaPlaylistCategory entity = new DramaPlaylistCategory();
entity.setName(req.getName().trim());
entity.setDescription(req.getDescription() == null ? "" : req.getDescription().trim());
Long count = categoryMapper.selectCount(null);
entity.setSort(req.getSort() == null
? (count == null ? 0 : count.intValue()) + 1
: req.getSort());
entity.setStatus(req.getStatus() == null ? 1 : req.getStatus());
categoryMapper.insert(entity);
return entity.getId();
}
public void update(Long id, DramaPlaylistCategorySaveReqVO req) {
DramaPlaylistCategory entity = getExisting(id);
ensureNameAvailable(req.getName(), id);
entity.setName(req.getName().trim());
if (req.getDescription() != null) {
entity.setDescription(req.getDescription().trim());
}
if (req.getSort() != null) {
entity.setSort(req.getSort());
}
if (req.getStatus() != null) {
entity.setStatus(req.getStatus());
}
categoryMapper.updateById(entity);
}
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
getExisting(id);
categoryDramaMapper.delete(Wrappers.<DramaPlaylistCategoryDrama>lambdaQuery()
.eq(DramaPlaylistCategoryDrama::getCategoryId, id));
categoryMapper.deleteById(id);
}
/** 分类下已关联的短剧(分页)。 */
public PageResult<DramaBriefVO> pageDramas(Long id, String keyword,
int pageIndex, int pageSize) {
return pageDramas(id, keyword, pageIndex, pageSize, true);
}
/** 尚未关联到该分类的短剧(分页),供「选择短剧」多选。 */
public PageResult<DramaBriefVO> pageCandidates(Long id, String keyword,
int pageIndex, int pageSize) {
return pageDramas(id, keyword, pageIndex, pageSize, false);
}
/** 批量把短剧加入分类;已关联的自动跳过。 */
@Transactional(rollbackFor = Exception.class)
public int bindDramas(Long id, List<Long> dramaIds) {
getExisting(id);
if (dramaIds == null || dramaIds.isEmpty()) {
return 0;
}
int added = 0;
for (Long dramaId : dramaIds) {
if (dramaId == null || dramaMapper.selectById(dramaId) == null) {
continue;
}
Long exists = categoryDramaMapper.selectCount(
Wrappers.<DramaPlaylistCategoryDrama>lambdaQuery()
.eq(DramaPlaylistCategoryDrama::getCategoryId, id)
.eq(DramaPlaylistCategoryDrama::getDramaId, dramaId));
if (exists != null && exists > 0) {
continue;
}
DramaPlaylistCategoryDrama relation = new DramaPlaylistCategoryDrama();
relation.setCategoryId(id);
relation.setDramaId(dramaId);
categoryDramaMapper.insert(relation);
added++;
}
return added;
}
/** 把短剧移出分类;短剧本身不受影响。 */
public void unbindDrama(Long id, Long dramaId) {
getExisting(id);
categoryDramaMapper.delete(Wrappers.<DramaPlaylistCategoryDrama>lambdaQuery()
.eq(DramaPlaylistCategoryDrama::getCategoryId, id)
.eq(DramaPlaylistCategoryDrama::getDramaId, dramaId));
}
private PageResult<DramaBriefVO> pageDramas(Long id, String keyword,
int pageIndex, int pageSize, boolean bound) {
getExisting(id);
Page<Drama> page = new Page<>(Math.max(1, pageIndex), Math.max(1, Math.min(pageSize, 100)));
LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>lambdaQuery()
.like(StringUtils.hasText(keyword), Drama::getTitle, keyword)
.orderByDesc(Drama::getSort)
.orderByDesc(Drama::getId);
String subQuery = "SELECT drama_id FROM drama_playlist_category_drama WHERE category_id = " + id;
if (bound) {
wrapper.inSql(Drama::getId, subQuery);
} else {
wrapper.notInSql(Drama::getId, subQuery);
}
IPage<Drama> result = dramaMapper.selectPage(page, wrapper);
return new PageResult<>(result.getTotal(),
result.getRecords().stream().map(this::toBrief).toList());
}
private Map<Long, Integer> countDramas() {
Map<Long, Integer> counts = new HashMap<>();
categoryDramaMapper.selectMaps(new QueryWrapper<DramaPlaylistCategoryDrama>()
.select("category_id AS cid", "COUNT(*) AS cnt")
.groupBy("category_id"))
.forEach(row -> {
Object cid = row.get("cid");
Object cnt = row.get("cnt");
if (cid instanceof Number c && cnt instanceof Number n) {
counts.put(c.longValue(), n.intValue());
}
});
return counts;
}
private DramaPlaylistCategory getExisting(Long id) {
DramaPlaylistCategory entity = id == null ? null : categoryMapper.selectById(id);
if (entity == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "剧单分类不存在");
}
return entity;
}
private void ensureNameAvailable(String name, Long excludeId) {
Long count = categoryMapper.selectCount(Wrappers.<DramaPlaylistCategory>lambdaQuery()
.eq(DramaPlaylistCategory::getName, name.trim())
.ne(excludeId != null, DramaPlaylistCategory::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "剧单分类名称已存在");
}
}
private DramaBriefVO toBrief(Drama drama) {
DramaBriefVO vo = new DramaBriefVO();
vo.setId(drama.getId());
vo.setTitle(drama.getTitle());
vo.setCover(drama.getCoverUrl());
vo.setStatus(drama.getStatus());
vo.setTotalEpisodes(drama.getTotalEpisodes());
vo.setSort(drama.getSort());
vo.setUpdatedAt(drama.getUpdatedAt());
return vo;
}
}
@@ -17,6 +17,7 @@ import com.fruitshorts.content.vo.EpisodeRespVO;
import com.fruitshorts.content.vo.EpisodeSaveReqVO; import com.fruitshorts.content.vo.EpisodeSaveReqVO;
import com.fruitshorts.media.service.EpisodeTranscodeBindingService; import com.fruitshorts.media.service.EpisodeTranscodeBindingService;
import com.fruitshorts.media.service.EpisodeTranscodeCompletionPort; import com.fruitshorts.media.service.EpisodeTranscodeCompletionPort;
import com.fruitshorts.media.service.EpisodeVideoSourcePort;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -31,7 +32,7 @@ import java.util.Objects;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class EpisodeService implements EpisodeTranscodeCompletionPort { public class EpisodeService implements EpisodeTranscodeCompletionPort, EpisodeVideoSourcePort {
private static final int DEFAULT_PAGE_INDEX = 1; private static final int DEFAULT_PAGE_INDEX = 1;
private static final int DEFAULT_PAGE_SIZE = 100; private static final int DEFAULT_PAGE_SIZE = 100;
@@ -41,6 +42,36 @@ public class EpisodeService implements EpisodeTranscodeCompletionPort {
private final DramaService dramaService; private final DramaService dramaService;
private final EpisodeTranscodeBindingService transcodeBindingService; private final EpisodeTranscodeBindingService transcodeBindingService;
@Override
public java.util.Optional<EpisodeVideoSource> resolveByVideoUrl(String videoUrl) {
if (!StringUtils.hasText(videoUrl)) {
return java.util.Optional.empty();
}
DramaEpisode episode = dramaEpisodeMapper.selectOne(Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getVideoUrl, videoUrl)
.last("limit 1"));
return episode == null
? java.util.Optional.empty()
: java.util.Optional.of(toVideoSource(episode));
}
private EpisodeVideoSource toVideoSource(DramaEpisode episode) {
return new EpisodeVideoSource(
episode.getId(), episode.getDramaId(), episode.getVideoAssetId(),
episode.getVideoUrl(), episode.getDurationSec());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void link(Long episodeId, Long mediaAssetId) {
if (episodeId == null || episodeId <= 0 || mediaAssetId == null || mediaAssetId <= 0) {
return;
}
dramaEpisodeMapper.update(null, Wrappers.<DramaEpisode>lambdaUpdate()
.eq(DramaEpisode::getId, episodeId)
.set(DramaEpisode::getVideoAssetId, mediaAssetId));
}
/** 后台:按短剧分页查询分集,status 在 SQL 层过滤;排序稳定(sort、ep_no)。 */ /** 后台:按短剧分页查询分集,status 在 SQL 层过滤;排序稳定(sort、ep_no)。 */
public PageResult<EpisodeRespVO> pageByDrama(Long dramaId, String status, public PageResult<EpisodeRespVO> pageByDrama(Long dramaId, String status,
Integer pageIndex, Integer pageSize) { Integer pageIndex, Integer pageSize) {
@@ -0,0 +1,20 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import lombok.Data;
import java.time.LocalDateTime;
/** 短剧精简信息:分类关联页列表用。 */
@Data
public class DramaBriefVO {
private Long id;
private String title;
private String cover;
private String status;
private Integer totalEpisodes;
private Integer sort;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,19 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import lombok.Data;
import java.time.LocalDateTime;
/** 分类列表项:带关联短剧数量。 */
@Data
public class DramaClassificationRespVO {
private Long id;
private String name;
private Integer sort;
private Integer status;
private Integer dramaCount;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,17 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/** 分类新增/编辑请求体。 */
@Data
public class DramaClassificationSaveReqVO {
@NotBlank(message = "请填写分类名称")
private String name;
private Integer sort;
private Integer status;
}
@@ -0,0 +1,16 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.List;
/** 批量操作短剧 id 的通用请求体。 */
@Data
public class DramaIdsReqVO {
@NotEmpty(message = "请选择短剧")
private List<Long> dramaIds;
}
@@ -0,0 +1,18 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import lombok.Data;
/** 剧单分类列表项:带该分类下的短剧数量。 */
@Data
public class DramaPlaylistCategoryRespVO {
private Long id;
private String name;
private String description;
private Integer sort;
private Integer status;
/** 该分类下已关联的短剧数量 */
private Integer dramaCount;
}
@@ -0,0 +1,21 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/** 剧单分类新增/编辑请求体。 */
@Data
public class DramaPlaylistCategorySaveReqVO {
@NotBlank(message = "请填写剧单分类名称")
private String name;
@Size(max = 255, message = "描述最多 255 个字符")
private String description;
private Integer sort;
private Integer status;
}
+5
View File
@@ -25,5 +25,10 @@
<groupId>com.fruitshorts</groupId> <groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-system</artifactId> <artifactId>fruitshorts-module-system</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>
@@ -4,6 +4,17 @@ package com.fruitshorts.media.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.media.dal.entity.MediaRendition; import com.fruitshorts.media.dal.entity.MediaRendition;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
public interface MediaRenditionMapper extends BaseMapper<MediaRendition> { public interface MediaRenditionMapper extends BaseMapper<MediaRendition> {
/**
* 物理删除旧产物。唯一索引 uk_rendition(media_asset_id, quality, format) 不含 deleted,
* 逻辑删除的旧行会继续占着唯一键,重转时插入必然冲突,所以这里必须绕过 @TableLogic 真删。
*/
@Delete("<script>DELETE FROM media_rendition WHERE media_asset_id = #{mediaAssetId}"
+ "<if test='episodeId != null and episodeId > 0'> OR episode_id = #{episodeId}</if>"
+ "</script>")
int deletePhysically(@Param("mediaAssetId") Long mediaAssetId, @Param("episodeId") Long episodeId);
} }
@@ -0,0 +1,25 @@
// Author: fruit.com.cn
package com.fruitshorts.media.service;
import java.util.Optional;
/**
* 媒资域读取分集视频来源的跨域端口,由 content 域实现。
*/
public interface EpisodeVideoSourcePort {
/** 按视频播放地址查找分集,用于把未回填 video_asset_id 的媒资绑定到分集。 */
Optional<EpisodeVideoSource> resolveByVideoUrl(String videoUrl);
/** 回填分集的 video_asset_id。 */
void link(Long episodeId, Long mediaAssetId);
record EpisodeVideoSource(
Long episodeId,
Long dramaId,
Long videoAssetId,
String videoUrl,
Integer durationSec) {
}
}
@@ -42,6 +42,7 @@ public class MediaService {
private final MediaAssetMapper mediaAssetMapper; private final MediaAssetMapper mediaAssetMapper;
private final MediaUploadTaskMapper mediaUploadTaskMapper; private final MediaUploadTaskMapper mediaUploadTaskMapper;
private final TranscodeService transcodeService; private final TranscodeService transcodeService;
private final EpisodeVideoSourcePort episodeVideoSourcePort;
public UploadInitRespVO init(UploadInitReqVO req, Long userId) { public UploadInitRespVO init(UploadInitReqVO req, Long userId) {
if (req.getFileSize() > properties.getMaxFileSize()) { if (req.getFileSize() > properties.getMaxFileSize()) {
@@ -203,13 +204,21 @@ public class MediaService {
/** 后台手动触发/重转:锁住媒资行,保证同一媒体的重复请求串行判定任务状态。 */ /** 后台手动触发/重转:锁住媒资行,保证同一媒体的重复请求串行判定任务状态。 */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public TranscodeTriggerRespVO triggerTranscode(Long id) { public TranscodeTriggerRespVO triggerTranscode(Long id) {
MediaAsset asset = id == null ? null : mediaAssetMapper.selectOne( MediaAsset asset = lockAsset(id);
Wrappers.<MediaAsset>lambdaQuery()
.eq(MediaAsset::getId, id)
.last("for update"));
if (asset == null) { if (asset == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "媒资不存在"); throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "媒资不存在");
} }
return triggerAsset(asset);
}
private MediaAsset lockAsset(Long id) {
return id == null ? null : mediaAssetMapper.selectOne(
Wrappers.<MediaAsset>lambdaQuery()
.eq(MediaAsset::getId, id)
.last("for update"));
}
private TranscodeTriggerRespVO triggerAsset(MediaAsset asset) {
if (isImage(asset.getFileName())) { if (isImage(asset.getFileName())) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "图片媒资无需转码"); throw new ServiceException(ErrorCode.BAD_REQUEST, "图片媒资无需转码");
} }
@@ -217,7 +226,13 @@ public class MediaService {
if (sourceUrl == null || sourceUrl.isBlank()) { if (sourceUrl == null || sourceUrl.isBlank()) {
sourceUrl = asset.getMp4Url(); sourceUrl = asset.getMp4Url();
} }
TranscodeTriggerRespVO result = transcodeService.trigger(asset.getId(), sourceUrl); EpisodeVideoSourcePort.EpisodeVideoSource episode = episodeVideoSourcePort
.resolveByVideoUrl(sourceUrl)
.orElseThrow(() -> new ServiceException(
ErrorCode.BAD_REQUEST, "媒资未绑定分集视频"));
episodeVideoSourcePort.link(episode.episodeId(), asset.getId());
TranscodeTriggerRespVO result = transcodeService.trigger(
asset.getId(), sourceUrl, episode.dramaId(), episode.episodeId());
asset.setStatus(STATUS_TRANSCODING); asset.setStatus(STATUS_TRANSCODING);
asset.setTranscodeStatus(result.getStatus()); asset.setTranscodeStatus(result.getStatus());
mediaAssetMapper.updateById(asset); mediaAssetMapper.updateById(asset);
@@ -0,0 +1,276 @@
// Author: fruit.com.cn
package com.fruitshorts.media.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.media.dal.entity.MediaAsset;
import com.fruitshorts.media.dal.entity.MediaRendition;
import com.fruitshorts.media.dal.entity.MediaThumbnail;
import com.fruitshorts.media.dal.entity.TranscodeTask;
import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
import com.fruitshorts.media.vo.MediaRenditionRespVO;
import com.fruitshorts.media.vo.MediaThumbnailFrameVO;
import com.fruitshorts.media.vo.MediaThumbnailRespVO;
import com.fruitshorts.media.vo.MediaThumbnailSpriteVO;
import com.fruitshorts.media.vo.TranscodeTaskRespVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* 后台只读查询转码产物;不参与 worker 入队、认领与回写。
*/
@Service
@RequiredArgsConstructor
public class MediaTranscodeOutputService {
private static final int DEFAULT_PAGE_INDEX = 1;
private static final int DEFAULT_PAGE_SIZE = 100;
private static final int MAX_PAGE_SIZE = 500;
private static final Comparator<MediaRendition> RENDITION_ORDER =
Comparator.comparing(MediaRendition::getHeight,
Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(MediaRendition::getVideoBitrate,
Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(MediaRendition::getId,
Comparator.nullsLast(Comparator.naturalOrder()));
private final MediaAssetMapper mediaAssetMapper;
private final TranscodeService transcodeService;
private final MediaStorageService storageService;
private final PlaybackSigner signer;
public List<MediaRenditionRespVO> renditions(Long mediaAssetId) {
requireMedia(mediaAssetId);
return renditionsForMediaAssetId(mediaAssetId);
}
public List<MediaRenditionRespVO> renditionsForMediaAssetId(Long mediaAssetId) {
return transcodeService.listRenditionsByMediaAssetId(mediaAssetId).stream()
.sorted(RENDITION_ORDER)
.map(this::toRenditionVo)
.toList();
}
public MediaThumbnailRespVO thumbnails(Long mediaAssetId,
Integer pageIndex, Integer pageSize) {
requireMedia(mediaAssetId);
int safeIndex = pageIndex == null || pageIndex < 1 ? DEFAULT_PAGE_INDEX : pageIndex;
int safeSize = pageSize == null || pageSize < 1
? DEFAULT_PAGE_SIZE : Math.min(pageSize, MAX_PAGE_SIZE);
TranscodeTask task = transcodeService.findLatestTaskByMediaAssetId(mediaAssetId);
if (task == null || task.getEpisodeId() == null || task.getEpisodeId() <= 0) {
return MediaThumbnailRespVO.empty(mediaAssetId, null,
MediaThumbnailRespVO.STATUS_NO_EPISODE, safeIndex, safeSize);
}
return thumbnailForEpisode(
task.getEpisodeId(), mediaAssetId, safeIndex, safeSize);
}
public MediaThumbnailRespVO thumbnailForEpisode(Long episodeId, Long mediaAssetId,
Integer pageIndex, Integer pageSize) {
int safeIndex = pageIndex == null || pageIndex < 1 ? DEFAULT_PAGE_INDEX : pageIndex;
int safeSize = pageSize == null || pageSize < 1
? DEFAULT_PAGE_SIZE : Math.min(pageSize, MAX_PAGE_SIZE);
MediaThumbnail thumbnail = transcodeService.findThumbnailByEpisodeId(episodeId);
if (thumbnail == null) {
return MediaThumbnailRespVO.empty(mediaAssetId, episodeId,
MediaThumbnailRespVO.STATUS_EMPTY, safeIndex, safeSize);
}
if (!MediaThumbnail.STATUS_READY.equals(thumbnail.getStatus())) {
String status = StringUtils.hasText(thumbnail.getStatus())
? thumbnail.getStatus() : MediaThumbnailRespVO.STATUS_INVALID;
MediaThumbnailRespVO failed = MediaThumbnailRespVO.empty(
mediaAssetId, episodeId, status, safeIndex, safeSize);
failed.setErrorMsg(thumbnail.getErrorMsg());
return failed;
}
return readyThumbnail(thumbnail, mediaAssetId, safeIndex, safeSize);
}
public TranscodeTaskRespVO latestTask(Long mediaAssetId) {
return TranscodeTaskRespVO.from(
transcodeService.findLatestTaskByMediaAssetId(mediaAssetId));
}
public boolean mediaAssetExists(Long mediaAssetId) {
return mediaAssetId != null && mediaAssetMapper.selectById(mediaAssetId) != null;
}
public Long resolveMediaAssetId(String videoUrl) {
if (!StringUtils.hasText(videoUrl)) {
return null;
}
String objectKey = storageService.objectKeyOf(videoUrl);
if (!StringUtils.hasText(objectKey)) {
return null;
}
MediaAsset asset = mediaAssetMapper.selectOne(Wrappers.<MediaAsset>lambdaQuery()
.and(wrapper -> wrapper.eq(MediaAsset::getSourceUrl, videoUrl)
.or().eq(MediaAsset::getObjectKey, objectKey))
.last("limit 1"));
return asset == null ? null : asset.getId();
}
private MediaThumbnailRespVO readyThumbnail(MediaThumbnail thumbnail, Long mediaAssetId,
int pageIndex, int pageSize) {
List<ThumbnailVttParser.Cue> cues;
try {
cues = readCues(thumbnail);
} catch (Exception ex) {
MediaThumbnailRespVO invalid = MediaThumbnailRespVO.empty(
mediaAssetId, thumbnail.getEpisodeId(),
MediaThumbnailRespVO.STATUS_INVALID, pageIndex, pageSize);
invalid.setErrorMsg("缩略图 VTT 读取失败:" + messageOf(ex));
return invalid;
}
List<MediaThumbnailFrameVO> frames = cues.stream()
.map(cue -> toFrame(thumbnail, cue))
.toList();
PageResult<MediaThumbnailFrameVO> page = page(frames, pageIndex, pageSize);
MediaThumbnailRespVO result = new MediaThumbnailRespVO();
result.setMediaAssetId(mediaAssetId);
result.setEpisodeId(thumbnail.getEpisodeId());
result.setStatus(MediaThumbnail.STATUS_READY);
result.setErrorMsg(thumbnail.getErrorMsg());
result.setPageIndex(pageIndex);
result.setPageSize(pageSize);
result.setFrames(page);
result.setSprite(toSprite(thumbnail, cues));
return result;
}
private List<ThumbnailVttParser.Cue> readCues(MediaThumbnail thumbnail) throws IOException {
String objectKey = thumbnail.getVttObjectKey();
if (!StringUtils.hasText(objectKey)) {
return List.of();
}
return ThumbnailVttParser.parse(
Files.readString(storageService.fileFor(objectKey), StandardCharsets.UTF_8));
}
private PageResult<MediaThumbnailFrameVO> page(
List<MediaThumbnailFrameVO> frames, int pageIndex, int pageSize) {
long offsetLong = (long) (pageIndex - 1) * pageSize;
if (offsetLong >= frames.size()) {
return new PageResult<>(frames.size(), List.of());
}
int offset = (int) offsetLong;
int end = Math.min(offset + pageSize, frames.size());
return new PageResult<>(frames.size(), frames.subList(offset, end));
}
private MediaThumbnailFrameVO toFrame(
MediaThumbnail thumbnail, ThumbnailVttParser.Cue cue) {
String spriteUrl = spriteUrl(thumbnail.getSpritePrefix(), cue.spriteFile());
MediaThumbnailFrameVO frame = new MediaThumbnailFrameVO();
frame.setFrameIndex(frameIndex(thumbnail, cue));
frame.setTimeMs(cue.startMs());
frame.setEndTimeMs(cue.endMs());
frame.setSpriteUrl(spriteUrl);
frame.setImageUrl(spriteUrl + "#xywh=" + cue.x() + "," + cue.y()
+ "," + cue.width() + "," + cue.height());
frame.setX(cue.x());
frame.setY(cue.y());
frame.setWidth(cue.width());
frame.setHeight(cue.height());
return frame;
}
private int frameIndex(MediaThumbnail thumbnail, ThumbnailVttParser.Cue cue) {
int intervalMs = Math.max(1, thumbnail.getIntervalSec() == null
? 1000 : thumbnail.getIntervalSec() * 1000);
return (int) (cue.startMs() / intervalMs);
}
private MediaThumbnailSpriteVO toSprite(
MediaThumbnail thumbnail, List<ThumbnailVttParser.Cue> cues) {
Set<String> urls = new LinkedHashSet<>();
cues.forEach(cue -> urls.add(
spriteUrl(thumbnail.getSpritePrefix(), cue.spriteFile())));
List<String> spriteUrls = new ArrayList<>(urls);
MediaThumbnailSpriteVO sprite = new MediaThumbnailSpriteVO();
sprite.setSpriteUrls(spriteUrls);
sprite.setSpriteUrl(spriteUrls.isEmpty() ? null : spriteUrls.get(0));
sprite.setVttUrl(signedUrl(thumbnail.getVttObjectKey()));
sprite.setColumns(thumbnail.getColumns());
sprite.setRows(thumbnail.getRows());
sprite.setTotalFrames(thumbnail.getFrameCount() == null || thumbnail.getFrameCount() <= 0
? cues.size() : thumbnail.getFrameCount());
sprite.setThumbWidth(thumbnail.getThumbWidth());
sprite.setThumbHeight(thumbnail.getThumbHeight());
sprite.setIntervalSeconds(thumbnail.getIntervalSec());
return sprite;
}
private String spriteUrl(String spritePrefix, String spriteFile) {
if (!StringUtils.hasText(spritePrefix) || !StringUtils.hasText(spriteFile)) {
return "";
}
int slash = spritePrefix.lastIndexOf('/');
String directory = slash < 0 ? "" : spritePrefix.substring(0, slash + 1);
return signedUrl(directory + spriteFile);
}
private String signedUrl(String objectKey) {
if (!StringUtils.hasText(objectKey)) {
return null;
}
return signedPath(storageService.toPublicUrl(objectKey));
}
private String signedPath(String publicUrl) {
if (!StringUtils.hasText(publicUrl)) {
return null;
}
String signed = signer.sign(publicUrl);
return StringUtils.hasText(signed) ? signed : publicUrl;
}
private MediaRenditionRespVO toRenditionVo(MediaRendition rendition) {
MediaRenditionRespVO vo = new MediaRenditionRespVO();
vo.setId(rendition.getId());
vo.setQuality(rendition.getQuality());
vo.setQualityName(rendition.getQuality() == null
? null : rendition.getQuality().toUpperCase(Locale.ROOT));
vo.setWidth(rendition.getWidth());
vo.setHeight(rendition.getHeight());
vo.setResolution(rendition.getWidth() + "x" + rendition.getHeight());
vo.setVideoBitrateKbps(rendition.getVideoBitrate());
vo.setAudioBitrateKbps(rendition.getAudioBitrate());
vo.setVideoCodec("H.264");
vo.setAudioCodec("AAC");
vo.setContainer(rendition.getFormat());
vo.setHlsUrl(signedPath(rendition.getPlayUrl()));
vo.setSizeBytes(rendition.getSizeBytes());
vo.setDurationSec(rendition.getDurationSec());
vo.setSegmentCount(rendition.getSegmentCount());
vo.setStatus(rendition.getStatus());
return vo;
}
private void requireMedia(Long mediaAssetId) {
MediaAsset asset = mediaAssetId == null ? null : mediaAssetMapper.selectById(mediaAssetId);
if (asset == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "媒资不存在");
}
}
private String messageOf(Exception exception) {
String message = exception.getMessage();
return StringUtils.hasText(message) ? message : exception.getClass().getSimpleName();
}
}
@@ -27,17 +27,12 @@ public class ThumbnailRecordService {
if (episodeId == null || episodeId <= 0) { if (episodeId == null || episodeId <= 0) {
return; return;
} }
MediaThumbnail existing = mediaThumbnailMapper.selectOne(
Wrappers.<MediaThumbnail>lambdaQuery()
.eq(MediaThumbnail::getEpisodeId, episodeId)
.last("limit 1"));
thumbnail.setDramaId(task.getDramaId() == null ? 0L : task.getDramaId()); thumbnail.setDramaId(task.getDramaId() == null ? 0L : task.getDramaId());
thumbnail.setEpisodeId(episodeId); thumbnail.setEpisodeId(episodeId);
if (existing == null) { thumbnail.setErrorMsg(TranscodeErrorTruncator.truncate(thumbnail.getErrorMsg()));
mediaThumbnailMapper.insert(thumbnail); thumbnail.setId(null);
return; mediaThumbnailMapper.delete(Wrappers.<MediaThumbnail>lambdaQuery()
} .eq(MediaThumbnail::getEpisodeId, episodeId));
thumbnail.setId(existing.getId()); mediaThumbnailMapper.insert(thumbnail);
mediaThumbnailMapper.updateById(thumbnail);
} }
} }
@@ -0,0 +1,72 @@
// Author: fruit.com.cn
package com.fruitshorts.media.service;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** 解析 worker 生成的 WebVTT 雪碧图索引。 */
public final class ThumbnailVttParser {
private static final Pattern TIMING = Pattern.compile(
"^(\\d{2}:\\d{2}:\\d{2}\\.\\d{3})\\s+-->\\s+"
+ "(\\d{2}:\\d{2}:\\d{2}\\.\\d{3})$");
private static final Pattern IMAGE = Pattern.compile(
"^([^#\\s]+)#xywh=(\\d+),(\\d+),(\\d+),(\\d+)$");
private static final int HOURS_PER_DAY = 24;
private ThumbnailVttParser() {
}
public static List<Cue> parse(String content) {
if (content == null || content.isBlank()) {
return List.of();
}
String[] lines = content.replace("\r\n", "\n").split("\n");
List<Cue> cues = new ArrayList<>();
for (int index = 0; index < lines.length; index++) {
Matcher timing = TIMING.matcher(lines[index].trim());
if (!timing.matches()) {
continue;
}
String imageLine = nextNonBlank(lines, index + 1);
Matcher image = IMAGE.matcher(imageLine);
if (!image.matches()) {
continue;
}
cues.add(new Cue(
timestampMs(timing.group(1)), timestampMs(timing.group(2)),
image.group(1), integer(image.group(2)), integer(image.group(3)),
integer(image.group(4)), integer(image.group(5))));
}
return cues;
}
private static String nextNonBlank(String[] lines, int start) {
for (int index = start; index < lines.length; index++) {
String line = lines[index].trim();
if (!line.isEmpty()) {
return line;
}
}
return "";
}
private static long timestampMs(String value) {
int hours = Integer.parseInt(value.substring(0, 2)) % HOURS_PER_DAY;
int minutes = Integer.parseInt(value.substring(3, 5));
int seconds = Integer.parseInt(value.substring(6, 8));
int millis = Integer.parseInt(value.substring(9, 12));
return ((hours * 60L + minutes) * 60L + seconds) * 1000L + millis;
}
private static int integer(String value) {
return Integer.parseInt(value);
}
public record Cue(long startMs, long endMs, String spriteFile,
int x, int y, int width, int height) {
}
}
@@ -0,0 +1,26 @@
// Author: fruit.com.cn
package com.fruitshorts.media.service;
/** 转码错误消息统一截断,避免数据库写入失败。 */
public final class TranscodeErrorTruncator {
public static final int MAX_ERROR_CHARS = 500;
private static final String EMPTY = "";
private static final String TRUNCATED_TEMPLATE = "…(truncated, total=%d chars)…";
private TranscodeErrorTruncator() {
}
public static String truncate(String message) {
if (message == null || message.isEmpty() || message.length() <= MAX_ERROR_CHARS) {
return message == null ? EMPTY : message;
}
String suffix = String.format(TRUNCATED_TEMPLATE, message.length());
int remain = Math.max(0, MAX_ERROR_CHARS - suffix.length());
int prefixLength = remain / 2;
int tailLength = remain - prefixLength;
return message.substring(0, prefixLength)
+ suffix + message.substring(message.length() - tailLength);
}
}
@@ -12,6 +12,7 @@ import com.fruitshorts.media.dal.mapper.MediaRenditionMapper;
import com.fruitshorts.media.dal.mapper.MediaThumbnailMapper; import com.fruitshorts.media.dal.mapper.MediaThumbnailMapper;
import com.fruitshorts.media.dal.mapper.TranscodeTaskMapper; import com.fruitshorts.media.dal.mapper.TranscodeTaskMapper;
import com.fruitshorts.media.vo.TranscodeTriggerRespVO; import com.fruitshorts.media.vo.TranscodeTriggerRespVO;
import com.fruitshorts.media.vo.TranscodeQueueStatsVO;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -22,6 +23,8 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Collection; import java.util.Collection;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/** /**
* 转码任务编排。 * 转码任务编排。
@@ -41,11 +44,17 @@ public class TranscodeService {
public static final String STATUS_FAILED = "FAILED"; public static final String STATUS_FAILED = "FAILED";
/** 失败重试上限(`backend-design-phase1.md` §7.2:失败重试 3 次并记录原因)。 */ /** 失败重试上限(`backend-design-phase1.md` §7.2:失败重试 3 次并记录原因)。 */
public static final int MAX_RETRY = 3; public static final int MAX_RETRY = 3;
public static final int STALE_RUNNING_MINUTES = 5;
private static final int MAX_RUNNING_PROGRESS = 99;
private static final int CLAIM_SKIP_LOG_LIMIT = 20;
private static final String STALE_RUNNING_REASON =
"任务 RUNNING 超过 5 分钟无进度更新,已自动标记失败,请重新转码";
private final TranscodeTaskMapper transcodeTaskMapper; private final TranscodeTaskMapper transcodeTaskMapper;
private final MediaRenditionMapper mediaRenditionMapper; private final MediaRenditionMapper mediaRenditionMapper;
private final MediaThumbnailMapper mediaThumbnailMapper; private final MediaThumbnailMapper mediaThumbnailMapper;
private final ThumbnailRecordService thumbnailRecordService; private final ThumbnailRecordService thumbnailRecordService;
private final Set<Long> loggedSkipTaskIds = ConcurrentHashMap.newKeySet();
/** 上传完成后入队;同一媒资重复入队时复用未完成的任务,避免重复转码。 */ /** 上传完成后入队;同一媒资重复入队时复用未完成的任务,避免重复转码。 */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@@ -77,20 +86,27 @@ public class TranscodeService {
*/ */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public TranscodeTriggerRespVO trigger(Long mediaAssetId, String sourceUrl) { public TranscodeTriggerRespVO trigger(Long mediaAssetId, String sourceUrl) {
TranscodeTask latest = latestTask(mediaAssetId); return trigger(mediaAssetId, sourceUrl, 0L, 0L);
}
/** 手动重转时同时补齐分集绑定,禁止创建 episode_id=0 的不可认领任务。 */
@Transactional(rollbackFor = Exception.class)
public TranscodeTriggerRespVO trigger(
Long mediaAssetId, String sourceUrl, Long dramaId, Long episodeId) {
TranscodeTask latest = findLatestTaskByMediaAssetId(mediaAssetId);
if (latest != null && isActive(latest.getStatus())) { if (latest != null && isActive(latest.getStatus())) {
updateTaskBinding(latest, dramaId, episodeId, sourceUrl);
return response(latest.getId(), latest.getStatus()); return response(latest.getId(), latest.getStatus());
} }
if (latest != null && isRetryable(latest.getStatus())) { if (latest != null && isRetryable(latest.getStatus())) {
resetFailedTask(latest, sourceUrl); resetTaskForRetry(latest, dramaId, episodeId, sourceUrl);
return response(latest.getId(), STATUS_QUEUED); return response(latest.getId(), STATUS_QUEUED);
} }
Long dramaId = latest == null ? 0L : latest.getDramaId();
Long episodeId = latest == null ? 0L : latest.getEpisodeId();
String retrySource = latest == null || latest.getSourceUrl() == null String retrySource = latest == null || latest.getSourceUrl() == null
|| latest.getSourceUrl().isBlank() ? sourceUrl : latest.getSourceUrl(); || latest.getSourceUrl().isBlank() ? sourceUrl : latest.getSourceUrl();
Long taskId = enqueue(mediaAssetId, dramaId, episodeId, retrySource); Long taskId = enqueue(mediaAssetId, nonPositiveToZero(dramaId),
nonPositiveToZero(episodeId), retrySource);
return response(taskId, STATUS_QUEUED); return response(taskId, STATUS_QUEUED);
} }
@@ -134,9 +150,13 @@ public class TranscodeService {
/** worker 认领任务。并发下依赖状态条件更新,抢不到返回 null。 */ /** worker 认领任务。并发下依赖状态条件更新,抢不到返回 null。 */
public TranscodeTask claimNext() { public TranscodeTask claimNext() {
logSkippedQueuedTasks();
TranscodeTask candidate = transcodeTaskMapper.selectOne(Wrappers.<TranscodeTask>lambdaQuery() TranscodeTask candidate = transcodeTaskMapper.selectOne(Wrappers.<TranscodeTask>lambdaQuery()
.eq(TranscodeTask::getStatus, STATUS_QUEUED) .eq(TranscodeTask::getStatus, STATUS_QUEUED)
.gt(TranscodeTask::getMediaAssetId, 0)
.gt(TranscodeTask::getEpisodeId, 0) .gt(TranscodeTask::getEpisodeId, 0)
.isNotNull(TranscodeTask::getSourceUrl)
.ne(TranscodeTask::getSourceUrl, "")
.orderByAsc(TranscodeTask::getId) .orderByAsc(TranscodeTask::getId)
.last("limit 1")); .last("limit 1"));
if (candidate == null) { if (candidate == null) {
@@ -145,12 +165,48 @@ public class TranscodeService {
int updated = transcodeTaskMapper.update(null, Wrappers.<TranscodeTask>lambdaUpdate() int updated = transcodeTaskMapper.update(null, Wrappers.<TranscodeTask>lambdaUpdate()
.eq(TranscodeTask::getId, candidate.getId()) .eq(TranscodeTask::getId, candidate.getId())
.eq(TranscodeTask::getStatus, STATUS_QUEUED) .eq(TranscodeTask::getStatus, STATUS_QUEUED)
.gt(TranscodeTask::getMediaAssetId, 0)
.gt(TranscodeTask::getEpisodeId, 0) .gt(TranscodeTask::getEpisodeId, 0)
.isNotNull(TranscodeTask::getSourceUrl)
.ne(TranscodeTask::getSourceUrl, "")
.set(TranscodeTask::getStatus, STATUS_RUNNING) .set(TranscodeTask::getStatus, STATUS_RUNNING)
.set(TranscodeTask::getStartedAt, LocalDateTime.now())); .set(TranscodeTask::getStartedAt, LocalDateTime.now()));
return updated == 1 ? candidate : null; return updated == 1 ? candidate : null;
} }
private void logSkippedQueuedTasks() {
List<TranscodeTask> skipped = transcodeTaskMapper.selectList(
Wrappers.<TranscodeTask>lambdaQuery()
.eq(TranscodeTask::getStatus, STATUS_QUEUED)
.and(wrapper -> wrapper
.isNull(TranscodeTask::getMediaAssetId)
.or().le(TranscodeTask::getMediaAssetId, 0)
.or().isNull(TranscodeTask::getEpisodeId)
.or().le(TranscodeTask::getEpisodeId, 0)
.or().isNull(TranscodeTask::getSourceUrl)
.or().eq(TranscodeTask::getSourceUrl, ""))
.orderByAsc(TranscodeTask::getId)
.last("limit " + CLAIM_SKIP_LOG_LIMIT));
for (TranscodeTask task : skipped) {
if (task.getId() != null && loggedSkipTaskIds.add(task.getId())) {
log.warn("跳过转码任务 {}:{}", task.getId(), claimSkipReason(task));
}
}
}
private String claimSkipReason(TranscodeTask task) {
if (task.getMediaAssetId() == null || task.getMediaAssetId() <= 0) {
return "media_asset_id 无效";
}
if (task.getEpisodeId() == null || task.getEpisodeId() <= 0) {
return "episode_id 未绑定";
}
if (task.getSourceUrl() == null || task.getSourceUrl().isBlank()) {
return "source_url 为空";
}
return "未知原因";
}
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void succeed(Long taskId, List<MediaRendition> renditions) { public void succeed(Long taskId, List<MediaRendition> renditions) {
succeed(taskId, renditions, null); succeed(taskId, renditions, null);
@@ -161,6 +217,8 @@ public class TranscodeService {
public void succeed(Long taskId, List<MediaRendition> renditions, public void succeed(Long taskId, List<MediaRendition> renditions,
MediaThumbnail thumbnail) { MediaThumbnail thumbnail) {
TranscodeTask task = getExisting(taskId); TranscodeTask task = getExisting(taskId);
// 唯一索引不含 deleted,旧产物必须物理删除,否则重转时插入会撞 uk_rendition
mediaRenditionMapper.deletePhysically(task.getMediaAssetId(), task.getEpisodeId());
for (MediaRendition rendition : renditions) { for (MediaRendition rendition : renditions) {
rendition.setMediaAssetId(task.getMediaAssetId()); rendition.setMediaAssetId(task.getMediaAssetId());
rendition.setDramaId(task.getDramaId()); rendition.setDramaId(task.getDramaId());
@@ -172,10 +230,10 @@ public class TranscodeService {
if (thumbnail != null) { if (thumbnail != null) {
try { try {
thumbnailRecordService.upsert(task, thumbnail); thumbnailRecordService.upsert(task, thumbnail);
thumbnailError = thumbnail.getErrorMsg() == null thumbnailError = TranscodeErrorTruncator.truncate(thumbnail.getErrorMsg());
? "" : thumbnail.getErrorMsg();
} catch (Exception ex) { } catch (Exception ex) {
thumbnailError = "缩略图登记失败:" + exceptionMessage(ex); thumbnailError = TranscodeErrorTruncator.truncate(
"缩略图登记失败:" + exceptionMessage(ex));
log.warn("缩略图登记失败 task={}:{}", taskId, ex.getMessage()); log.warn("缩略图登记失败 task={}:{}", taskId, ex.getMessage());
} }
} }
@@ -201,18 +259,92 @@ public class TranscodeService {
task.setStatus(STATUS_QUEUED); task.setStatus(STATUS_QUEUED);
} }
task.setRetryCount(retry + 1); task.setRetryCount(retry + 1);
task.setErrorMsg(errorMessage == null ? "" : errorMessage); task.setErrorMsg(TranscodeErrorTruncator.truncate(errorMessage));
task.setFinishedAt(LocalDateTime.now()); task.setFinishedAt(LocalDateTime.now());
transcodeTaskMapper.updateById(task); transcodeTaskMapper.updateById(task);
return task.getStatus(); return task.getStatus();
} }
/** worker 实时上报 RUNNING 任务进度,最多到 99,100 由成功事务统一写入。 */
public void updateProgress(Long taskId, int progress) {
if (taskId == null) {
return;
}
int safeProgress = Math.min(MAX_RUNNING_PROGRESS, Math.max(0, progress));
transcodeTaskMapper.update(null, Wrappers.<TranscodeTask>lambdaUpdate()
.eq(TranscodeTask::getId, taskId)
.eq(TranscodeTask::getStatus, STATUS_RUNNING)
.set(TranscodeTask::getProgress, safeProgress));
}
/** 主流程异常时立即终止任务,保留最后进度并写入截断后的错误。 */
public int failFinal(Long taskId, String errorMessage) {
if (taskId == null) {
return 0;
}
int updated = transcodeTaskMapper.update(null, Wrappers.<TranscodeTask>lambdaUpdate()
.eq(TranscodeTask::getId, taskId)
.set(TranscodeTask::getStatus, STATUS_FAILED)
.set(TranscodeTask::getErrorMsg,
TranscodeErrorTruncator.truncate(errorMessage))
.set(TranscodeTask::getFinishedAt, LocalDateTime.now()));
if (updated == 0) {
log.error("转码任务失败状态落库失败:task={} 不存在或未更新", taskId);
}
return updated;
}
/** RUNNING 超过阈值且 updated_at 长期未变时自动收口,避免僵尸任务永久占位。 */
public int recoverStaleRunningTasks() {
LocalDateTime now = LocalDateTime.now();
return transcodeTaskMapper.update(null, Wrappers.<TranscodeTask>lambdaUpdate()
.eq(TranscodeTask::getStatus, STATUS_RUNNING)
.lt(TranscodeTask::getUpdatedAt, now.minusMinutes(STALE_RUNNING_MINUTES))
.set(TranscodeTask::getStatus, STATUS_FAILED)
.set(TranscodeTask::getErrorMsg, STALE_RUNNING_REASON)
.set(TranscodeTask::getFinishedAt, now));
}
public List<MediaRendition> listRenditions(Long episodeId) { public List<MediaRendition> listRenditions(Long episodeId) {
return mediaRenditionMapper.selectList(Wrappers.<MediaRendition>lambdaQuery() return mediaRenditionMapper.selectList(Wrappers.<MediaRendition>lambdaQuery()
.eq(MediaRendition::getEpisodeId, episodeId) .eq(MediaRendition::getEpisodeId, episodeId)
.orderByAsc(MediaRendition::getVideoBitrate)); .orderByAsc(MediaRendition::getVideoBitrate));
} }
/** 后台按媒资查询全部清晰度产物。 */
public List<MediaRendition> listRenditionsByMediaAssetId(Long mediaAssetId) {
if (mediaAssetId == null) {
return List.of();
}
return mediaRenditionMapper.selectList(Wrappers.<MediaRendition>lambdaQuery()
.eq(MediaRendition::getMediaAssetId, mediaAssetId)
.orderByDesc(MediaRendition::getHeight)
.orderByDesc(MediaRendition::getVideoBitrate)
.orderByAsc(MediaRendition::getId));
}
/** 后台查询媒资最新一次转码任务。 */
public TranscodeTask findLatestTaskByMediaAssetId(Long mediaAssetId) {
if (mediaAssetId == null) {
return null;
}
return transcodeTaskMapper.selectOne(Wrappers.<TranscodeTask>lambdaQuery()
.eq(TranscodeTask::getMediaAssetId, mediaAssetId)
.orderByDesc(TranscodeTask::getId)
.last("limit 1"));
}
/** 后台查询分集缩略图记录,不限 READY,便于展示失败原因。 */
public MediaThumbnail findThumbnailByEpisodeId(Long episodeId) {
if (episodeId == null) {
return null;
}
return mediaThumbnailMapper.selectOne(Wrappers.<MediaThumbnail>lambdaQuery()
.eq(MediaThumbnail::getEpisodeId, episodeId)
.orderByDesc(MediaThumbnail::getId)
.last("limit 1"));
}
/** 播放侧查询单集 READY 缩略图轴。 */ /** 播放侧查询单集 READY 缩略图轴。 */
public MediaThumbnail findReadyThumbnail(Long episodeId) { public MediaThumbnail findReadyThumbnail(Long episodeId) {
if (episodeId == null) { if (episodeId == null) {
@@ -245,15 +377,16 @@ public class TranscodeService {
* 队列类指标看的是"当前积压",用累计值才有意义。 * 队列类指标看的是"当前积压",用累计值才有意义。
* 产物数取自 media_rendition,反映已经生成多少路码率文件。 * 产物数取自 media_rendition,反映已经生成多少路码率文件。
*/ */
public Map<String, Object> stats() { public TranscodeQueueStatsVO stats() {
Map<String, Object> data = new LinkedHashMap<>(); long total = orZero(transcodeTaskMapper.selectCount(null));
data.put("queued", countByStatus(STATUS_QUEUED)); long queued = countByStatus(STATUS_QUEUED);
data.put("running", countByStatus(STATUS_RUNNING)); long running = countByStatus(STATUS_RUNNING);
data.put("success", countByStatus(STATUS_SUCCESS)); long success = countByStatus(STATUS_SUCCESS);
data.put("failed", countByStatus(STATUS_FAILED)); long failed = countByStatus(STATUS_FAILED);
data.put("canceled", countByStatus("CANCELED")); long canceled = countByStatus("CANCELED");
data.put("renditions", mediaRenditionMapper.selectCount(null)); long renditions = orZero(mediaRenditionMapper.selectCount(null));
return data; return new TranscodeQueueStatsVO(
total, queued, running, success, failed, canceled, renditions);
} }
/** 失败任务明细:带 error_msg 与 retry_count,便于运营判断是重试还是排查片源。 */ /** 失败任务明细:带 error_msg 与 retry_count,便于运营判断是重试还是排查片源。 */
@@ -264,16 +397,13 @@ public class TranscodeService {
.last("limit " + (limit <= 0 || limit > 100 ? 20 : limit))); .last("limit " + (limit <= 0 || limit > 100 ? 20 : limit)));
} }
private Long countByStatus(String status) { private long countByStatus(String status) {
return transcodeTaskMapper.selectCount(Wrappers.<TranscodeTask>lambdaQuery() return orZero(transcodeTaskMapper.selectCount(Wrappers.<TranscodeTask>lambdaQuery()
.eq(TranscodeTask::getStatus, status)); .eq(TranscodeTask::getStatus, status)));
} }
private TranscodeTask latestTask(Long mediaAssetId) { private long orZero(Long value) {
return transcodeTaskMapper.selectOne(Wrappers.<TranscodeTask>lambdaQuery() return value == null ? 0L : value;
.eq(TranscodeTask::getMediaAssetId, mediaAssetId)
.orderByDesc(TranscodeTask::getId)
.last("limit 1"));
} }
private boolean isActive(String status) { private boolean isActive(String status) {
@@ -281,23 +411,34 @@ public class TranscodeService {
} }
private boolean isRetryable(String status) { private boolean isRetryable(String status) {
return STATUS_FAILED.equals(status) || "CANCELED".equals(status); return STATUS_SUCCESS.equals(status)
|| STATUS_FAILED.equals(status) || "CANCELED".equals(status);
} }
private void resetFailedTask(TranscodeTask task, String sourceUrl) { private void resetTaskForRetry(
TranscodeTask task, Long dramaId, Long episodeId, String sourceUrl) {
boolean hasSource = sourceUrl != null && !sourceUrl.isBlank(); boolean hasSource = sourceUrl != null && !sourceUrl.isBlank();
boolean hasDrama = dramaId != null && dramaId > 0;
boolean hasEpisode = episodeId != null && episodeId > 0;
transcodeTaskMapper.update(null, Wrappers.<TranscodeTask>lambdaUpdate() transcodeTaskMapper.update(null, Wrappers.<TranscodeTask>lambdaUpdate()
.eq(TranscodeTask::getId, task.getId()) .eq(TranscodeTask::getId, task.getId())
.in(TranscodeTask::getStatus, List.of(STATUS_FAILED, "CANCELED")) .in(TranscodeTask::getStatus,
List.of(STATUS_SUCCESS, STATUS_FAILED, "CANCELED"))
.set(TranscodeTask::getStatus, STATUS_QUEUED) .set(TranscodeTask::getStatus, STATUS_QUEUED)
.set(TranscodeTask::getProgress, 0) .set(TranscodeTask::getProgress, 0)
.set(TranscodeTask::getRetryCount, 0) .set(TranscodeTask::getRetryCount, 0)
.set(TranscodeTask::getErrorMsg, "") .set(TranscodeTask::getErrorMsg, "")
.set(TranscodeTask::getStartedAt, null) .set(TranscodeTask::getStartedAt, null)
.set(TranscodeTask::getFinishedAt, null) .set(TranscodeTask::getFinishedAt, null)
.set(hasDrama, TranscodeTask::getDramaId, dramaId)
.set(hasEpisode, TranscodeTask::getEpisodeId, episodeId)
.set(hasSource, TranscodeTask::getSourceUrl, sourceUrl)); .set(hasSource, TranscodeTask::getSourceUrl, sourceUrl));
} }
private long nonPositiveToZero(Long value) {
return value == null || value <= 0 ? 0L : value;
}
private TranscodeTriggerRespVO response(Long taskId, String status) { private TranscodeTriggerRespVO response(Long taskId, String status) {
return new TranscodeTriggerRespVO(taskId, status); return new TranscodeTriggerRespVO(taskId, status);
} }
@@ -0,0 +1,105 @@
// Author: fruit.com.cn
package com.fruitshorts.media.service.transcode;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.IntConsumer;
/** 解析 ffmpeg -progress 临时文件并节流上报。 */
final class FfmpegProgressMonitor {
private static final long MICROS_PER_SECOND = 1_000_000L;
private FfmpegProgressMonitor() {
}
static Path createTempFile() {
try {
return Files.createTempFile("fruitshorts-ffmpeg-progress-", ".log");
} catch (IOException ex) {
throw new ServiceException(ErrorCode.INTERNAL_ERROR,
"创建 ffmpeg 进度文件失败:" + ex.getMessage());
}
}
static void report(
Path progressFile, int durationSec,
AtomicInteger lastReported, IntConsumer listener) {
if (!Files.isRegularFile(progressFile)) {
return;
}
String content;
try {
content = Files.readString(progressFile, StandardCharsets.UTF_8);
} catch (IOException ex) {
return;
}
long microseconds = extractOutTimeMicros(content);
if (microseconds < 0) {
return;
}
int percent = durationSec <= 0 ? 0
: (int) Math.min(100, microseconds * 100 / (durationSec * MICROS_PER_SECOND));
if (percent > lastReported.get()) {
lastReported.set(percent);
listener.accept(percent);
}
}
static void deleteQuietly(Path path) {
if (path == null) {
return;
}
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
// 临时进度文件删不掉不影响业务结果
}
}
private static long extractOutTimeMicros(String content) {
long result = -1L;
for (String line : content.split("\\R")) {
String[] pair = line.trim().split("=", 2);
if (pair.length != 2) {
continue;
}
if ("out_time_us".equals(pair[0]) || "out_time_ms".equals(pair[0])) {
result = Math.max(result, parseLong(pair[1]));
} else if ("out_time".equals(pair[0])) {
result = Math.max(result, parseTimestampMicros(pair[1]));
}
}
return result;
}
private static long parseTimestampMicros(String timestamp) {
String[] parts = timestamp.trim().split(":");
if (parts.length != 3) {
return -1L;
}
try {
double seconds = Integer.parseInt(parts[0]) * 3600.0
+ Integer.parseInt(parts[1]) * 60.0
+ Double.parseDouble(parts[2]);
return Math.round(seconds * MICROS_PER_SECOND);
} catch (NumberFormatException ex) {
return -1L;
}
}
private static long parseLong(String value) {
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException ex) {
return -1L;
}
}
}
@@ -15,6 +15,8 @@ import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.IntConsumer;
import java.util.stream.Stream; import java.util.stream.Stream;
/** /**
@@ -35,6 +37,11 @@ public class FfmpegTranscodeRunner implements TranscodeRunner {
private static final String VARIANT_PLAYLIST = "index.m3u8"; private static final String VARIANT_PLAYLIST = "index.m3u8";
private static final String POSTER_FILE = "poster.jpg"; private static final String POSTER_FILE = "poster.jpg";
private static final int STDERR_TAIL = 800; private static final int STDERR_TAIL = 800;
private static final int TRANSCODE_PROGRESS_END = 90;
private static final int POSTER_PROGRESS_START = 92;
private static final int POSTER_PROGRESS_END = 95;
private static final int THUMBNAIL_PROGRESS_END = 99;
private static final long PROCESS_POLL_MILLIS = 500;
private final FfmpegThumbnailGenerator thumbnailGenerator = new FfmpegThumbnailGenerator(); private final FfmpegThumbnailGenerator thumbnailGenerator = new FfmpegThumbnailGenerator();
@Override @Override
@@ -45,14 +52,29 @@ public class FfmpegTranscodeRunner implements TranscodeRunner {
} }
createDirectories(request.outputDir()); createDirectories(request.outputDir());
int durationSec = probeDuration(request); int durationSec = probeDuration(request);
SourceDimensions source = probeSourceDimensions(request);
reportProgress(request.progressListener(), 1);
List<ProducedRendition> renditions = new ArrayList<>(request.ladder().size()); List<ProducedRendition> renditions = new ArrayList<>(request.ladder().size());
for (LadderRung rung : request.ladder()) { int totalRungs = Math.max(1, request.ladder().size());
renditions.add(transcodeRung(request, rung)); for (int index = 0; index < request.ladder().size(); index++) {
int start = index * TRANSCODE_PROGRESS_END / totalRungs;
int end = (index + 1) * TRANSCODE_PROGRESS_END / totalRungs;
LadderRung rung = request.ladder().get(index);
ProducedRendition produced = transcodeRung(
request, rung, source, durationSec, start, end);
renditions.add(produced);
log.info("档位完成 task={} quality={} 实际={}x{} 进度={}%",
request.taskId(), rung.quality(),
produced.width(), produced.height(), end);
} }
reportProgress(request.progressListener(), POSTER_PROGRESS_START);
writeMasterPlaylist(request, renditions); writeMasterPlaylist(request, renditions);
ProducedRendition poster = extractPoster(request); ProducedRendition poster = extractPoster(request);
reportProgress(request.progressListener(), POSTER_PROGRESS_END);
reportProgress(request.progressListener(), 96);
ProducedThumbnail thumbnail = thumbnailGenerator.generate(request, durationSec); ProducedThumbnail thumbnail = thumbnailGenerator.generate(request, durationSec);
reportProgress(request.progressListener(), THUMBNAIL_PROGRESS_END);
log.info("转码完成 task={}:{} 档 HLS + 封面 + 缩略图状态={},时长 {}s", log.info("转码完成 task={}:{} 档 HLS + 封面 + 缩略图状态={},时长 {}s",
request.taskId(), renditions.size(), request.taskId(), renditions.size(),
thumbnail == null ? "DISABLED" : thumbnail.status(), durationSec); thumbnail == null ? "DISABLED" : thumbnail.status(), durationSec);
@@ -60,17 +82,23 @@ public class FfmpegTranscodeRunner implements TranscodeRunner {
poster.objectKey(), durationSec, thumbnail); poster.objectKey(), durationSec, thumbnail);
} }
private ProducedRendition transcodeRung(TranscodeRequest request, LadderRung rung) { private ProducedRendition transcodeRung(
TranscodeRequest request, LadderRung rung, SourceDimensions source,
int durationSec, int progressStart, int progressEnd) {
String relativeDir = rung.quality() + "/"; String relativeDir = rung.quality() + "/";
Path rungDir = request.outputDir().resolve(rung.quality()); Path rungDir = request.outputDir().resolve(rung.quality());
createDirectories(rungDir); createDirectories(rungDir);
int shortEdge = effectiveShortEdge(rung, source);
String scaleFilter = source.landscape()
? "scale=-2:" + shortEdge + ":flags=lanczos,setsar=1"
: "scale=" + shortEdge + ":-2:flags=lanczos,setsar=1";
Path progressFile = FfmpegProgressMonitor.createTempFile();
List<String> command = List.of( List<String> command = List.of(
request.ffmpegPath(), "-y", request.ffmpegPath(), "-y",
"-progress", progressFile.toString(), "-nostats",
"-i", request.sourceFile().toString(), "-i", request.sourceFile().toString(),
// 等比缩放到目标档位(不拉伸)。force_divisible_by=2 不能省: // 按源方向只用短边定档;-2 保证自动推导的一边为偶数,setsar=1 避免播放器再次拉伸
// 640x360 的源在 854x480 档会算出 853x480 这种奇数宽度,libx264 直接拒绝(width not divisible by 2) "-vf", scaleFilter,
"-vf", "scale=" + rung.width() + ":" + rung.height()
+ ":force_original_aspect_ratio=decrease:force_divisible_by=2",
"-c:v", "libx264", "-preset", "veryfast", "-c:v", "libx264", "-preset", "veryfast",
"-b:v", rung.videoBitrateKbps() + "k", "-b:v", rung.videoBitrateKbps() + "k",
// 强制在每个分片边界插关键帧:只写 -hls_time 的话,片源关键帧间隔大于分片时长时 // 强制在每个分片边界插关键帧:只写 -hls_time 的话,片源关键帧间隔大于分片时长时
@@ -81,19 +109,45 @@ public class FfmpegTranscodeRunner implements TranscodeRunner {
"-hls_playlist_type", "vod", "-hls_playlist_type", "vod",
"-hls_segment_filename", rungDir.resolve("seg_%03d.ts").toString(), "-hls_segment_filename", rungDir.resolve("seg_%03d.ts").toString(),
rungDir.resolve(VARIANT_PLAYLIST).toString()); rungDir.resolve(VARIANT_PLAYLIST).toString());
runProcess(command, request.timeoutSeconds(), "转码 " + rung.quality() + " 失败"); try {
runProcessWithProgress(command, request.timeoutSeconds(),
"转码 " + rung.quality() + " 失败", progressFile, durationSec,
rawProgress -> reportProgress(request.progressListener(),
mapProgress(rawProgress, progressStart, progressEnd)));
reportProgress(request.progressListener(), progressEnd);
} finally {
FfmpegProgressMonitor.deleteQuietly(progressFile);
}
Path playlist = rungDir.resolve(VARIANT_PLAYLIST); Path playlist = rungDir.resolve(VARIANT_PLAYLIST);
if (!Files.exists(playlist)) { if (!Files.exists(playlist)) {
throw new ServiceException(ErrorCode.INTERNAL_ERROR, throw new ServiceException(ErrorCode.INTERNAL_ERROR,
"ffmpeg 未产出播放列表:" + playlist); "ffmpeg 未产出播放列表:" + playlist);
} }
return new ProducedRendition(rung.quality(), rung.width(), rung.height(), Dimensions output = probeOutputDimensions(request, playlist);
return new ProducedRendition(rung.quality(), output.width(), output.height(),
rung.videoBitrateKbps(), rung.audioBitrateKbps(), rung.videoBitrateKbps(), rung.audioBitrateKbps(),
joinKey(request.objectKeyPrefix(), relativeDir + VARIANT_PLAYLIST), joinKey(request.objectKeyPrefix(), relativeDir + VARIANT_PLAYLIST),
sizeOf(rungDir), countSegments(rungDir)); sizeOf(rungDir), countSegments(rungDir));
} }
private int mapProgress(int rawProgress, int start, int end) {
int safe = Math.min(100, Math.max(0, rawProgress));
return start + (end - start) * safe / 100;
}
private void reportProgress(IntConsumer listener, int progress) {
if (listener != null) {
listener.accept(Math.min(99, Math.max(0, progress)));
}
}
private int effectiveShortEdge(LadderRung rung, SourceDimensions source) {
int rungShortEdge = Math.min(rung.width(), rung.height());
int capped = Math.min(rungShortEdge, source.shortEdge());
return Math.max(2, capped - capped % 2);
}
/** 手写 master.m3u8:按带宽升序,把每档的 index.m3u8 串起来。 */ /** 手写 master.m3u8:按带宽升序,把每档的 index.m3u8 串起来。 */
private void writeMasterPlaylist(TranscodeRequest request, List<ProducedRendition> renditions) { private void writeMasterPlaylist(TranscodeRequest request, List<ProducedRendition> renditions) {
StringBuilder body = new StringBuilder("#EXTM3U\n#EXT-X-VERSION:3\n"); StringBuilder body = new StringBuilder("#EXTM3U\n#EXT-X-VERSION:3\n");
@@ -151,6 +205,132 @@ public class FfmpegTranscodeRunner implements TranscodeRunner {
} }
} }
/**
* ffprobe 同时读取编码尺寸和旋转信息;旋转 90/270 度时按最终显示方向交换宽高。
*/
private SourceDimensions probeSourceDimensions(TranscodeRequest request) {
String output = runProcess(List.of(
request.ffprobePath(), "-v", "error",
"-select_streams", "v:0",
"-show_entries",
"stream=width,height:stream_side_data=rotation:stream_tags=rotate",
"-of", "default=noprint_wrappers=1",
request.sourceFile().toString()), 60, "探测视频尺寸失败");
int width = 0;
int height = 0;
Double sideRotation = null;
Double tagRotation = null;
for (String line : output.split("\\R")) {
String[] pair = line.trim().split("=", 2);
if (pair.length != 2) {
continue;
}
String key = pair[0];
String value = pair[1];
if ("width".equals(key)) {
width = parseInt(value);
} else if ("height".equals(key)) {
height = parseInt(value);
} else if ("rotation".equals(key)) {
sideRotation = parseDouble(value);
} else if ("TAG:rotate".equals(key)) {
tagRotation = parseDouble(value);
}
}
if (width <= 0 || height <= 0) {
throw new ServiceException(ErrorCode.INTERNAL_ERROR,
"ffprobe 未返回有效视频尺寸:" + width + "x" + height);
}
double rotation = sideRotation != null
? sideRotation : tagRotation == null ? 0 : tagRotation;
boolean quarterTurn = Math.abs(Math.round(rotation)) % 180 == 90;
return new SourceDimensions(
quarterTurn ? height : width,
quarterTurn ? width : height);
}
/** rendition 必须记录真实产物尺寸,不能沿用档位名义宽高。 */
private Dimensions probeOutputDimensions(TranscodeRequest request, Path playlist) {
String output = runProcess(List.of(
request.ffprobePath(), "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "csv=s=x:p=0",
playlist.toString()), 60, "探测转码产物尺寸失败");
String first = output.lines().findFirst().orElse("").trim();
String[] pair = first.split("x");
if (pair.length != 2) {
throw new ServiceException(ErrorCode.INTERNAL_ERROR,
"ffprobe 返回了无法解析的产物尺寸:" + first);
}
int width = parseInt(pair[0]);
int height = parseInt(pair[1]);
if (width <= 0 || height <= 0 || width % 2 != 0 || height % 2 != 0) {
throw new ServiceException(ErrorCode.INTERNAL_ERROR,
"转码产物尺寸非法:" + width + "x" + height);
}
return new Dimensions(width, height);
}
private int parseInt(String value) {
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException ex) {
throw new ServiceException(ErrorCode.INTERNAL_ERROR,
"ffprobe 返回了非法数字:" + value);
}
}
private double parseDouble(String value) {
try {
return Double.parseDouble(value.trim());
} catch (NumberFormatException ex) {
throw new ServiceException(ErrorCode.INTERNAL_ERROR,
"ffprobe 返回了非法旋转角度:" + value);
}
}
/** 运行 ffmpeg 并轮询 -progress 文件,把 out_time 映射成 0-100。 */
private String runProcessWithProgress(
List<String> command, int timeoutSeconds, String errorPrefix,
Path progressFile, int durationSec, IntConsumer listener) {
Path logFile = null;
try {
logFile = Files.createTempFile("fruitshorts-ffmpeg-", ".log");
Process process = new ProcessBuilder(command)
.redirectErrorStream(true)
.redirectOutput(logFile.toFile())
.start();
AtomicInteger lastReported = new AtomicInteger(-1);
long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
while (!process.waitFor(PROCESS_POLL_MILLIS, TimeUnit.MILLISECONDS)) {
FfmpegProgressMonitor.report(progressFile, durationSec, lastReported, listener);
if (System.currentTimeMillis() > deadline) {
process.destroyForcibly();
throw new ServiceException(ErrorCode.INTERNAL_ERROR,
errorPrefix + ":超时(" + timeoutSeconds + "s)");
}
}
FfmpegProgressMonitor.report(progressFile, durationSec, lastReported, listener);
String output = Files.readString(logFile, StandardCharsets.UTF_8);
if (process.exitValue() != 0) {
throw new ServiceException(ErrorCode.INTERNAL_ERROR,
errorPrefix + ":" + tail(output));
}
return output;
} catch (ServiceException ex) {
throw ex;
} catch (IOException ex) {
throw new ServiceException(ErrorCode.INTERNAL_ERROR,
errorPrefix + ":无法执行 ffmpeg(" + ex.getMessage() + ")");
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new ServiceException(ErrorCode.INTERNAL_ERROR, errorPrefix + ":被中断");
} finally {
FfmpegProgressMonitor.deleteQuietly(logFile);
}
}
/** /**
* 起进程并等结果;非 0 退出把输出尾部带进异常,方便定位是片源问题还是参数问题。 * 起进程并等结果;非 0 退出把输出尾部带进异常,方便定位是片源问题还是参数问题。
* *
@@ -235,4 +415,17 @@ public class FfmpegTranscodeRunner implements TranscodeRunner {
String cleanPrefix = prefix == null ? "" : prefix.replaceAll("/+$", ""); String cleanPrefix = prefix == null ? "" : prefix.replaceAll("/+$", "");
return cleanPrefix.isEmpty() ? relative : cleanPrefix + "/" + relative; return cleanPrefix.isEmpty() ? relative : cleanPrefix + "/" + relative;
} }
private record SourceDimensions(int width, int height) {
private boolean landscape() {
return width >= height;
}
private int shortEdge() {
return Math.min(width, height);
}
}
private record Dimensions(int width, int height) {
}
} }
@@ -4,6 +4,7 @@ package com.fruitshorts.media.service.transcode;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.function.IntConsumer;
/** /**
* 转码执行端口:把"调 ffmpeg 出多码率 HLS + 封面"封在一层后面。 * 转码执行端口:把"调 ffmpeg 出多码率 HLS + 封面"封在一层后面。
@@ -30,14 +31,25 @@ public interface TranscodeRunner {
record TranscodeRequest(Long taskId, Path sourceFile, Path outputDir, String objectKeyPrefix, record TranscodeRequest(Long taskId, Path sourceFile, Path outputDir, String objectKeyPrefix,
List<LadderRung> ladder, String ffmpegPath, String ffprobePath, List<LadderRung> ladder, String ffmpegPath, String ffprobePath,
int segmentSeconds, int posterSecond, int timeoutSeconds, int segmentSeconds, int posterSecond, int timeoutSeconds,
SpriteOptions sprite) { SpriteOptions sprite, IntConsumer progressListener) {
public TranscodeRequest(Long taskId, Path sourceFile, Path outputDir, String objectKeyPrefix,
List<LadderRung> ladder, String ffmpegPath, String ffprobePath,
int segmentSeconds, int posterSecond, int timeoutSeconds,
SpriteOptions sprite) {
this(taskId, sourceFile, outputDir, objectKeyPrefix, ladder, ffmpegPath, ffprobePath,
segmentSeconds, posterSecond, timeoutSeconds, sprite, ignored -> {
});
}
/** 兼容未启用缩略图的既有调用。 */ /** 兼容未启用缩略图的既有调用。 */
public TranscodeRequest(Long taskId, Path sourceFile, Path outputDir, String objectKeyPrefix, public TranscodeRequest(Long taskId, Path sourceFile, Path outputDir, String objectKeyPrefix,
List<LadderRung> ladder, String ffmpegPath, String ffprobePath, List<LadderRung> ladder, String ffmpegPath, String ffprobePath,
int segmentSeconds, int posterSecond, int timeoutSeconds) { int segmentSeconds, int posterSecond, int timeoutSeconds) {
this(taskId, sourceFile, outputDir, objectKeyPrefix, ladder, ffmpegPath, ffprobePath, this(taskId, sourceFile, outputDir, objectKeyPrefix, ladder, ffmpegPath, ffprobePath,
segmentSeconds, posterSecond, timeoutSeconds, SpriteOptions.disabled()); segmentSeconds, posterSecond, timeoutSeconds, SpriteOptions.disabled(),
ignored -> {
});
} }
} }
@@ -9,6 +9,7 @@ import com.fruitshorts.media.service.MediaService;
import com.fruitshorts.media.service.MediaStorageService; import com.fruitshorts.media.service.MediaStorageService;
import com.fruitshorts.media.service.MediaTranscodeProfileService; import com.fruitshorts.media.service.MediaTranscodeProfileService;
import com.fruitshorts.media.service.EpisodeTranscodeCompletionPort; import com.fruitshorts.media.service.EpisodeTranscodeCompletionPort;
import com.fruitshorts.media.service.TranscodeErrorTruncator;
import com.fruitshorts.media.service.TranscodeService; import com.fruitshorts.media.service.TranscodeService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -19,6 +20,8 @@ import org.springframework.stereotype.Component;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.IntConsumer;
/** /**
* 转码 worker:消费 `transcode_task`,产出多码率 HLS + 封面。 * 转码 worker:消费 `transcode_task`,产出多码率 HLS + 封面。
@@ -38,6 +41,8 @@ import java.util.List;
@ConditionalOnProperty(name = "fruitshorts.media.transcode.worker-enabled", havingValue = "true") @ConditionalOnProperty(name = "fruitshorts.media.transcode.worker-enabled", havingValue = "true")
public class TranscodeWorker { public class TranscodeWorker {
private static final long PROGRESS_WRITE_INTERVAL_MS = 1000L;
private final TranscodeService transcodeService; private final TranscodeService transcodeService;
private final MediaService mediaService; private final MediaService mediaService;
private final MediaStorageService storageService; private final MediaStorageService storageService;
@@ -49,6 +54,10 @@ public class TranscodeWorker {
@Scheduled(fixedDelayString = "${fruitshorts.media.transcode.poll-delay-ms:5000}") @Scheduled(fixedDelayString = "${fruitshorts.media.transcode.poll-delay-ms:5000}")
public void poll() { public void poll() {
try { try {
int recovered = transcodeService.recoverStaleRunningTasks();
if (recovered > 0) {
log.warn("已自动收口 {} 个超时 RUNNING 转码任务", recovered);
}
processOne(); processOne();
} catch (Exception ex) { } catch (Exception ex) {
// 轮询里不能让异常逃出去,否则调度线程会被取消,整个 worker 静默停摆 // 轮询里不能让异常逃出去,否则调度线程会被取消,整个 worker 静默停摆
@@ -66,6 +75,8 @@ public class TranscodeWorker {
if (task == null) { if (task == null) {
return false; return false;
} }
log.info("开始处理转码任务 task={} asset={} episode={}",
task.getId(), task.getMediaAssetId(), task.getEpisodeId());
process(task); process(task);
return true; return true;
} }
@@ -82,7 +93,8 @@ public class TranscodeWorker {
task.getId(), sourceFile, outputDir, objectKeyPrefix, ladder(profile), task.getId(), sourceFile, outputDir, objectKeyPrefix, ladder(profile),
profileService.ffmpegPath(), profileService.ffprobePath(), profileService.ffmpegPath(), profileService.ffprobePath(),
profile.getSegmentSeconds(), profileService.posterSecond(), profile.getSegmentSeconds(), profileService.posterSecond(),
profileService.timeoutSeconds(), spriteOptions(profile))); profileService.timeoutSeconds(), spriteOptions(profile),
progressListener(task.getId())));
transcodeService.succeed(task.getId(), toRenditions(result), transcodeService.succeed(task.getId(), toRenditions(result),
toThumbnail(task, result.thumbnail())); toThumbnail(task, result.thumbnail()));
@@ -104,15 +116,23 @@ public class TranscodeWorker {
logThumbnailResult(result.thumbnail()); logThumbnailResult(result.thumbnail());
} catch (Exception ex) { } catch (Exception ex) {
String reason = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage(); String reason = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage();
int attempt = (task.getRetryCount() == null ? 0 : task.getRetryCount()) + 1; try {
String status = transcodeService.fail(task.getId(), reason); transcodeService.failFinal(task.getId(), reason);
if (TranscodeService.STATUS_FAILED.equals(status) } catch (Exception persistError) {
&& task.getMediaAssetId() != null && task.getMediaAssetId() > 0) { log.error("转码任务失败状态落库失败 task={} 原因={}",
mediaService.markFailed(task.getMediaAssetId(), reason); task.getId(), persistError.getMessage(), persistError);
} }
log.error("转码失败 task={} 第 {}/{} 次尝试 耗时={}ms 结果={} 原因={}", if (task.getMediaAssetId() != null && task.getMediaAssetId() > 0) {
task.getId(), attempt, TranscodeService.MAX_RETRY, System.currentTimeMillis() - startedAt, try {
status, reason); mediaService.markFailed(task.getMediaAssetId(), reason);
} catch (Exception mediaError) {
log.error("媒资失败状态落库失败 asset={} 原因={}",
task.getMediaAssetId(), mediaError.getMessage(), mediaError);
}
}
log.error("转码失败 task={} 耗时={}ms 结果=FAILED 原因={}",
task.getId(), System.currentTimeMillis() - startedAt,
TranscodeErrorTruncator.truncate(reason));
} }
} }
@@ -140,6 +160,17 @@ public class TranscodeWorker {
sprite.getColumns(), sprite.getThumbWidth()); sprite.getColumns(), sprite.getThumbWidth());
} }
private IntConsumer progressListener(Long taskId) {
AtomicLong lastWriteAt = new AtomicLong(0L);
return progress -> {
long now = System.currentTimeMillis();
if (now - lastWriteAt.get() >= PROGRESS_WRITE_INTERVAL_MS || progress >= 99) {
lastWriteAt.set(now);
transcodeService.updateProgress(taskId, progress);
}
};
}
private MediaThumbnail toThumbnail( private MediaThumbnail toThumbnail(
TranscodeTask task, TranscodeRunner.ProducedThumbnail produced) { TranscodeTask task, TranscodeRunner.ProducedThumbnail produced) {
if (produced == null) { if (produced == null) {
@@ -159,7 +190,7 @@ public class TranscodeWorker {
thumbnail.setDurationSec(produced.durationSec()); thumbnail.setDurationSec(produced.durationSec());
thumbnail.setSizeBytes(produced.sizeBytes()); thumbnail.setSizeBytes(produced.sizeBytes());
thumbnail.setStatus(produced.status()); thumbnail.setStatus(produced.status());
thumbnail.setErrorMsg(produced.errorMessage()); thumbnail.setErrorMsg(TranscodeErrorTruncator.truncate(produced.errorMessage()));
return thumbnail; return thumbnail;
} }
@@ -169,7 +200,8 @@ public class TranscodeWorker {
return; return;
} }
if (MediaThumbnail.STATUS_FAILED.equals(thumbnail.status())) { if (MediaThumbnail.STATUS_FAILED.equals(thumbnail.status())) {
log.warn("主转码成功但缩略图失败:{}", thumbnail.errorMessage()); log.warn("主转码成功但缩略图失败:{}",
TranscodeErrorTruncator.truncate(thumbnail.errorMessage()));
return; return;
} }
log.info("缩略图完成:{} 帧,{}x{}", thumbnail.frameCount(), log.info("缩略图完成:{} 帧,{}x{}", thumbnail.frameCount(),
@@ -0,0 +1,34 @@
// Author: fruit.com.cn
package com.fruitshorts.media.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/** 后台媒资清晰度产物。 */
@Data
@Schema(description = "媒资清晰度产物")
public class MediaRenditionRespVO {
private Long id;
@Schema(description = "档位标识")
private String quality;
@Schema(description = "档位名称")
private String qualityName;
private Integer width;
private Integer height;
@Schema(description = "宽x高")
private String resolution;
private Integer videoBitrateKbps;
private Integer audioBitrateKbps;
private String videoCodec;
private String audioCodec;
@Schema(description = "容器类型")
private String container;
@Schema(description = "HLS 播放地址")
private String hlsUrl;
private Long sizeBytes;
private Integer durationSec;
private Integer segmentCount;
private String status;
}
@@ -0,0 +1,24 @@
// Author: fruit.com.cn
package com.fruitshorts.media.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/** VTT 时间轴中的单个缩略图帧。 */
@Data
@Schema(description = "缩略图帧")
public class MediaThumbnailFrameVO {
@Schema(description = "帧序号,从 0 开始")
private Integer frameIndex;
private Long timeMs;
private Long endTimeMs;
@Schema(description = "带 xywh 片段的图片地址")
private String imageUrl;
private String spriteUrl;
private Integer x;
private Integer y;
private Integer width;
private Integer height;
}
@@ -0,0 +1,40 @@
// Author: fruit.com.cn
package com.fruitshorts.media.vo;
import com.fruitshorts.common.pojo.PageResult;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/** 后台单集缩略图轴响应。 */
@Data
@Schema(description = "缩略图轴信息")
public class MediaThumbnailRespVO {
public static final String STATUS_EMPTY = "EMPTY";
public static final String STATUS_INVALID = "INVALID";
public static final String STATUS_NO_EPISODE = "NO_EPISODE";
private Long mediaAssetId;
private Long episodeId;
private String status;
private String errorMsg;
private Integer pageIndex;
private Integer pageSize;
private PageResult<MediaThumbnailFrameVO> frames = PageResult.empty();
private MediaThumbnailSpriteVO sprite;
public static MediaThumbnailRespVO empty(Long mediaAssetId, Long episodeId,
String status, int pageIndex, int pageSize) {
MediaThumbnailRespVO vo = new MediaThumbnailRespVO();
vo.setMediaAssetId(mediaAssetId);
vo.setEpisodeId(episodeId);
vo.setStatus(status);
vo.setPageIndex(pageIndex);
vo.setPageSize(pageSize);
vo.setFrames(new PageResult<>(0L, List.of()));
return vo;
}
}
@@ -0,0 +1,24 @@
// Author: fruit.com.cn
package com.fruitshorts.media.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/** 雪碧图与 VTT 元数据。 */
@Data
@Schema(description = "雪碧图信息")
public class MediaThumbnailSpriteVO {
private String spriteUrl;
private List<String> spriteUrls;
private String vttUrl;
private Integer columns;
private Integer rows;
private Integer totalFrames;
private Integer thumbWidth;
private Integer thumbHeight;
private Integer intervalSeconds;
}
@@ -0,0 +1,24 @@
// Author: fruit.com.cn
package com.fruitshorts.media.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/** 转码队列累计统计。 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "转码队列统计")
public class TranscodeQueueStatsVO {
private long total;
private long queued;
private long running;
private long success;
private long failed;
private long canceled;
private long renditions;
}
@@ -0,0 +1,52 @@
// Author: fruit.com.cn
package com.fruitshorts.media.vo;
import com.fruitshorts.media.dal.entity.TranscodeTask;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/** 后台转码任务快照。 */
@Data
@Schema(description = "转码任务")
public class TranscodeTaskRespVO {
private Long id;
private Long mediaAssetId;
private Long dramaId;
private Long episodeId;
private String sourceUrl;
private String profile;
private String status;
private Integer progress;
private Integer retryCount;
private String errorMsg;
private LocalDateTime startedAt;
private LocalDateTime finishedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public static TranscodeTaskRespVO from(TranscodeTask task) {
if (task == null) {
return null;
}
TranscodeTaskRespVO vo = new TranscodeTaskRespVO();
vo.setId(task.getId());
vo.setMediaAssetId(task.getMediaAssetId());
vo.setDramaId(task.getDramaId());
vo.setEpisodeId(task.getEpisodeId());
vo.setSourceUrl(task.getSourceUrl());
vo.setProfile(task.getProfile());
vo.setStatus(task.getStatus());
vo.setProgress(task.getProgress());
vo.setRetryCount(task.getRetryCount());
vo.setErrorMsg(task.getErrorMsg());
vo.setStartedAt(task.getStartedAt());
vo.setFinishedAt(task.getFinishedAt());
vo.setCreatedAt(task.getCreatedAt());
vo.setUpdatedAt(task.getUpdatedAt());
return vo;
}
}
@@ -0,0 +1,213 @@
// Author: fruit.com.cn
package com.fruitshorts.media.service;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.media.dal.entity.MediaAsset;
import com.fruitshorts.media.dal.entity.MediaRendition;
import com.fruitshorts.media.dal.entity.MediaThumbnail;
import com.fruitshorts.media.dal.entity.TranscodeTask;
import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
class MediaTranscodeOutputServiceTest {
private static final Long MEDIA_ASSET_ID = 88L;
private static final Long EPISODE_ID = 31L;
@TempDir
Path tempDir;
private MediaAssetMapper mediaAssetMapper;
private TranscodeService transcodeService;
private MediaStorageService storageService;
private PlaybackSigner signer;
private MediaTranscodeOutputService service;
@BeforeEach
void setUp() {
mediaAssetMapper = mock(MediaAssetMapper.class);
transcodeService = mock(TranscodeService.class);
storageService = mock(MediaStorageService.class);
signer = mock(PlaybackSigner.class);
service = new MediaTranscodeOutputService(
mediaAssetMapper, transcodeService, storageService, signer);
given(signer.sign(anyString())).willAnswer(invocation -> invocation.getArgument(0));
}
@Test
void should_return_renditions_sorted_from_highest_to_lowest() {
givenMedia();
given(transcodeService.listRenditionsByMediaAssetId(MEDIA_ASSET_ID))
.willReturn(List.of(
rendition(1L, "360p", 640, 360, 400),
rendition(2L, "1080p", 1920, 1080, 5000),
rendition(3L, "720p", 1280, 720, 2500)));
var result = service.renditions(MEDIA_ASSET_ID);
assertThat(result).extracting(vo -> vo.getQuality())
.containsExactly("1080p", "720p", "360p");
assertThat(result.get(0).getQualityName()).isEqualTo("1080P");
assertThat(result.get(0).getResolution()).isEqualTo("1920x1080");
assertThat(result.get(0).getVideoBitrateKbps()).isEqualTo(5000);
assertThat(result.get(0).getVideoCodec()).isEqualTo("H.264");
assertThat(result.get(0).getAudioCodec()).isEqualTo("AAC");
assertThat(result.get(0).getContainer()).isEqualTo("HLS");
assertThat(result.get(0).getHlsUrl()).isEqualTo("/files/master.m3u8");
assertThat(result.get(0).getSizeBytes()).isEqualTo(4096L);
assertThat(result.get(0).getStatus()).isEqualTo("ready");
}
@Test
void should_return_not_found_when_media_does_not_exist() {
given(mediaAssetMapper.selectById(404L)).willReturn(null);
assertThatThrownBy(() -> service.renditions(404L))
.isInstanceOf(ServiceException.class)
.hasMessage("媒资不存在");
}
@Test
void should_return_empty_thumbnail_status_when_product_is_missing() {
givenMedia();
given(transcodeService.findLatestTaskByMediaAssetId(MEDIA_ASSET_ID))
.willReturn(task(EPISODE_ID));
given(transcodeService.findThumbnailByEpisodeId(EPISODE_ID)).willReturn(null);
var result = service.thumbnails(MEDIA_ASSET_ID, 1, 100);
assertThat(result.getStatus()).isEqualTo("EMPTY");
assertThat(result.getFrames().getTotal()).isZero();
assertThat(result.getFrames().getList()).isEmpty();
assertThat(result.getSprite()).isNull();
}
@Test
void should_page_vtt_frames_and_expose_sprite_metadata() throws Exception {
givenMedia();
given(transcodeService.findLatestTaskByMediaAssetId(MEDIA_ASSET_ID))
.willReturn(task(EPISODE_ID));
MediaThumbnail thumbnail = thumbnail();
given(transcodeService.findThumbnailByEpisodeId(EPISODE_ID)).willReturn(thumbnail);
writeVtt("""
WEBVTT
00:00:00.000 --> 00:00:01.000
sprite_001.jpg#xywh=0,0,160,90
00:00:01.000 --> 00:00:02.000
sprite_001.jpg#xywh=160,0,160,90
00:00:02.000 --> 00:00:03.000
sprite_001.jpg#xywh=320,0,160,90
00:00:03.000 --> 00:00:04.000
sprite_002.jpg#xywh=0,0,160,90
""");
given(storageService.fileFor("transcoded/8/thumbnails/thumbnails.vtt"))
.willReturn(tempDir.resolve("thumbnails.vtt"));
given(storageService.toPublicUrl("transcoded/8/thumbnails/thumbnails.vtt"))
.willReturn("/files/transcoded/8/thumbnails/thumbnails.vtt");
given(storageService.toPublicUrl("transcoded/8/thumbnails/sprite_001.jpg"))
.willReturn("/files/transcoded/8/thumbnails/sprite_001.jpg");
given(storageService.toPublicUrl("transcoded/8/thumbnails/sprite_002.jpg"))
.willReturn("/files/transcoded/8/thumbnails/sprite_002.jpg");
var result = service.thumbnails(MEDIA_ASSET_ID, 2, 2);
assertThat(result.getStatus()).isEqualTo("READY");
assertThat(result.getPageIndex()).isEqualTo(2);
assertThat(result.getPageSize()).isEqualTo(2);
assertThat(result.getFrames().getTotal()).isEqualTo(4);
assertThat(result.getFrames().getList()).hasSize(2);
assertThat(result.getFrames().getList().get(0).getFrameIndex()).isEqualTo(2);
assertThat(result.getFrames().getList().get(0).getTimeMs()).isEqualTo(2000);
assertThat(result.getFrames().getList().get(0).getImageUrl())
.isEqualTo("/files/transcoded/8/thumbnails/sprite_001.jpg#xywh=320,0,160,90");
assertThat(result.getSprite().getColumns()).isEqualTo(2);
assertThat(result.getSprite().getRows()).isEqualTo(2);
assertThat(result.getSprite().getTotalFrames()).isEqualTo(4);
assertThat(result.getSprite().getThumbWidth()).isEqualTo(160);
assertThat(result.getSprite().getThumbHeight()).isEqualTo(90);
assertThat(result.getSprite().getSpriteUrls()).hasSize(2);
assertThat(result.getSprite().getVttUrl())
.isEqualTo("/files/transcoded/8/thumbnails/thumbnails.vtt");
}
@Test
void should_clamp_thumbnail_page_size_to_five_hundred() {
givenMedia();
given(transcodeService.findLatestTaskByMediaAssetId(MEDIA_ASSET_ID))
.willReturn(task(EPISODE_ID));
given(transcodeService.findThumbnailByEpisodeId(EPISODE_ID))
.willReturn(thumbnail());
var result = service.thumbnails(MEDIA_ASSET_ID, 1, 501);
assertThat(result.getPageIndex()).isEqualTo(1);
assertThat(result.getPageSize()).isEqualTo(500);
}
private void givenMedia() {
MediaAsset asset = new MediaAsset();
asset.setId(MEDIA_ASSET_ID);
asset.setFileName("video.mp4");
given(mediaAssetMapper.selectById(MEDIA_ASSET_ID)).willReturn(asset);
}
private MediaRendition rendition(Long id, String quality, int width, int height, int bitrate) {
MediaRendition rendition = new MediaRendition();
rendition.setId(id);
rendition.setQuality(quality);
rendition.setWidth(width);
rendition.setHeight(height);
rendition.setVideoBitrate(bitrate);
rendition.setAudioBitrate(64);
rendition.setFormat("HLS");
rendition.setPlayUrl("/files/master.m3u8");
rendition.setSizeBytes(4096L);
rendition.setStatus("ready");
return rendition;
}
private TranscodeTask task(Long episodeId) {
TranscodeTask task = new TranscodeTask();
task.setId(8L);
task.setMediaAssetId(MEDIA_ASSET_ID);
task.setEpisodeId(episodeId);
task.setStatus(TranscodeService.STATUS_SUCCESS);
return task;
}
private MediaThumbnail thumbnail() {
MediaThumbnail thumbnail = new MediaThumbnail();
thumbnail.setEpisodeId(EPISODE_ID);
thumbnail.setVttObjectKey("transcoded/8/thumbnails/thumbnails.vtt");
thumbnail.setSpritePrefix("transcoded/8/thumbnails/sprite_");
thumbnail.setColumns(2);
thumbnail.setRows(2);
thumbnail.setThumbWidth(160);
thumbnail.setThumbHeight(90);
thumbnail.setFrameCount(4);
thumbnail.setIntervalSec(1);
thumbnail.setStatus(MediaThumbnail.STATUS_READY);
return thumbnail;
}
private void writeVtt(String content) throws Exception {
Files.writeString(tempDir.resolve("thumbnails.vtt"), content);
}
}
@@ -0,0 +1,44 @@
// Author: fruit.com.cn
package com.fruitshorts.media.service;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class ThumbnailVttParserTest {
@Test
void should_parse_timestamps_and_sprite_rectangles() {
String vtt = """
WEBVTT
00:00:00.000 --> 00:00:01.000
sprite_001.jpg#xywh=0,0,160,90
00:00:01.500 --> 00:00:02.500
sprite_002.jpg#xywh=160,90,160,90
""";
List<ThumbnailVttParser.Cue> cues = ThumbnailVttParser.parse(vtt);
assertThat(cues).hasSize(2);
assertThat(cues.get(0).startMs()).isZero();
assertThat(cues.get(0).endMs()).isEqualTo(1000);
assertThat(cues.get(0).spriteFile()).isEqualTo("sprite_001.jpg");
assertThat(cues.get(1).startMs()).isEqualTo(1500);
assertThat(cues.get(1).spriteFile()).isEqualTo("sprite_002.jpg");
assertThat(cues.get(1).x()).isEqualTo(160);
assertThat(cues.get(1).y()).isEqualTo(90);
assertThat(cues.get(1).width()).isEqualTo(160);
assertThat(cues.get(1).height()).isEqualTo(90);
}
@Test
void should_return_empty_list_when_vtt_is_invalid() {
assertThat(ThumbnailVttParser.parse("not a vtt")).isEmpty();
assertThat(ThumbnailVttParser.parse(null)).isEmpty();
}
}
@@ -0,0 +1,53 @@
// Author: fruit.com.cn
package com.fruitshorts.media.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.media.dal.entity.MediaRendition;
import com.fruitshorts.media.dal.entity.MediaThumbnail;
import com.fruitshorts.media.dal.entity.TranscodeTask;
import com.fruitshorts.media.dal.mapper.MediaRenditionMapper;
import com.fruitshorts.media.dal.mapper.MediaThumbnailMapper;
import com.fruitshorts.media.dal.mapper.TranscodeTaskMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
class TranscodeQueueStatsTest {
@BeforeAll
static void initTableInfoCache() {
MapperBuilderAssistant assistant =
new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, TranscodeTask.class);
TableInfoHelper.initTableInfo(assistant, MediaRendition.class);
TableInfoHelper.initTableInfo(assistant, MediaThumbnail.class);
}
@Test
void should_return_typed_total_and_status_counts() {
TranscodeTaskMapper taskMapper = mock(TranscodeTaskMapper.class);
MediaRenditionMapper renditionMapper = mock(MediaRenditionMapper.class);
given(taskMapper.selectCount(any())).willReturn(10L, 2L, 1L, 5L, 1L, 1L);
given(renditionMapper.selectCount(null)).willReturn(20L);
TranscodeService service = new TranscodeService(
taskMapper, renditionMapper, mock(MediaThumbnailMapper.class),
mock(ThumbnailRecordService.class));
var stats = service.stats();
assertThat(stats.getTotal()).isEqualTo(10L);
assertThat(stats.getQueued()).isEqualTo(2L);
assertThat(stats.getRunning()).isEqualTo(1L);
assertThat(stats.getSuccess()).isEqualTo(5L);
assertThat(stats.getFailed()).isEqualTo(1L);
assertThat(stats.getCanceled()).isEqualTo(1L);
assertThat(stats.getRenditions()).isEqualTo(20L);
}
}
@@ -4,6 +4,32 @@ package com.fruitshorts.playback.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.playback.dal.entity.WatchProgress; import com.fruitshorts.playback.dal.entity.WatchProgress;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
public interface WatchProgressMapper extends BaseMapper<WatchProgress> { public interface WatchProgressMapper extends BaseMapper<WatchProgress> {
/** 含逻辑删除行。唯一索引不区分 deleted,再看同一部剧必须能找回旧行。 */
@Select("""
SELECT * FROM watch_progress
WHERE user_id = #{userId} AND drama_id = #{dramaId}
LIMIT 1
""")
WatchProgress selectAny(@Param("userId") Long userId, @Param("dramaId") Long dramaId);
/** 恢复并更新。手写 SQL 不带 deleted=0 条件,逻辑删除行也能写回。 */
@Update("""
UPDATE watch_progress SET
deleted = 0,
episode_id = #{episodeId},
position_ms = #{positionMs},
duration_ms = #{durationMs},
finished = #{finished},
play_count = #{playCount},
device_id = #{deviceId},
source = #{source}
WHERE id = #{id}
""")
int updateAny(WatchProgress progress);
} }
@@ -13,6 +13,7 @@ import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.playback.dal.entity.PlaybackTicket; import com.fruitshorts.playback.dal.entity.PlaybackTicket;
import com.fruitshorts.playback.dal.mapper.PlaybackTicketMapper; import com.fruitshorts.playback.dal.mapper.PlaybackTicketMapper;
import com.fruitshorts.playback.vo.PlayRespVO; import com.fruitshorts.playback.vo.PlayRespVO;
import com.fruitshorts.playback.vo.PlayTrackVO;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -87,6 +88,7 @@ public class PlaybackService {
vo.setAccessible(true); vo.setAccessible(true);
vo.setAccessType(entitled && !free ? ACCESS_ENTITLED : ACCESS_FREE); vo.setAccessType(entitled && !free ? ACCESS_ENTITLED : ACCESS_FREE);
vo.setPlayUrl(signedUrl); vo.setPlayUrl(signedUrl);
vo.setTracks(tracksOf(episode.getId(), signedUrl));
if (userId != null) { if (userId != null) {
recordTicket(userId, drama.getId(), episode.getId(), signedUrl, deviceId, clientIp); recordTicket(userId, drama.getId(), episode.getId(), signedUrl, deviceId, clientIp);
@@ -115,6 +117,29 @@ public class PlaybackService {
return qualities; return qualities;
} }
/** AUTO 用主列表,其余档位用各自的 index.m3u8,地址都签名。 */
private List<PlayTrackVO> tracksOf(Long episodeId, String signedAuto) {
List<PlayTrackVO> tracks = new ArrayList<>();
tracks.add(track("AUTO", signedAuto));
for (MediaRendition rendition : transcodeService.listRenditions(episodeId)) {
if (!StringUtils.hasText(rendition.getPlayUrl())) {
continue;
}
if (!"ready".equalsIgnoreCase(rendition.getStatus())) {
continue;
}
tracks.add(track(rendition.getQuality(), signer.sign(rendition.getPlayUrl())));
}
return tracks;
}
private PlayTrackVO track(String quality, String playUrl) {
PlayTrackVO track = new PlayTrackVO();
track.setQuality(quality);
track.setPlayUrl(playUrl);
return track;
}
private void recordTicket(Long userId, Long dramaId, Long episodeId, String playUrl, private void recordTicket(Long userId, Long dramaId, Long episodeId, String playUrl,
String deviceId, String clientIp) { String deviceId, String clientIp) {
PlaybackTicket ticket = new PlaybackTicket(); PlaybackTicket ticket = new PlaybackTicket();
@@ -17,6 +17,7 @@ import com.fruitshorts.playback.dal.mapper.WatchProgressMapper;
import com.fruitshorts.playback.vo.ProgressRespVO; import com.fruitshorts.playback.vo.ProgressRespVO;
import com.fruitshorts.playback.vo.ProgressSaveReqVO; import com.fruitshorts.playback.vo.ProgressSaveReqVO;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -58,30 +59,23 @@ public class WatchProgressService {
boolean finished = Boolean.TRUE.equals(req.getFinished()) boolean finished = Boolean.TRUE.equals(req.getFinished())
|| (duration > 0 && position + FINISH_THRESHOLD_MS >= duration); || (duration > 0 && position + FINISH_THRESHOLD_MS >= duration);
WatchProgress existing = progressMapper.selectOne(Wrappers.<WatchProgress>lambdaQuery() LoadedProgress loaded = loadForSave(userId, drama.getId(), episode, position, duration,
.eq(WatchProgress::getUserId, userId) finished, req);
.eq(WatchProgress::getDramaId, drama.getId()) WatchProgress existing = loaded.progress();
.last("limit 1")); if (loaded.created()) {
if (existing == null) {
WatchProgress progress = new WatchProgress();
progress.setUserId(userId);
progress.setDramaId(drama.getId());
progress.setEpisodeId(episode.getId());
progress.setPositionMs(position);
progress.setDurationMs(duration);
progress.setFinished(finished ? 1 : 0);
progress.setPlayCount(1);
progress.setDeviceId(req.getDeviceId() == null ? "" : req.getDeviceId());
progress.setSource(req.getSource() == null ? "" : req.getSource());
progressMapper.insert(progress);
appendHistory(userId, drama.getId(), episode.getId(), position, duration, appendHistory(userId, drama.getId(), episode.getId(), position, duration,
progress.getSource(), progress.getDeviceId()); existing.getSource(), existing.getDeviceId());
// updated_at 由触发器维护,回读一次,保证响应与后续 recent 列表口径一致 return toResp(progressMapper.selectById(existing.getId()));
return toResp(progressMapper.selectById(progress.getId()));
} }
boolean hidden = existing.getDeleted() != null && existing.getDeleted() != 0;
boolean sameEpisode = existing.getEpisodeId() != null boolean sameEpisode = existing.getEpisodeId() != null
&& existing.getEpisodeId().equals(episode.getId()); && existing.getEpisodeId().equals(episode.getId());
if (hidden && sameEpisode) {
existing.setPlayCount((existing.getPlayCount() == null ? 0 : existing.getPlayCount()) + 1);
appendHistory(userId, drama.getId(), episode.getId(), position, duration,
req.getSource(), existing.getDeviceId());
}
if (!sameEpisode) { if (!sameEpisode) {
// 换集:直接采纳新集与新的播放位置 // 换集:直接采纳新集与新的播放位置
existing.setEpisodeId(episode.getId()); existing.setEpisodeId(episode.getId());
@@ -103,10 +97,52 @@ public class WatchProgressService {
} }
existing.setDeviceId(req.getDeviceId() == null ? "" : req.getDeviceId()); existing.setDeviceId(req.getDeviceId() == null ? "" : req.getDeviceId());
existing.setSource(req.getSource() == null ? "" : req.getSource()); existing.setSource(req.getSource() == null ? "" : req.getSource());
progressMapper.updateById(existing); if (hidden) {
existing.setDeleted(0);
progressMapper.updateAny(existing);
} else {
progressMapper.updateById(existing);
}
return toResp(progressMapper.selectById(existing.getId())); return toResp(progressMapper.selectById(existing.getId()));
} }
private LoadedProgress loadForSave(Long userId, Long dramaId, DramaEpisode episode, long position,
long duration, boolean finished, ProgressSaveReqVO req) {
WatchProgress existing = progressMapper.selectAny(userId, dramaId);
if (existing != null) {
return new LoadedProgress(existing, false);
}
WatchProgress created = newProgress(userId, dramaId, episode, position, duration, finished, req);
try {
progressMapper.insert(created);
return new LoadedProgress(created, true);
} catch (DuplicateKeyException ex) {
WatchProgress raced = progressMapper.selectAny(userId, dramaId);
if (raced == null) {
throw ex;
}
return new LoadedProgress(raced, false);
}
}
private WatchProgress newProgress(Long userId, Long dramaId, DramaEpisode episode, long position,
long duration, boolean finished, ProgressSaveReqVO req) {
WatchProgress progress = new WatchProgress();
progress.setUserId(userId);
progress.setDramaId(dramaId);
progress.setEpisodeId(episode.getId());
progress.setPositionMs(position);
progress.setDurationMs(duration);
progress.setFinished(finished ? 1 : 0);
progress.setPlayCount(1);
progress.setDeviceId(req.getDeviceId() == null ? "" : req.getDeviceId());
progress.setSource(req.getSource() == null ? "" : req.getSource());
return progress;
}
private record LoadedProgress(WatchProgress progress, boolean created) {
}
public ProgressRespVO ofDrama(Long userId, Long dramaId) { public ProgressRespVO ofDrama(Long userId, Long dramaId) {
WatchProgress progress = progressMapper.selectOne(Wrappers.<WatchProgress>lambdaQuery() WatchProgress progress = progressMapper.selectOne(Wrappers.<WatchProgress>lambdaQuery()
.eq(WatchProgress::getUserId, userId) .eq(WatchProgress::getUserId, userId)
@@ -185,6 +221,7 @@ public class WatchProgressService {
vo.setDramaTitle(drama.getTitle()); vo.setDramaTitle(drama.getTitle());
vo.setDramaCover(drama.getCoverUrl()); vo.setDramaCover(drama.getCoverUrl());
vo.setTotalEpisodes(drama.getTotalEpisodes()); vo.setTotalEpisodes(drama.getTotalEpisodes());
vo.setViewCount(drama.getViewCount());
} }
DramaEpisode episode = episodeById.get(progress.getEpisodeId()); DramaEpisode episode = episodeById.get(progress.getEpisodeId());
if (episode != null) { if (episode != null) {
@@ -0,0 +1,16 @@
// Author: fruit.com.cn
package com.fruitshorts.playback.vo;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import lombok.Data;
/** 批量取消收藏。 */
@Data
public class FavoriteRemoveReqVO {
@NotEmpty(message = "缺少 dramaIds")
private List<@NotNull Long> dramaIds;
}
@@ -23,6 +23,8 @@ public class PlayRespVO {
private String playUrl; private String playUrl;
/** 可用的清晰度档位 */ /** 可用的清晰度档位 */
private List<String> qualities; private List<String> qualities;
/** 各档签名地址。AUTO 是主播放列表,其余是单档 HLS。 */
private List<PlayTrackVO> tracks;
/** 雪碧图 WebVTT 时间轴地址;没有产物时为 null */ /** 雪碧图 WebVTT 时间轴地址;没有产物时为 null */
private String thumbnailsUrl; private String thumbnailsUrl;
/** 需要多少金币解锁 */ /** 需要多少金币解锁 */
@@ -0,0 +1,16 @@
// Author: fruit.com.cn
package com.fruitshorts.playback.vo;
import lombok.Data;
/** 一档可切换的清晰度及其签名播放地址。 */
@Data
public class PlayTrackVO {
/** AUTO 或 360p/480p/720p/1080p。 */
private String quality;
/** 签名后的播放地址,客户端原样使用。 */
private String playUrl;
}
@@ -20,6 +20,8 @@ public class ProgressRespVO {
private Integer episodeNumber; private Integer episodeNumber;
/** 短剧总集数;短剧不存在时为空。 */ /** 短剧总集数;短剧不存在时为空。 */
private Integer totalEpisodes; private Integer totalEpisodes;
/** 短剧热度,取 drama.view_count。不是用户自己的播放次数。 */
private Long viewCount;
private Long positionMs; private Long positionMs;
private Long durationMs; private Long durationMs;
private Boolean finished; private Boolean finished;
+5
View File
@@ -21,5 +21,10 @@
<groupId>com.fruitshorts</groupId> <groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-framework</artifactId> <artifactId>fruitshorts-framework</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.22.0</version>
</dependency>
</dependencies> </dependencies>
</project> </project>
@@ -0,0 +1,31 @@
// Author: fruit.com.cn
package com.fruitshorts.system.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 服务条款与隐私协议。每种文档按语言各存一份。
*/
@Data
@TableName("legal_document")
public class LegalDocument {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** terms 或 privacy。 */
private String docCode;
/** zh、en、id。 */
private String lang;
private String title;
private String content;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,10 @@
// Author: fruit.com.cn
package com.fruitshorts.system.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.system.dal.entity.LegalDocument;
/** 协议正文。 */
public interface LegalDocumentMapper extends BaseMapper<LegalDocument> {
}
@@ -0,0 +1,125 @@
// Author: fruit.com.cn
package com.fruitshorts.system.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.system.dal.entity.LegalDocument;
import com.fruitshorts.system.dal.mapper.LegalDocumentMapper;
import com.fruitshorts.system.vo.LegalDocumentVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 服务条款与隐私协议:后台保存三语文案,公开页按路径和语言输出 HTML。
*/
@Service
@RequiredArgsConstructor
public class LegalDocumentService {
private final LegalDocumentMapper legalDocumentMapper;
/** 六份协议。库里没有的语言用缺省文案补齐,方便后台第一次打开就能编辑。 */
public List<LegalDocumentVO> list() {
Map<String, LegalDocument> stored = index(legalDocumentMapper.selectList(null));
List<LegalDocumentVO> result = new ArrayList<>();
for (String code : LegalPages.DOC_CODES) {
for (String lang : LegalPages.LANGS) {
result.add(toView(code, lang, stored.get(LegalPages.key(code, lang))));
}
}
return result;
}
/** 按文档和语言覆盖保存。未知种类或语言直接拒绝。 */
@Transactional(rollbackFor = Exception.class)
public void save(List<LegalDocumentVO> items) {
if (items == null || items.isEmpty()) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请提交协议内容");
}
for (LegalDocumentVO item : items) {
upsert(item);
}
}
/**
* 公开页面。路径只能是 privacy 或 terms,语言只能是 zh、en、id。
* 不合法时返回 null,由控制器回 404。
*/
public String render(String path, String lang) {
String code = LegalPages.docCodeOfPath(path);
if (code == null || !LegalPages.LANGS.contains(lang)) {
return null;
}
return LegalPageHtml.render(lang, contentOf(code, lang, find(code, lang)));
}
private void upsert(LegalDocumentVO item) {
String code = item == null ? null : item.getDocCode();
String lang = item == null ? null : item.getLang();
if (!LegalPages.DOC_CODES.contains(code) || !LegalPages.LANGS.contains(lang)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "协议种类或语言不正确");
}
String title = item.getTitle() == null ? "" : item.getTitle().trim();
String content = item.getContent() == null ? "" : item.getContent();
if (title.length() > LegalPages.TITLE_MAX || content.length() > LegalPages.CONTENT_MAX) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "协议标题或正文过长");
}
LegalDocument row = find(code, lang);
if (row == null) {
row = new LegalDocument();
row.setDocCode(code);
row.setLang(lang);
}
row.setTitle(title);
row.setContent(content);
row.setUpdatedAt(LocalDateTime.now());
if (row.getId() == null) {
legalDocumentMapper.insert(row);
} else {
legalDocumentMapper.updateById(row);
}
}
private LegalDocument find(String code, String lang) {
return legalDocumentMapper.selectOne(Wrappers.<LegalDocument>lambdaQuery()
.eq(LegalDocument::getDocCode, code)
.eq(LegalDocument::getLang, lang)
.last("limit 1"));
}
private Map<String, LegalDocument> index(List<LegalDocument> rows) {
Map<String, LegalDocument> stored = new HashMap<>();
if (rows == null) {
return stored;
}
for (LegalDocument row : rows) {
stored.put(LegalPages.key(row.getDocCode(), row.getLang()), row);
}
return stored;
}
private LegalDocumentVO toView(String code, String lang, LegalDocument row) {
LegalDocumentVO view = new LegalDocumentVO();
view.setDocCode(code);
view.setLang(lang);
view.setTitle("");
view.setContent(row == null ? LegalPages.defaultContent(lang) : row.getContent());
return view;
}
private String contentOf(String code, String lang, LegalDocument row) {
if (row != null && row.getContent() != null) {
return row.getContent();
}
return LegalPages.defaultContent(lang);
}
}
@@ -0,0 +1,56 @@
// Author: fruit.com.cn
package com.fruitshorts.system.service;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.HtmlRenderer;
/**
* 把协议 Markdown 包成手机上可读的 HTML。不另加标题,原文里的 HTML 会转义。
*/
public final class LegalPageHtml {
private static final Parser PARSER = Parser.builder().build();
private static final HtmlRenderer RENDERER = HtmlRenderer.builder().escapeHtml(true).build();
private LegalPageHtml() {
}
/** 生成完整页面。语言只用于 html lang 属性。 */
public static String render(String lang, String markdown) {
String body = RENDERER.render(PARSER.parse(markdown == null ? "" : markdown));
return """
<!DOCTYPE html>
<html lang="%s">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title></title>
<style>
body{margin:0;background:#111;color:#f4f4f5;font:16px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
main{max-width:720px;margin:0 auto;padding:28px 20px 48px}
article>*:first-child{margin-top:0}
article p{margin:0 0 12px}
article h1,article h2,article h3{font-size:18px;line-height:1.4;margin:20px 0 8px}
article a{color:#ff6b2c}
article ul,article ol{margin:0 0 12px;padding-left:1.2em}
article code{font-family:ui-monospace,monospace}
article pre{overflow:auto;padding:12px;background:#1c1c1e;border-radius:8px}
</style>
</head>
<body><main><article>%s</article></main></body>
</html>
""".formatted(escape(lang), body);
}
/** 转义 HTML 特殊字符。 */
public static String escape(String raw) {
if (raw == null || raw.isEmpty()) {
return "";
}
return raw.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -0,0 +1,47 @@
// Author: fruit.com.cn
package com.fruitshorts.system.service;
import java.util.List;
/**
* 协议种类、公开路径和缺省文案。路径按语言区分,例如 /privacy/zh、/terms/en。
*/
public final class LegalPages {
public static final String TERMS = "terms";
public static final String PRIVACY = "privacy";
public static final String PATH_TERMS = "terms";
public static final String PATH_PRIVACY = "privacy";
public static final List<String> DOC_CODES = List.of(TERMS, PRIVACY);
public static final List<String> LANGS = List.of("zh", "en", "id");
public static final int TITLE_MAX = 80;
public static final int CONTENT_MAX = 100000;
private LegalPages() {
}
/** 公开路径对应的文档编码。无法识别时返回 null。 */
public static String docCodeOfPath(String path) {
if (PATH_PRIVACY.equals(path)) {
return PRIVACY;
}
if (PATH_TERMS.equals(path)) {
return TERMS;
}
return null;
}
/** 后台还没保存时页面上使用的 Markdown 正文。 */
public static String defaultContent(String lang) {
return switch (lang) {
case "en" -> "This page is published by FruitShorts and can be edited as Markdown in the admin console.";
case "id" -> "Halaman ini dipublikasikan oleh FruitShorts dan dapat diubah sebagai Markdown di konsol admin.";
default -> "本页由水果短剧发布,可在管理后台用 Markdown 修改。";
};
}
public static String key(String docCode, String lang) {
return docCode + ":" + lang;
}
}
@@ -0,0 +1,21 @@
// Author: fruit.com.cn
package com.fruitshorts.system.vo;
import lombok.Data;
/**
* 后台读写的一份协议。公开页面不走这个结构,直接返回 HTML。
*/
@Data
public class LegalDocumentVO {
/** terms 或 privacy。 */
private String docCode;
/** zh、en、id。 */
private String lang;
private String title;
private String content;
}
@@ -294,6 +294,12 @@ public class AppAuthService {
/** 刷新令牌:轮换 refresh token,旧的立即失效。 */ /** 刷新令牌:轮换 refresh token,旧的立即失效。 */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public TokenRespVO refresh(String refreshToken) { public TokenRespVO refresh(String refreshToken) {
return refresh(refreshToken, null, null);
}
/** 刷新令牌,并在客户端带上系统信息时更新最近一次使用的系统。 */
@Transactional(rollbackFor = Exception.class)
public TokenRespVO refresh(String refreshToken, String platform, String osVersion) {
AppUserSession session = sessionMapper.selectOne(Wrappers.<AppUserSession>lambdaQuery() AppUserSession session = sessionMapper.selectOne(Wrappers.<AppUserSession>lambdaQuery()
.eq(AppUserSession::getRefreshTokenHash, hasher.sha256(refreshToken)) .eq(AppUserSession::getRefreshTokenHash, hasher.sha256(refreshToken))
.last("limit 1")); .last("limit 1"));
@@ -313,6 +319,12 @@ public class AppAuthService {
String nextRefreshToken = hasher.newRefreshToken(); String nextRefreshToken = hasher.newRefreshToken();
session.setRefreshTokenHash(hasher.sha256(nextRefreshToken)); session.setRefreshTokenHash(hasher.sha256(nextRefreshToken));
if (StringUtils.hasText(platform)) {
session.setPlatform(platform);
}
if (StringUtils.hasText(osVersion)) {
session.setOsVersion(osVersion);
}
session.setLastActiveAt(now); session.setLastActiveAt(now);
session.setExpireAt(now.plus(tokenProvider.appRefreshTtl())); session.setExpireAt(now.plus(tokenProvider.appRefreshTtl()));
sessionMapper.updateById(session); sessionMapper.updateById(session);
@@ -11,9 +11,11 @@ import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.user.dal.entity.AppUser; import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.entity.AppUserBanRecord; import com.fruitshorts.user.dal.entity.AppUserBanRecord;
import com.fruitshorts.user.dal.entity.AppUserCredential; import com.fruitshorts.user.dal.entity.AppUserCredential;
import com.fruitshorts.user.dal.entity.AppUserSession;
import com.fruitshorts.user.dal.mapper.AppUserBanRecordMapper; import com.fruitshorts.user.dal.mapper.AppUserBanRecordMapper;
import com.fruitshorts.user.dal.mapper.AppUserCredentialMapper; import com.fruitshorts.user.dal.mapper.AppUserCredentialMapper;
import com.fruitshorts.user.dal.mapper.AppUserMapper; import com.fruitshorts.user.dal.mapper.AppUserMapper;
import com.fruitshorts.user.dal.mapper.AppUserSessionMapper;
import com.fruitshorts.user.enums.AppUserStatus; import com.fruitshorts.user.enums.AppUserStatus;
import com.fruitshorts.user.vo.AppUserQueryReqVO; import com.fruitshorts.user.vo.AppUserQueryReqVO;
import com.fruitshorts.user.vo.AppUserAdminRespVO; import com.fruitshorts.user.vo.AppUserAdminRespVO;
@@ -26,7 +28,9 @@ import org.springframework.util.StringUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 后台用户管理:检索、详情、状态处置。 * 后台用户管理:检索、详情、状态处置。
@@ -40,6 +44,7 @@ public class AppUserAdminService {
private final AppUserMapper appUserMapper; private final AppUserMapper appUserMapper;
private final AppUserCredentialMapper credentialMapper; private final AppUserCredentialMapper credentialMapper;
private final AppUserBanRecordMapper banRecordMapper; private final AppUserBanRecordMapper banRecordMapper;
private final AppUserSessionMapper sessionMapper;
private final AppAuthService authService; private final AppAuthService authService;
public PageResult<AppUserAdminRespVO> page(AppUserQueryReqVO req) { public PageResult<AppUserAdminRespVO> page(AppUserQueryReqVO req) {
@@ -74,7 +79,9 @@ public class AppUserAdminService {
req.getRegisterSource()) req.getRegisterSource())
.orderByDesc(AppUser::getId)); .orderByDesc(AppUser::getId));
} }
return new PageResult<>(result.getTotal(), toRespList(result.getRecords())); List<AppUserAdminRespVO> list = toRespList(result.getRecords());
fillLatestSystem(list);
return new PageResult<>(result.getTotal(), list);
} }
public AppUserAdminRespVO detail(Long id) { public AppUserAdminRespVO detail(Long id) {
@@ -82,7 +89,9 @@ public class AppUserAdminService {
if (user == null) { if (user == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "用户不存在"); throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "用户不存在");
} }
return toAdminResp(user); AppUserAdminRespVO vo = toAdminResp(user);
fillLatestSystem(List.of(vo));
return vo;
} }
/** 修改账号状态。封禁/注销会立即踢下线。 */ /** 修改账号状态。封禁/注销会立即踢下线。 */
@@ -154,4 +163,37 @@ public class AppUserAdminService {
vo.setId(user.getId()); vo.setId(user.getId());
return vo; return vo;
} }
/** 用最近一次未吊销会话上的系统信息,供后台用户列表展示。 */
private void fillLatestSystem(List<AppUserAdminRespVO> list) {
if (list.isEmpty()) {
return;
}
List<Long> ids = new ArrayList<>(list.size());
for (AppUserAdminRespVO vo : list) {
if (vo.getId() != null) {
ids.add(vo.getId());
}
}
if (ids.isEmpty()) {
return;
}
List<AppUserSession> sessions = sessionMapper.selectList(Wrappers.<AppUserSession>lambdaQuery()
.in(AppUserSession::getUserId, ids)
.isNull(AppUserSession::getRevokedAt)
.orderByDesc(AppUserSession::getLastActiveAt)
.orderByDesc(AppUserSession::getId));
Map<Long, AppUserSession> latest = new HashMap<>();
for (AppUserSession session : sessions) {
latest.putIfAbsent(session.getUserId(), session);
}
for (AppUserAdminRespVO vo : list) {
AppUserSession session = latest.get(vo.getId());
if (session == null) {
continue;
}
vo.setPlatform(session.getPlatform());
vo.setOsVersion(session.getOsVersion());
}
}
} }
@@ -16,4 +16,10 @@ import lombok.EqualsAndHashCode;
public class AppUserAdminRespVO extends AppUserRespVO { public class AppUserAdminRespVO extends AppUserRespVO {
private Long id; private Long id;
/** 最近一次登录的系统:ANDROID / IOS / WEB。 */
private String platform;
/** 最近一次登录的系统版本。 */
private String osVersion;
} }
@@ -11,4 +11,10 @@ public class RefreshReqVO {
@NotBlank(message = "缺少 refreshToken") @NotBlank(message = "缺少 refreshToken")
private String refreshToken; private String refreshToken;
/** 客户端系统:ANDROID / IOS / WEB。刷新时顺带更新,避免必须重新登录才有记录。 */
private String platform;
/** 系统版本,例如 Android 16。 */
private String osVersion;
} }
@@ -12,6 +12,7 @@ import java.nio.file.Path;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream; import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -40,13 +41,15 @@ class FfmpegTranscodeRunnerSmokeTest {
Path output = work.resolve("out"); Path output = work.resolve("out");
TranscodeRunner runner = new FfmpegTranscodeRunner(); TranscodeRunner runner = new FfmpegTranscodeRunner();
AtomicInteger maxProgress = new AtomicInteger();
// 只跑两档(360p/480p)保持秒级;四档阶梯的逻辑在 TranscodeWorkerTest 里断言 // 只跑两档(360p/480p)保持秒级;四档阶梯的逻辑在 TranscodeWorkerTest 里断言
TranscodeRunner.TranscodeResult result = runner.run(new TranscodeRunner.TranscodeRequest( TranscodeRunner.TranscodeResult result = runner.run(new TranscodeRunner.TranscodeRequest(
1L, sample, output, "transcoded/smoke", 1L, sample, output, "transcoded/smoke",
List.of(new TranscodeRunner.LadderRung("360p", 640, 360, 400, 64), List.of(new TranscodeRunner.LadderRung("360p", 640, 360, 400, 64),
new TranscodeRunner.LadderRung("480p", 854, 480, 800, 64)), new TranscodeRunner.LadderRung("480p", 854, 480, 800, 64)),
ffmpeg, ffprobe, 2, 1, 120, ffmpeg, ffprobe, 2, 1, 120,
new TranscodeRunner.SpriteOptions(true, 1, 10, 160))); new TranscodeRunner.SpriteOptions(true, 1, 10, 160),
value -> maxProgress.accumulateAndGet(value, Math::max)));
assertThat(Files.exists(output.resolve("master.m3u8"))).isTrue(); assertThat(Files.exists(output.resolve("master.m3u8"))).isTrue();
assertThat(Files.exists(output.resolve("poster.jpg"))).isTrue(); assertThat(Files.exists(output.resolve("poster.jpg"))).isTrue();
@@ -60,6 +63,7 @@ class FfmpegTranscodeRunnerSmokeTest {
assertThat(rendition.sizeBytes()).isGreaterThan(0L); assertThat(rendition.sizeBytes()).isGreaterThan(0L);
} }
assertThat(result.durationSec()).isBetween(1, 5); assertThat(result.durationSec()).isBetween(1, 5);
assertThat(maxProgress.get()).isGreaterThanOrEqualTo(90);
assertThat(result.thumbnail()).isNotNull(); assertThat(result.thumbnail()).isNotNull();
assertThat(result.thumbnail().status()).isEqualTo("READY"); assertThat(result.thumbnail().status()).isEqualTo("READY");
assertThat(result.thumbnail().thumbWidth()).isEqualTo(160); assertThat(result.thumbnail().thumbWidth()).isEqualTo(160);
@@ -74,9 +78,58 @@ class FfmpegTranscodeRunnerSmokeTest {
} }
} }
@Test
void should_keep_landscape_aspect_and_write_actual_dimensions() throws Exception {
String ffmpeg = binaryPath("ffmpeg");
String ffprobe = binaryPath("ffprobe");
assumeTrue(ffmpeg != null && ffprobe != null,
"本机没有 ffmpeg/ffprobe,跳过真实转码 smoke");
TranscodeRunner.TranscodeResult result = transcodeOneRung(
ffmpeg, ffprobe, 1024, 576,
new TranscodeRunner.LadderRung("360p", 640, 360, 400, 64));
assertRenditionDimensions(result, 0, 640, 360);
assertNoUpscaleOrDistortion(result, 1024, 576, 640, 360);
}
@Test
void should_keep_portrait_orientation_and_even_dimensions() throws Exception {
String ffmpeg = binaryPath("ffmpeg");
String ffprobe = binaryPath("ffprobe");
assumeTrue(ffmpeg != null && ffprobe != null,
"本机没有 ffmpeg/ffprobe,跳过真实转码 smoke");
TranscodeRunner.TranscodeResult result = transcodeOneRung(
ffmpeg, ffprobe, 720, 1280,
new TranscodeRunner.LadderRung("360p", 640, 360, 400, 64));
assertRenditionDimensions(result, 0, 360, 640);
assertNoUpscaleOrDistortion(result, 720, 1280, 360, 640);
}
@Test
void should_not_upscale_when_source_short_edge_is_below_rung() throws Exception {
String ffmpeg = binaryPath("ffmpeg");
String ffprobe = binaryPath("ffprobe");
assumeTrue(ffmpeg != null && ffprobe != null,
"本机没有 ffmpeg/ffprobe,跳过真实转码 smoke");
TranscodeRunner.TranscodeResult result = transcodeOneRung(
ffmpeg, ffprobe, 640, 360,
new TranscodeRunner.LadderRung("480p", 854, 480, 800, 64));
assertRenditionDimensions(result, 0, 640, 360);
assertNoUpscaleOrDistortion(result, 640, 360, 640, 360);
}
private void generateSample(String ffmpeg, Path sample) throws Exception { private void generateSample(String ffmpeg, Path sample) throws Exception {
generateSample(ffmpeg, sample, 640, 360);
}
private void generateSample(String ffmpeg, Path sample, int width, int height) throws Exception {
Process process = new ProcessBuilder(ffmpeg, "-y", Process process = new ProcessBuilder(ffmpeg, "-y",
"-f", "lavfi", "-i", "testsrc=size=640x360:rate=15", "-f", "lavfi", "-i", "testsrc=size=" + width + "x" + height + ":rate=15",
"-f", "lavfi", "-i", "sine=frequency=440:duration=2", "-f", "lavfi", "-i", "sine=frequency=440:duration=2",
"-shortest", "-t", "2", "-shortest", "-t", "2",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac",
@@ -87,6 +140,84 @@ class FfmpegTranscodeRunnerSmokeTest {
assertThat(process.exitValue()).as("生成测试片失败").isZero(); assertThat(process.exitValue()).as("生成测试片失败").isZero();
} }
private TranscodeRunner.TranscodeResult transcodeOneRung(
String ffmpeg, String ffprobe, int width, int height,
TranscodeRunner.LadderRung rung) throws Exception {
Path work = Files.createTempDirectory("fruitshorts-aspect-smoke");
try {
Path sample = work.resolve("sample-" + width + "x" + height + ".mp4");
generateSample(ffmpeg, sample, width, height);
Path output = work.resolve("out");
TranscodeRunner.TranscodeResult result = new FfmpegTranscodeRunner().run(
new TranscodeRunner.TranscodeRequest(
2L, sample, output, "transcoded/aspect",
List.of(rung), ffmpeg, ffprobe, 2, 1, 120,
new TranscodeRunner.SpriteOptions(false, 1, 10, 160)));
Path playlist = output.resolve(rung.quality()).resolve("index.m3u8");
Dimensions probed = probeDimensions(ffprobe, playlist);
assertThat(result.renditions()).hasSize(1);
assertThat(result.renditions().get(0).width()).isEqualTo(probed.width());
assertThat(result.renditions().get(0).height()).isEqualTo(probed.height());
assertThat(probeSampleAspectRatio(ffprobe, playlist)).isEqualTo("1:1");
assertThat(readLines(output.resolve("master.m3u8")))
.anyMatch(line -> line.contains("RESOLUTION="
+ probed.width() + "x" + probed.height()));
return result;
} finally {
deleteRecursively(work);
}
}
private void assertRenditionDimensions(
TranscodeRunner.TranscodeResult result, int index, int width, int height) {
TranscodeRunner.ProducedRendition rendition = result.renditions().get(index);
assertThat(rendition.width()).isEqualTo(width);
assertThat(rendition.height()).isEqualTo(height);
}
private void assertNoUpscaleOrDistortion(
TranscodeRunner.TranscodeResult result,
int sourceWidth, int sourceHeight, int width, int height) {
TranscodeRunner.ProducedRendition rendition = result.renditions().get(0);
assertThat(rendition.width() % 2).isZero();
assertThat(rendition.height() % 2).isZero();
assertThat(rendition.width()).isLessThanOrEqualTo(sourceWidth);
assertThat(rendition.height()).isLessThanOrEqualTo(sourceHeight);
double sourceRatio = sourceWidth / (double) sourceHeight;
double outputRatio = rendition.width() / (double) rendition.height();
assertThat(Math.abs(sourceRatio - outputRatio)).isLessThan(0.01);
assertThat(rendition.width()).isEqualTo(width);
assertThat(rendition.height()).isEqualTo(height);
}
private Dimensions probeDimensions(String ffprobe, Path playlist) throws Exception {
String output = runAndRead(List.of(
ffprobe, "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "csv=s=x:p=0", playlist.toString()));
String[] parts = output.lines().findFirst().orElse("").split("x");
assertThat(parts).hasSize(2);
return new Dimensions(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));
}
private String probeSampleAspectRatio(String ffprobe, Path playlist) throws Exception {
String output = runAndRead(List.of(
ffprobe, "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=sample_aspect_ratio",
"-of", "default=noprint_wrappers=1:nokey=1",
playlist.toString()));
return output.lines().findFirst().orElse("").trim();
}
private String runAndRead(List<String> command) throws Exception {
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
String output = new String(process.getInputStream().readAllBytes()).trim();
assertThat(process.waitFor(30, TimeUnit.SECONDS)).as("ffprobe 超时").isTrue();
assertThat(process.exitValue()).as("ffprobe 失败:%s", output).isZero();
return output;
}
/** 在 PATH 里找可执行文件;找不到返回 null(由调用方 assume)。 */ /** 在 PATH 里找可执行文件;找不到返回 null(由调用方 assume)。 */
private String binaryPath(String name) { private String binaryPath(String name) {
try { try {
@@ -122,4 +253,7 @@ class FfmpegTranscodeRunnerSmokeTest {
// 同上 // 同上
} }
} }
private record Dimensions(int width, int height) {
}
} }
@@ -9,6 +9,7 @@ import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
import com.fruitshorts.media.dal.mapper.MediaUploadTaskMapper; import com.fruitshorts.media.dal.mapper.MediaUploadTaskMapper;
import com.fruitshorts.media.service.MediaService; import com.fruitshorts.media.service.MediaService;
import com.fruitshorts.media.service.MediaStorageService; import com.fruitshorts.media.service.MediaStorageService;
import com.fruitshorts.media.service.EpisodeVideoSourcePort;
import com.fruitshorts.media.service.TranscodeService; import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.media.vo.TranscodeTriggerRespVO; import com.fruitshorts.media.vo.TranscodeTriggerRespVO;
import com.fruitshorts.media.vo.UploadCompleteReqVO; import com.fruitshorts.media.vo.UploadCompleteReqVO;
@@ -16,6 +17,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -33,12 +35,15 @@ import static org.mockito.Mockito.verify;
class MediaServiceTranscodePipelineTest { class MediaServiceTranscodePipelineTest {
private static final Long ASSET_ID = 88L; private static final Long ASSET_ID = 88L;
private static final Long EPISODE_ID = 31L;
private static final Long DRAMA_ID = 7L;
private static final String SOURCE_URL = "/files/episode/202610/video.mp4"; private static final String SOURCE_URL = "/files/episode/202610/video.mp4";
private MediaAssetMapper mediaAssetMapper; private MediaAssetMapper mediaAssetMapper;
private MediaUploadTaskMapper uploadTaskMapper; private MediaUploadTaskMapper uploadTaskMapper;
private MediaStorageService storageService; private MediaStorageService storageService;
private TranscodeService transcodeService; private TranscodeService transcodeService;
private EpisodeVideoSourcePort episodeVideoSourcePort;
private MediaService mediaService; private MediaService mediaService;
@BeforeEach @BeforeEach
@@ -47,9 +52,10 @@ class MediaServiceTranscodePipelineTest {
uploadTaskMapper = mock(MediaUploadTaskMapper.class); uploadTaskMapper = mock(MediaUploadTaskMapper.class);
storageService = mock(MediaStorageService.class); storageService = mock(MediaStorageService.class);
transcodeService = mock(TranscodeService.class); transcodeService = mock(TranscodeService.class);
episodeVideoSourcePort = mock(EpisodeVideoSourcePort.class);
mediaService = new MediaService( mediaService = new MediaService(
new MediaProperties(), storageService, mediaAssetMapper, new MediaProperties(), storageService, mediaAssetMapper,
uploadTaskMapper, transcodeService); uploadTaskMapper, transcodeService, episodeVideoSourcePort);
} }
@Test @Test
@@ -91,25 +97,44 @@ class MediaServiceTranscodePipelineTest {
assertThatThrownBy(() -> mediaService.triggerTranscode(404L)) assertThatThrownBy(() -> mediaService.triggerTranscode(404L))
.isInstanceOf(com.fruitshorts.common.exception.ServiceException.class) .isInstanceOf(com.fruitshorts.common.exception.ServiceException.class)
.hasMessage("媒资不存在"); .hasMessage("媒资不存在");
verify(transcodeService, never()).trigger(any(), anyString()); verify(transcodeService, never()).trigger(
any(), anyString(), any(), any());
verify(episodeVideoSourcePort, never()).resolveByVideoUrl(anyString());
} }
@Test @Test
void should_trigger_transcode_and_mark_asset_queued() { void should_bind_episode_by_video_url_before_triggering_transcode() {
MediaAsset asset = asset("video.mp4"); MediaAsset asset = asset("video.mp4");
given(mediaAssetMapper.selectOne(any())).willReturn(asset); given(mediaAssetMapper.selectOne(any())).willReturn(asset);
given(transcodeService.trigger(ASSET_ID, SOURCE_URL)) given(episodeVideoSourcePort.resolveByVideoUrl(SOURCE_URL))
.willReturn(Optional.of(episodeSource()));
given(transcodeService.trigger(ASSET_ID, SOURCE_URL, DRAMA_ID, EPISODE_ID))
.willReturn(new TranscodeTriggerRespVO(100L, TranscodeService.STATUS_QUEUED)); .willReturn(new TranscodeTriggerRespVO(100L, TranscodeService.STATUS_QUEUED));
TranscodeTriggerRespVO result = mediaService.triggerTranscode(ASSET_ID); TranscodeTriggerRespVO result = mediaService.triggerTranscode(ASSET_ID);
assertThat(result.getTaskId()).isEqualTo(100L); assertThat(result.getTaskId()).isEqualTo(100L);
assertThat(result.getStatus()).isEqualTo(TranscodeService.STATUS_QUEUED); assertThat(result.getStatus()).isEqualTo(TranscodeService.STATUS_QUEUED);
verify(episodeVideoSourcePort).link(EPISODE_ID, ASSET_ID);
assertThat(asset.getStatus()).isEqualTo("transcoding"); assertThat(asset.getStatus()).isEqualTo("transcoding");
assertThat(asset.getTranscodeStatus()).isEqualTo(TranscodeService.STATUS_QUEUED); assertThat(asset.getTranscodeStatus()).isEqualTo(TranscodeService.STATUS_QUEUED);
verify(mediaAssetMapper).updateById(asset); verify(mediaAssetMapper).updateById(asset);
} }
@Test
void should_reject_existing_media_when_no_episode_uses_video_url() {
MediaAsset asset = asset("video.mp4");
given(mediaAssetMapper.selectOne(any())).willReturn(asset);
given(episodeVideoSourcePort.resolveByVideoUrl(SOURCE_URL))
.willReturn(Optional.empty());
assertThatThrownBy(() -> mediaService.triggerTranscode(ASSET_ID))
.isInstanceOf(com.fruitshorts.common.exception.ServiceException.class)
.hasMessage("媒资未绑定分集视频");
verify(transcodeService, never()).trigger(
any(), anyString(), any(), any());
}
private void givenCompletedUpload(String fileName) { private void givenCompletedUpload(String fileName) {
String objectKey = "episode/202610/" + fileName; String objectKey = "episode/202610/" + fileName;
MediaUploadTask task = new MediaUploadTask(); MediaUploadTask task = new MediaUploadTask();
@@ -143,4 +168,9 @@ class MediaServiceTranscodePipelineTest {
asset.setStatus("uploaded"); asset.setStatus("uploaded");
return asset; return asset;
} }
private EpisodeVideoSourcePort.EpisodeVideoSource episodeSource() {
return new EpisodeVideoSourcePort.EpisodeVideoSource(
EPISODE_ID, DRAMA_ID, ASSET_ID, SOURCE_URL, 120);
}
} }
@@ -20,7 +20,10 @@ import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import java.nio.file.Path; import java.nio.file.Path;
@@ -38,6 +41,7 @@ import static org.mockito.Mockito.verify;
* ① 认领任务靠状态条件更新,抢不到就是 null(多进程同时跑也只有一个能转); * ① 认领任务靠状态条件更新,抢不到就是 null(多进程同时跑也只有一个能转);
* ② objectKey → 本地路径要挡住目录穿越。 * ② objectKey → 本地路径要挡住目录穿越。
*/ */
@ExtendWith(OutputCaptureExtension.class)
class TranscodeClaimAndStorageTest { class TranscodeClaimAndStorageTest {
/** /**
@@ -98,6 +102,10 @@ class TranscodeClaimAndStorageTest {
@Test @Test
void should_exclude_unbound_queued_task_from_claim_query() { void should_exclude_unbound_queued_task_from_claim_query() {
TranscodeTask unbound = task(3L);
unbound.setMediaAssetId(88L);
unbound.setEpisodeId(0L);
given(transcodeTaskMapper.selectList(any())).willReturn(java.util.List.of(unbound));
given(transcodeTaskMapper.selectOne(any())).willReturn(null); given(transcodeTaskMapper.selectOne(any())).willReturn(null);
assertThat(transcodeService.claimNext()).isNull(); assertThat(transcodeService.claimNext()).isNull();
@@ -105,7 +113,23 @@ class TranscodeClaimAndStorageTest {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ArgumentCaptor<Wrapper<TranscodeTask>> captor = ArgumentCaptor.forClass(Wrapper.class); ArgumentCaptor<Wrapper<TranscodeTask>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(transcodeTaskMapper).selectOne(captor.capture()); verify(transcodeTaskMapper).selectOne(captor.capture());
assertThat(captor.getValue().getSqlSegment()).contains("episode_id >"); assertThat(captor.getValue().getSqlSegment())
.contains("episode_id >")
.contains("media_asset_id >")
.contains("source_url");
}
@Test
void should_log_skip_reason_when_queued_task_has_no_episode(CapturedOutput output) {
TranscodeTask unbound = task(9L);
unbound.setMediaAssetId(88L);
unbound.setEpisodeId(0L);
given(transcodeTaskMapper.selectList(any())).willReturn(java.util.List.of(unbound));
given(transcodeTaskMapper.selectOne(any())).willReturn(null);
assertThat(transcodeService.claimNext()).isNull();
assertThat(output).contains("跳过转码任务 9").contains("episode_id");
} }
@Test @Test
@@ -175,6 +199,45 @@ class TranscodeClaimAndStorageTest {
verify(transcodeTaskMapper).updateById(task); verify(transcodeTaskMapper).updateById(task);
} }
@Test
void should_delete_old_renditions_by_media_or_episode_before_insert() {
TranscodeTask task = task(6L);
task.setMediaAssetId(99L);
task.setEpisodeId(31L);
given(transcodeTaskMapper.selectById(6L)).willReturn(task);
MediaRendition rendition = new MediaRendition();
rendition.setQuality("360p");
rendition.setFormat("HLS");
transcodeService.succeed(6L, java.util.List.of(rendition));
verify(mediaRenditionMapper).deletePhysically(99L, 31L);
verify(mediaRenditionMapper).insert(rendition);
}
@Test
void should_replace_existing_thumbnail_by_episode_id() {
TranscodeTask task = task(7L);
task.setDramaId(7L);
task.setEpisodeId(31L);
MediaThumbnail existing = new MediaThumbnail();
existing.setId(70L);
existing.setEpisodeId(31L);
given(mediaThumbnailMapper.selectOne(any())).willReturn(existing);
MediaThumbnail thumbnail = new MediaThumbnail();
thumbnail.setEpisodeId(31L);
thumbnail.setStatus(MediaThumbnail.STATUS_READY);
ThumbnailRecordService recordService = new ThumbnailRecordService(mediaThumbnailMapper);
recordService.upsert(task, thumbnail);
@SuppressWarnings("unchecked")
ArgumentCaptor<Wrapper<MediaThumbnail>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(mediaThumbnailMapper).delete(captor.capture());
assertThat(captor.getValue().getSqlSegment()).containsIgnoringCase("episode_id");
verify(mediaThumbnailMapper).insert(thumbnail);
}
@Test @Test
void should_reject_object_key_that_escapes_the_storage_root() { void should_reject_object_key_that_escapes_the_storage_root() {
assertThatThrownBy(() -> storageService.fileFor("../../etc/passwd")) assertThatThrownBy(() -> storageService.fileFor("../../etc/passwd"))
@@ -3,6 +3,7 @@
package com.fruitshorts.worker; package com.fruitshorts.worker;
import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.media.dal.entity.MediaRendition; import com.fruitshorts.media.dal.entity.MediaRendition;
import com.fruitshorts.media.dal.entity.MediaThumbnail; import com.fruitshorts.media.dal.entity.MediaThumbnail;
@@ -17,11 +18,14 @@ import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -54,24 +58,22 @@ class TranscodeServiceTriggerTest {
} }
@Test @Test
void should_create_once_and_reuse_queued_task_when_retrying_successful_asset() { void should_reset_completed_task_in_place_and_reuse_it_when_triggered_again() {
TranscodeTask success = task(10L, TranscodeService.STATUS_SUCCESS); TranscodeTask success = task(10L, TranscodeService.STATUS_SUCCESS);
TranscodeTask queued = task(100L, TranscodeService.STATUS_QUEUED); TranscodeTask queued = task(100L, TranscodeService.STATUS_QUEUED);
given(taskMapper.selectOne(any())).willReturn(success, null, queued); queued.setId(10L);
doAnswer(invocation -> { given(taskMapper.selectOne(any())).willReturn(success, queued);
TranscodeTask inserted = invocation.getArgument(0); given(taskMapper.update(any(), any())).willReturn(1);
inserted.setId(100L);
return 1;
}).when(taskMapper).insert(any(TranscodeTask.class));
TranscodeTriggerRespVO first = transcodeService.trigger(ASSET_ID, "/files/video.mp4"); TranscodeTriggerRespVO first = transcodeService.trigger(ASSET_ID, "/files/video.mp4");
TranscodeTriggerRespVO second = transcodeService.trigger(ASSET_ID, "/files/video.mp4"); TranscodeTriggerRespVO second = transcodeService.trigger(ASSET_ID, "/files/video.mp4");
assertThat(first.getTaskId()).isEqualTo(100L); assertThat(first.getTaskId()).isEqualTo(10L);
assertThat(first.getStatus()).isEqualTo(TranscodeService.STATUS_QUEUED); assertThat(first.getStatus()).isEqualTo(TranscodeService.STATUS_QUEUED);
assertThat(second.getTaskId()).isEqualTo(100L); assertThat(second.getTaskId()).isEqualTo(10L);
assertThat(second.getStatus()).isEqualTo(TranscodeService.STATUS_QUEUED); assertThat(second.getStatus()).isEqualTo(TranscodeService.STATUS_QUEUED);
verify(taskMapper).insert(any(TranscodeTask.class)); verify(taskMapper, never()).insert(any(TranscodeTask.class));
verify(taskMapper, atLeastOnce()).update(any(), any());
} }
@Test @Test
@@ -111,17 +113,65 @@ class TranscodeServiceTriggerTest {
TranscodeTask running = task(41L, TranscodeService.STATUS_RUNNING); TranscodeTask running = task(41L, TranscodeService.STATUS_RUNNING);
running.setRetryCount(2); running.setRetryCount(2);
given(taskMapper.selectById(41L)).willReturn(running); given(taskMapper.selectById(41L)).willReturn(running);
String longReason = "ffmpeg 输出异常:" + "x".repeat(5000);
String status = transcodeService.fail( String status = transcodeService.fail(41L, longReason);
41L, "ffmpeg 退出码 1:Invalid data found");
assertThat(status).isEqualTo(TranscodeService.STATUS_FAILED); assertThat(status).isEqualTo(TranscodeService.STATUS_FAILED);
assertThat(running.getStatus()).isEqualTo(TranscodeService.STATUS_FAILED); assertThat(running.getStatus()).isEqualTo(TranscodeService.STATUS_FAILED);
assertThat(running.getRetryCount()).isEqualTo(3); assertThat(running.getRetryCount()).isEqualTo(3);
assertThat(running.getErrorMsg()).contains("Invalid data found"); assertThat(running.getErrorMsg()).contains("ffmpeg 输出异常");
assertThat(running.getErrorMsg()).contains("truncated");
assertThat(running.getErrorMsg()).contains("total=" + longReason.length());
assertThat(running.getErrorMsg().length()).isLessThanOrEqualTo(500);
verify(taskMapper).updateById(running); verify(taskMapper).updateById(running);
} }
@Test
void should_update_progress_only_for_running_task() {
given(taskMapper.update(any(), any())).willReturn(1);
transcodeService.updateProgress(51L, 46);
verify(taskMapper).update(any(), any());
}
@Test
void should_mark_stale_running_task_failed() {
given(taskMapper.update(any(), any())).willReturn(2);
int recovered = transcodeService.recoverStaleRunningTasks();
assertThat(recovered).isEqualTo(2);
verify(taskMapper).update(any(), any());
}
@Test
void should_persist_failed_status_and_tail_when_fail_final_is_called() {
given(taskMapper.update(any(), any())).willReturn(1);
String reason = "ffmpeg output head: " + "x".repeat(1000)
+ " UNIQUE_TAIL_DUPLICATE_KEY";
transcodeService.failFinal(61L, reason);
@SuppressWarnings("unchecked")
ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<TranscodeTask>>
captor = ArgumentCaptor.forClass(
com.baomidou.mybatisplus.core.conditions.Wrapper.class);
verify(taskMapper).update(any(), captor.capture());
@SuppressWarnings("unchecked")
LambdaUpdateWrapper<TranscodeTask> wrapper =
(LambdaUpdateWrapper<TranscodeTask>) captor.getValue();
assertThat(wrapper.getSqlSet())
.containsIgnoringCase("status")
.containsIgnoringCase("error_msg")
.containsIgnoringCase("finished_at");
Map<String, Object> params = wrapper.getParamNameValuePairs();
assertThat(params.values()).contains("FAILED");
assertThat(params.values())
.anyMatch(value -> String.valueOf(value).contains("UNIQUE_TAIL_DUPLICATE_KEY"));
}
private TranscodeTask task(Long id, String status) { private TranscodeTask task(Long id, String status) {
TranscodeTask task = new TranscodeTask(); TranscodeTask task = new TranscodeTask();
task.setId(id); task.setId(id);
@@ -15,7 +15,10 @@ import com.fruitshorts.media.service.transcode.TranscodeRunner;
import com.fruitshorts.media.service.transcode.TranscodeWorker; import com.fruitshorts.media.service.transcode.TranscodeWorker;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.List; import java.util.List;
@@ -28,6 +31,7 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
/** /**
@@ -35,6 +39,7 @@ import static org.mockito.Mockito.verify;
* *
* <p>用替身 `TranscodeRunner`,所以**不依赖 ffmpeg**;真实 ffmpeg 的验证在 ffmpeg smoke 测试里。 * <p>用替身 `TranscodeRunner`,所以**不依赖 ffmpeg**;真实 ffmpeg 的验证在 ffmpeg smoke 测试里。
*/ */
@ExtendWith(OutputCaptureExtension.class)
class TranscodeWorkerTest { class TranscodeWorkerTest {
private static final Long TASK_ID = 88L; private static final Long TASK_ID = 88L;
@@ -72,6 +77,7 @@ class TranscodeWorkerTest {
@Test @Test
void should_register_renditions_and_mark_asset_ready_when_transcode_succeeds() { void should_register_renditions_and_mark_asset_ready_when_transcode_succeeds() {
given(transcodeService.claimNext()).willReturn(queuedTask()); given(transcodeService.claimNext()).willReturn(queuedTask());
runner.progress = 45;
assertThat(worker.processOne()).isTrue(); assertThat(worker.processOne()).isTrue();
@@ -92,6 +98,7 @@ class TranscodeWorkerTest {
eq("/files/transcoded/88/poster.jpg"), eq(120)); eq("/files/transcoded/88/poster.jpg"), eq(120));
verify(episodeCompletionPort).markEpisodeReady( verify(episodeCompletionPort).markEpisodeReady(
12L, "/files/transcoded/88/master.m3u8", 120); 12L, "/files/transcoded/88/master.m3u8", 120);
verify(transcodeService).updateProgress(TASK_ID, 45);
verify(transcodeService, never()).fail(any(), anyString()); verify(transcodeService, never()).fail(any(), anyString());
} }
@@ -110,11 +117,12 @@ class TranscodeWorkerTest {
@Test @Test
void should_keep_main_transcode_successful_when_thumbnail_generation_fails() { void should_keep_main_transcode_successful_when_thumbnail_generation_fails() {
given(transcodeService.claimNext()).willReturn(queuedTask()); given(transcodeService.claimNext()).willReturn(queuedTask());
String thumbnailError = "tile 滤镜失败:" + "x".repeat(5000);
runner.thumbnail = new TranscodeRunner.ProducedThumbnail( runner.thumbnail = new TranscodeRunner.ProducedThumbnail(
"transcoded/88/thumbnails/thumbnails.vtt", "transcoded/88/thumbnails/thumbnails.vtt",
"transcoded/88/thumbnails/sprite_", "transcoded/88/thumbnails/sprite_",
1, 10, 1, 160, 0, 120, 120, 0L, 1, 10, 1, 160, 0, 120, 120, 0L,
MediaThumbnail.STATUS_FAILED, "tile 滤镜失败"); MediaThumbnail.STATUS_FAILED, thumbnailError);
worker.processOne(); worker.processOne();
@@ -128,38 +136,50 @@ class TranscodeWorkerTest {
assertThat(thumbnail.getValue().getStatus()) assertThat(thumbnail.getValue().getStatus())
.isEqualTo(MediaThumbnail.STATUS_FAILED); .isEqualTo(MediaThumbnail.STATUS_FAILED);
assertThat(thumbnail.getValue().getErrorMsg()).contains("tile"); assertThat(thumbnail.getValue().getErrorMsg()).contains("tile");
assertThat(thumbnail.getValue().getErrorMsg()).contains("truncated");
assertThat(thumbnail.getValue().getErrorMsg().length()).isLessThanOrEqualTo(500);
verify(mediaService).markReady(eq(ASSET_ID), anyString(), anyString(), eq(120)); verify(mediaService).markReady(eq(ASSET_ID), anyString(), anyString(), eq(120));
verify(transcodeService, never()).fail(any(), anyString()); verify(transcodeService, never()).fail(any(), anyString());
} }
@Test @Test
void should_keep_task_queued_and_record_reason_when_transcode_fails_before_max_retry() { void should_mark_task_failed_immediately_when_transcode_fails() {
TranscodeTask task = queuedTask(); TranscodeTask task = queuedTask();
task.setRetryCount(1); task.setRetryCount(1);
given(transcodeService.claimNext()).willReturn(task); given(transcodeService.claimNext()).willReturn(task);
given(transcodeService.fail(eq(TASK_ID), anyString())).willReturn(TranscodeService.STATUS_QUEUED);
runner.failure = new IllegalStateException("ffmpeg 退出码 1:Invalid data found"); runner.failure = new IllegalStateException("ffmpeg 退出码 1:Invalid data found");
worker.processOne(); worker.processOne();
verify(transcodeService).fail(eq(TASK_ID), contains("Invalid data found")); verify(transcodeService).failFinal(eq(TASK_ID), contains("Invalid data found"));
// 还会重试,所以媒资不能置 failed(仍期待下一次成功) verify(mediaService).markFailed(eq(ASSET_ID), contains("Invalid data found"));
verify(mediaService, never()).markFailed(any(), anyString());
verify(mediaService, never()).markReady(any(), anyString(), anyString(), any()); verify(mediaService, never()).markReady(any(), anyString(), anyString(), any());
} }
@Test @Test
void should_mark_asset_failed_when_retries_are_exhausted() { void should_mark_asset_failed_when_rendition_write_throws_duplicate_key() {
TranscodeTask task = queuedTask(); TranscodeTask task = queuedTask();
task.setRetryCount(2);
given(transcodeService.claimNext()).willReturn(task); given(transcodeService.claimNext()).willReturn(task);
given(transcodeService.fail(eq(TASK_ID), anyString())).willReturn(TranscodeService.STATUS_FAILED); doThrow(new IllegalStateException(
runner.failure = new IllegalStateException("ffmpeg 未产出播放列表"); "duplicate key uk_rendition"))
.when(transcodeService).succeed(eq(TASK_ID), any(), any());
worker.processOne(); worker.processOne();
verify(transcodeService).fail(eq(TASK_ID), contains("未产出播放列表")); verify(transcodeService).failFinal(eq(TASK_ID), contains("uk_rendition"));
verify(mediaService).markFailed(eq(ASSET_ID), contains("未产出播放列表")); verify(mediaService).markFailed(eq(ASSET_ID), contains("uk_rendition"));
}
@Test
void should_log_error_without_escaping_when_failure_persistence_throws(CapturedOutput output) {
given(transcodeService.claimNext()).willReturn(queuedTask());
runner.failure = new IllegalStateException("ffmpeg failed");
doThrow(new IllegalStateException("database unavailable"))
.when(transcodeService).failFinal(eq(TASK_ID), anyString());
worker.processOne();
assertThat(output).contains("失败状态落库失败").contains("database unavailable");
} }
@Test @Test
@@ -170,7 +190,7 @@ class TranscodeWorkerTest {
assertThat(worker.processOne()).isFalse(); assertThat(worker.processOne()).isFalse();
assertThat(runner.called).isFalse(); assertThat(runner.called).isFalse();
verify(transcodeService, never()).succeed(any(), any()); verify(transcodeService, never()).succeed(any(), any());
verify(transcodeService, never()).fail(any(), anyString()); verify(transcodeService, never()).failFinal(any(), anyString());
} }
private TranscodeTask queuedTask() { private TranscodeTask queuedTask() {
@@ -190,12 +210,16 @@ class TranscodeWorkerTest {
private TranscodeRequest request; private TranscodeRequest request;
private RuntimeException failure; private RuntimeException failure;
private ProducedThumbnail thumbnail; private ProducedThumbnail thumbnail;
private Integer progress;
private boolean called; private boolean called;
@Override @Override
public TranscodeResult run(TranscodeRequest request) { public TranscodeResult run(TranscodeRequest request) {
this.request = request; this.request = request;
this.called = true; this.called = true;
if (progress != null) {
request.progressListener().accept(progress);
}
if (failure != null) { if (failure != null) {
throw failure; throw failure;
} }
+12 -2
View File
@@ -57,6 +57,8 @@ INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, r
(5, 3, '剧集管理', 'episodes', 'episodes', 'views/contentManagement/EpisodeManagementView', '', '', 'episode:list', 'MENU', 0, 0, 2), (5, 3, '剧集管理', 'episodes', 'episodes', 'views/contentManagement/EpisodeManagementView', '', '', 'episode:list', 'MENU', 0, 0, 2),
(12, 3, '轮播图管理', 'banners', 'banners', 'views/contentManagement/BannerManagementView', '', '', 'banner:list', 'MENU', 0, 0, 3), (12, 3, '轮播图管理', 'banners', 'banners', 'views/contentManagement/BannerManagementView', '', '', 'banner:list', 'MENU', 0, 0, 3),
(13, 3, '类型管理', 'categories', 'categories', 'views/contentManagement/CategoryManagementView','', '', 'drama:list', 'MENU', 0, 0, 4), (13, 3, '类型管理', 'categories', 'categories', 'views/contentManagement/CategoryManagementView','', '', 'drama:list', 'MENU', 0, 0, 4),
(36, 3, '分类管理', 'category-dramas','category-dramas','views/contentManagement/CategoryDramaView', '', '', 'drama:list', 'MENU', 0, 0, 5),
(37, 3, '剧单管理', 'drama-playlists','drama-playlists','views/contentManagement/DramaPlaylistView', '', '', 'drama:list', 'MENU', 0, 0, 6),
(6, 0, '后台管理', '/system', 'System', '#', '/system/access', 'material-symbols:settings-rounded', 'system:role', 'CATALOG', 1, 0, 3), (6, 0, '后台管理', '/system', 'System', '#', '/system/access', 'material-symbols:settings-rounded', 'system:role', 'CATALOG', 1, 0, 3),
(7, 6, '角色权限配置', 'access', 'access', 'views/permission/RoleManagementView', '', '', 'system:role', 'MENU', 0, 0, 1), (7, 6, '角色权限配置', 'access', 'access', 'views/permission/RoleManagementView', '', '', 'system:role', 'MENU', 0, 0, 1),
(8, 6, '管理员用户列表','admin-users','admin-users', 'views/userManagement/AdminUsersView', '', '', 'system:admin-user','MENU', 0, 0, 2), (8, 6, '管理员用户列表','admin-users','admin-users', 'views/userManagement/AdminUsersView', '', '', 'system:admin-user','MENU', 0, 0, 2),
@@ -77,9 +79,17 @@ ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.
redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code, redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code,
type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort; type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort;
-- 转码设置菜单(挂在后台管理下,仅超级管理员可见)
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon,
permission_code, type, always_show, affix, sort) VALUES
(35, 6, '转码设置', 'transcode-settings', 'transcode-settings', 'views/systemManagement/TranscodeSettingsView', '', '', 'episode:upload', 'MENU', 0, 0, 7)
ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.title,
route_path = EXCLUDED.route_path, route_name = EXCLUDED.route_name, component = EXCLUDED.component,
redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code,
type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort;
-- K23:二级菜单统一只显示文字,历史图标值也一并清空。 -- K23:二级菜单统一只显示文字,历史图标值也一并清空。
UPDATE sys_menu SET icon = '' WHERE type = 'MENU'; UPDATE sys_menu SET icon = '' WHERE type = 'MENU';
-- ---------- 角色-权限 ---------- -- ---------- 角色-权限 ----------
INSERT INTO sys_role_permission (id, role_id, permission_id) INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT r.id * 1000000 + p.id, r.id, p.id FROM sys_role r CROSS JOIN sys_permission p WHERE r.code = 'super_admin' SELECT r.id * 1000000 + p.id, r.id, p.id FROM sys_role r CROSS JOIN sys_permission p WHERE r.code = 'super_admin'
@@ -112,7 +122,7 @@ ON CONFLICT DO NOTHING;
INSERT INTO sys_role_menu (id, role_id, menu_id) INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT r.id * 1000000 + m.id, r.id, m.id FROM sys_role r CROSS JOIN sys_menu m SELECT r.id * 1000000 + m.id, r.id, m.id FROM sys_role r CROSS JOIN sys_menu m
WHERE r.code IN ('content_operator','content_reviewer','data_analyst') AND m.id IN (1,2,3,4,5,12,13) WHERE r.code IN ('content_operator','content_reviewer','data_analyst') AND m.id IN (1,2,3,4,5,12,13,36,37)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- ---------- 管理员(密码均为 admin123) ---------- -- ---------- 管理员(密码均为 admin123) ----------
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more