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
68 changed files with 2383 additions and 52 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/categories` | 分类列表 |
| GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) |
| GET | `/app/search?keyword=&pageIndex=&pageSize=` | 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 |
| GET | `/app/search?keyword=&pageIndex=&pageSize=` | **匿名**。搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 |
> 卡片(`AppDramaCardVO`)统一新增 `language` 字段(取值如 `zh-CN` / `id`),
> 首页、发现页、分类、收藏、搜索等所有下发卡片的场景都带该字段,客户端可据此做语言筛选。
@@ -264,7 +264,11 @@ app.register.max-per-device-per-day
"accessible": true,
"accessType": "FREE",
"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...",
"unlockCoin": 0,
"previewSeconds": 0,
@@ -296,6 +300,7 @@ app.register.max-per-device-per-day
| POST | `/app/favorites/toggle` | 收藏/取消收藏,返回切换后状态 |
| GET | `/app/favorites` | 收藏分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 |
| DELETE | `/app/favorites/{dramaId}` | 取消收藏 |
| POST | `/app/favorites/remove` | 批量取消收藏,请求体 `{ "dramaIds": ["12", "15"] }` |
进度合并规则:同一集内只前进不后退;换集直接采纳新集;`finished` 只在接近片尾或客户端显式上报时置位。
这样用户换设备不会看到进度倒退。
@@ -354,6 +359,7 @@ app.register.max-per-device-per-day
"dramaCover": "/files/drama_cover/12.jpg",
"episodeNumber": 6,
"totalEpisodes": 68,
"viewCount": "1800",
"positionMs": "80000",
"durationMs": "120000",
"finished": false,
@@ -375,6 +381,7 @@ app.register.max-per-device-per-day
| `dramaCover` | string / null | 短剧封面 |
| `episodeNumber` | int / null | 当前续看集号 `ep_no` |
| `totalEpisodes` | int / null | 短剧总集数 |
| `viewCount` | Long(JSON 字符串)/ null | 短剧热度,取 `drama.view_count`。`playCount` 是用户自己的播放次数 |
### 1.6 离线下载(已实现)
@@ -543,6 +550,17 @@ GET /app/notices?pageIndex=1&pageSize=20
`expire_at` 为空或未过期的公告;按 `publish_at desc, id desc` 排序。
**表里没有数据时返回 `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`(登录)
```json
@@ -604,6 +622,8 @@ GET /app/notices?pageIndex=1&pageSize=20
| 角色分类 | PUT · DELETE | `/admin/role-categories/{id}` | `system:role-category` |
| 系统配置 | GET | `/admin/configs` | `system:role` / `system:menu` |
| 系统配置 | 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/dicts/drama-category` · `/content-status` | `drama:list` |
@@ -1074,6 +1094,194 @@ GET /app/notices?pageIndex=1&pageSize=20
**筛选与错误码**:`title` 必填(1–128);`content` 最长 2000;公告不存在 → `200002 公告不存在`;
`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. 契约与实现状态对照
> 最后更新:2026-10-16(`develop` 分支)。未完成项汇总见仓库根 `README.md`「尚未完成」。
+150
View File
@@ -25751,3 +25751,153 @@ $ mvn -q -DskipTests=false test \
提交:`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.Result;
import com.fruitshorts.admin.service.AppSessionSystemService;
import com.fruitshorts.admin.vo.AppUserWatchHistoryVO;
import com.fruitshorts.content.dal.entity.Drama;
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.security.SecurityUtils;
import com.fruitshorts.playback.dal.entity.WatchHistory;
import com.fruitshorts.user.dal.entity.AppUserSession;
import com.fruitshorts.playback.service.WatchProgressService;
import com.fruitshorts.user.dal.entity.AppUserBanRecord;
import com.fruitshorts.user.service.AppUserAdminService;
@@ -44,6 +46,7 @@ public class AppUserAdminController {
private final WatchProgressService watchProgressService;
private final DramaService dramaService;
private final EpisodeService episodeService;
private final AppSessionSystemService sessionSystem;
@Operation(summary = "App 用户分页查询")
@GetMapping("/page")
@@ -83,11 +86,15 @@ public class AppUserAdminController {
Map<Long, DramaEpisode> episodes = episodeService
.listByIds(rows.stream().map(WatchHistory::getEpisodeId).distinct().toList())
.stream().collect(Collectors.toMap(DramaEpisode::getId, item -> item, (a, b) -> a));
return Result.ok(rows.stream().map(row -> toVo(row, dramas, episodes)).toList());
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,
Map<Long, DramaEpisode> episodes) {
Map<Long, DramaEpisode> episodes, AppUserSession session) {
AppUserWatchHistoryVO vo = new AppUserWatchHistoryVO();
vo.setId(row.getId());
vo.setDramaId(row.getDramaId());
@@ -100,6 +107,10 @@ public class AppUserAdminController {
long position = row.getPositionMs() == null ? 0L : row.getPositionMs();
long duration = row.getDurationMs() == null ? 0L : row.getDurationMs();
vo.setFinished(duration > 0 && position + 1000L >= duration);
if (session != null) {
vo.setPlatform(session.getPlatform());
vo.setOsVersion(session.getOsVersion());
}
Drama drama = dramas.get(row.getDramaId());
if (drama != null) {
vo.setDramaTitle(drama.getTitle());
@@ -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,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,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);
}
}
@@ -17,6 +17,7 @@ import com.fruitshorts.content.enums.ContentStatus;
import com.fruitshorts.playback.dal.entity.WatchHistory;
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
import com.fruitshorts.user.dal.entity.AppUser;
import com.fruitshorts.user.dal.entity.AppUserSession;
import com.fruitshorts.user.dal.mapper.AppUserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -46,6 +47,7 @@ public class WatchHistoryAdminService {
private final AppUserMapper appUserMapper;
private final DramaMapper dramaMapper;
private final DramaEpisodeMapper dramaEpisodeMapper;
private final AppSessionSystemService sessionSystem;
/** 按后台筛选条件分页查询观看记录。 */
public PageResult<AdminWatchHistoryVO> page(WatchHistoryQueryReqVO req) {
@@ -125,10 +127,13 @@ public class WatchHistoryAdminService {
Map<Long, AppUser> users = loadUsers(rows);
Map<Long, Drama> dramas = loadDramas(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());
for (WatchHistory row : rows) {
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;
}
@@ -161,7 +166,7 @@ public class WatchHistoryAdminService {
}
private AdminWatchHistoryVO toVo(WatchHistory row, AppUser user, Drama drama,
DramaEpisode episode) {
DramaEpisode episode, AppUserSession session) {
AdminWatchHistoryVO vo = new AdminWatchHistoryVO();
vo.setId(row.getId());
vo.setUserId(row.getUserId());
@@ -172,6 +177,10 @@ public class WatchHistoryAdminService {
vo.setSource(row.getSource());
vo.setDeviceId(row.getDeviceId());
vo.setWatchedAt(row.getWatchedAt());
if (session != null) {
vo.setPlatform(session.getPlatform());
vo.setOsVersion(session.getOsVersion());
}
long position = row.getPositionMs() == null ? 0L : row.getPositionMs();
long duration = row.getDurationMs() == null ? 0L : row.getDurationMs();
vo.setFinished(duration > 0L && position + FINISHED_TOLERANCE_MS >= duration);
@@ -29,5 +29,9 @@ public class AdminWatchHistoryVO {
private Boolean finished;
private String source;
private String deviceId;
/** ANDROID / IOS / WEB,来自该次设备的登录会话。 */
private String platform;
/** 系统版本,例如 Android 16。 */
private String osVersion;
private LocalDateTime watchedAt;
}
@@ -28,5 +28,9 @@ public class AppUserWatchHistoryVO {
private Boolean finished;
private String source;
private String deviceId;
/** ANDROID / IOS / WEB,来自该次设备的登录会话。 */
private String platform;
/** 系统版本。 */
private String osVersion;
private LocalDateTime watchedAt;
}
@@ -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;
}
}
@@ -58,7 +58,7 @@ class WatchHistoryAdminServiceTest {
dramaMapper = mock(DramaMapper.class);
dramaEpisodeMapper = mock(DramaEpisodeMapper.class);
service = new WatchHistoryAdminService(watchHistoryMapper, appUserMapper, dramaMapper,
dramaEpisodeMapper);
dramaEpisodeMapper, mock(AppSessionSystemService.class));
}
@Test
@@ -68,7 +68,7 @@ public class AppAuthController {
@Operation(summary = "刷新令牌(旧 refresh token 立即失效)")
@PostMapping("/refresh")
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 时吊销全部。 */
@@ -9,6 +9,7 @@ import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.playback.service.FavoriteService;
import com.fruitshorts.playback.vo.FavoriteRemoveReqVO;
import com.fruitshorts.playback.vo.FavoriteToggleReqVO;
import io.swagger.v3.oas.annotations.Operation;
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.RestController;
/** 客户端搜索:按标题或简介模糊匹配已上线短剧。 */
/** 客户端搜索:按标题或简介模糊匹配已上线短剧。不需要登录。 */
@Tag(name = "客户端-搜索")
@RestController
@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.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.media.dal.entity.MediaRendition;
import com.fruitshorts.media.service.PlaybackSigner;
import com.fruitshorts.media.service.ThumbnailUrlResolver;
import com.fruitshorts.media.service.TranscodeService;
@@ -147,6 +148,30 @@ class PlaybackServiceTest {
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) {
DramaEpisode episode = new DramaEpisode();
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.WatchProgressMapper;
import com.fruitshorts.playback.vo.ProgressRespVO;
import com.fruitshorts.playback.vo.ProgressSaveReqVO;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
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.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -108,6 +110,30 @@ class WatchProgressServiceTest {
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) {
Drama drama = new Drama();
drama.setId(id);
@@ -40,11 +40,15 @@ public class SecurityConfig {
"/app/categories/**",
"/app/banners/**",
"/app/dramas/**",
"/app/search",
"/app/episodes/*/play",
"/app/config/**",
/* 启动配置与公告:客户端登录前就要拿(POST /app/creator/apply 等仍要登录) */
"/app/config",
"/app/notices",
/* 服务条款与隐私协议:登录前就要能打开,按语言返回 HTML */
"/privacy/**",
"/terms/**",
/* 埋点上报:游客浏览也要记录,匿名可访问 */
"/app/events",
/* 媒资:封面与视频用 <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;
}
}
@@ -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;
}
@@ -4,6 +4,17 @@ package com.fruitshorts.media.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
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> {
/**
* 物理删除旧产物。唯一索引 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);
}
@@ -217,15 +217,8 @@ public class TranscodeService {
public void succeed(Long taskId, List<MediaRendition> renditions,
MediaThumbnail thumbnail) {
TranscodeTask task = getExisting(taskId);
var renditionDelete = Wrappers.<MediaRendition>lambdaQuery();
if (task.getEpisodeId() != null && task.getEpisodeId() > 0) {
renditionDelete.and(wrapper -> wrapper
.eq(MediaRendition::getMediaAssetId, task.getMediaAssetId())
.or().eq(MediaRendition::getEpisodeId, task.getEpisodeId()));
} else {
renditionDelete.eq(MediaRendition::getMediaAssetId, task.getMediaAssetId());
}
mediaRenditionMapper.delete(renditionDelete);
// 唯一索引不含 deleted,旧产物必须物理删除,否则重转时插入会撞 uk_rendition
mediaRenditionMapper.deletePhysically(task.getMediaAssetId(), task.getEpisodeId());
for (MediaRendition rendition : renditions) {
rendition.setMediaAssetId(task.getMediaAssetId());
rendition.setDramaId(task.getDramaId());
@@ -4,6 +4,32 @@ package com.fruitshorts.playback.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
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> {
/** 含逻辑删除行。唯一索引不区分 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.mapper.PlaybackTicketMapper;
import com.fruitshorts.playback.vo.PlayRespVO;
import com.fruitshorts.playback.vo.PlayTrackVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -87,6 +88,7 @@ public class PlaybackService {
vo.setAccessible(true);
vo.setAccessType(entitled && !free ? ACCESS_ENTITLED : ACCESS_FREE);
vo.setPlayUrl(signedUrl);
vo.setTracks(tracksOf(episode.getId(), signedUrl));
if (userId != null) {
recordTicket(userId, drama.getId(), episode.getId(), signedUrl, deviceId, clientIp);
@@ -115,6 +117,29 @@ public class PlaybackService {
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,
String deviceId, String clientIp) {
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.ProgressSaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -58,30 +59,23 @@ public class WatchProgressService {
boolean finished = Boolean.TRUE.equals(req.getFinished())
|| (duration > 0 && position + FINISH_THRESHOLD_MS >= duration);
WatchProgress existing = progressMapper.selectOne(Wrappers.<WatchProgress>lambdaQuery()
.eq(WatchProgress::getUserId, userId)
.eq(WatchProgress::getDramaId, drama.getId())
.last("limit 1"));
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);
LoadedProgress loaded = loadForSave(userId, drama.getId(), episode, position, duration,
finished, req);
WatchProgress existing = loaded.progress();
if (loaded.created()) {
appendHistory(userId, drama.getId(), episode.getId(), position, duration,
progress.getSource(), progress.getDeviceId());
// updated_at 由触发器维护,回读一次,保证响应与后续 recent 列表口径一致
return toResp(progressMapper.selectById(progress.getId()));
existing.getSource(), existing.getDeviceId());
return toResp(progressMapper.selectById(existing.getId()));
}
boolean hidden = existing.getDeleted() != null && existing.getDeleted() != 0;
boolean sameEpisode = existing.getEpisodeId() != null
&& 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) {
// 换集:直接采纳新集与新的播放位置
existing.setEpisodeId(episode.getId());
@@ -103,10 +97,52 @@ public class WatchProgressService {
}
existing.setDeviceId(req.getDeviceId() == null ? "" : req.getDeviceId());
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()));
}
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) {
WatchProgress progress = progressMapper.selectOne(Wrappers.<WatchProgress>lambdaQuery()
.eq(WatchProgress::getUserId, userId)
@@ -185,6 +221,7 @@ public class WatchProgressService {
vo.setDramaTitle(drama.getTitle());
vo.setDramaCover(drama.getCoverUrl());
vo.setTotalEpisodes(drama.getTotalEpisodes());
vo.setViewCount(drama.getViewCount());
}
DramaEpisode episode = episodeById.get(progress.getEpisodeId());
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 List<String> qualities;
/** 各档签名地址。AUTO 是主播放列表,其余是单档 HLS。 */
private List<PlayTrackVO> tracks;
/** 雪碧图 WebVTT 时间轴地址;没有产物时为 null */
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 totalEpisodes;
/** 短剧热度,取 drama.view_count。不是用户自己的播放次数。 */
private Long viewCount;
private Long positionMs;
private Long durationMs;
private Boolean finished;
+5
View File
@@ -21,5 +21,10 @@
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-framework</artifactId>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.22.0</version>
</dependency>
</dependencies>
</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,旧的立即失效。 */
@Transactional(rollbackFor = Exception.class)
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()
.eq(AppUserSession::getRefreshTokenHash, hasher.sha256(refreshToken))
.last("limit 1"));
@@ -313,6 +319,12 @@ public class AppAuthService {
String nextRefreshToken = hasher.newRefreshToken();
session.setRefreshTokenHash(hasher.sha256(nextRefreshToken));
if (StringUtils.hasText(platform)) {
session.setPlatform(platform);
}
if (StringUtils.hasText(osVersion)) {
session.setOsVersion(osVersion);
}
session.setLastActiveAt(now);
session.setExpireAt(now.plus(tokenProvider.appRefreshTtl()));
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.AppUserBanRecord;
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.AppUserCredentialMapper;
import com.fruitshorts.user.dal.mapper.AppUserMapper;
import com.fruitshorts.user.dal.mapper.AppUserSessionMapper;
import com.fruitshorts.user.enums.AppUserStatus;
import com.fruitshorts.user.vo.AppUserQueryReqVO;
import com.fruitshorts.user.vo.AppUserAdminRespVO;
@@ -26,7 +28,9 @@ import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 后台用户管理:检索、详情、状态处置。
@@ -40,6 +44,7 @@ public class AppUserAdminService {
private final AppUserMapper appUserMapper;
private final AppUserCredentialMapper credentialMapper;
private final AppUserBanRecordMapper banRecordMapper;
private final AppUserSessionMapper sessionMapper;
private final AppAuthService authService;
public PageResult<AppUserAdminRespVO> page(AppUserQueryReqVO req) {
@@ -74,7 +79,9 @@ public class AppUserAdminService {
req.getRegisterSource())
.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) {
@@ -82,7 +89,9 @@ public class AppUserAdminService {
if (user == null) {
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());
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 {
private Long id;
/** 最近一次登录的系统:ANDROID / IOS / WEB。 */
private String platform;
/** 最近一次登录的系统版本。 */
private String osVersion;
}
@@ -11,4 +11,10 @@ public class RefreshReqVO {
@NotBlank(message = "缺少 refreshToken")
private String refreshToken;
/** 客户端系统:ANDROID / IOS / WEB。刷新时顺带更新,避免必须重新登录才有记录。 */
private String platform;
/** 系统版本,例如 Android 16。 */
private String osVersion;
}
@@ -211,15 +211,7 @@ class TranscodeClaimAndStorageTest {
transcodeService.succeed(6L, java.util.List.of(rendition));
@SuppressWarnings("unchecked")
ArgumentCaptor<Wrapper<MediaRendition>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(mediaRenditionMapper).delete(captor.capture());
assertThat(captor.getValue().getSqlSegment())
.containsIgnoringCase("media_asset_id")
.containsIgnoringCase("episode_id")
.containsIgnoringCase("OR");
assertThat(captor.getValue().getSqlSegment())
.doesNotContainIgnoringCase("AND (");
verify(mediaRenditionMapper).deletePhysically(99L, 31L);
verify(mediaRenditionMapper).insert(rendition);
}
+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),
(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),
(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),
(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),
@@ -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,
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:二级菜单统一只显示文字,历史图标值也一并清空。
UPDATE sys_menu SET icon = '' WHERE type = 'MENU';
-- ---------- 角色-权限 ----------
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'
@@ -112,7 +122,7 @@ ON CONFLICT DO NOTHING;
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
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;
-- ---------- 管理员(密码均为 admin123) ----------
@@ -0,0 +1,21 @@
-- Author: fruit.com.cn
-- 后台「分类管理」菜单与分类-短剧关联页(幂等,仅提交文件,由发布流程执行)
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon,
permission_code, type, always_show, affix, sort) VALUES
(36, 3, '分类管理', 'category-dramas', 'category-dramas',
'views/contentManagement/CategoryDramaView', '', '',
'drama:list', 'MENU', 0, 0, 5)
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;
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
WHERE r.code IN ('super_admin', 'content_operator', 'content_reviewer', 'data_analyst')
AND m.id = 36
ON CONFLICT DO NOTHING;
@@ -0,0 +1,31 @@
-- Author: fruit.com.cn
-- 运营分类(与「类型」drama_category 是两个概念):分类可自定义增删,并关联多部短剧
-- 幂等,仅提交文件,由发布流程执行
CREATE TABLE IF NOT EXISTS drama_classification (
id BIGINT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
sort INTEGER NOT NULL DEFAULT 0,
status SMALLINT NOT NULL DEFAULT 1,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_drama_classification_name ON drama_classification (name);
COMMENT ON TABLE drama_classification IS '运营分类;与短剧类型(drama_category)相互独立';
CREATE TABLE IF NOT EXISTS drama_classification_drama (
id BIGINT PRIMARY KEY,
classification_id BIGINT NOT NULL,
drama_id BIGINT NOT NULL,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_classification_drama
ON drama_classification_drama (classification_id, drama_id);
CREATE INDEX IF NOT EXISTS idx_classification_drama_drama
ON drama_classification_drama (drama_id);
COMMENT ON TABLE drama_classification_drama IS '分类与短剧的关联关系';
DROP TRIGGER IF EXISTS trg_drama_classification_updated_at ON drama_classification;
CREATE TRIGGER trg_drama_classification_updated_at
BEFORE UPDATE ON drama_classification
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
@@ -0,0 +1,21 @@
-- Author: fruit.com.cn
-- 后台「剧单管理」菜单(幂等,仅提交文件,由发布流程执行)
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon,
permission_code, type, always_show, affix, sort) VALUES
(37, 3, '剧单管理', 'drama-playlists', 'drama-playlists',
'views/contentManagement/DramaPlaylistView', '', '',
'drama:list', 'MENU', 0, 0, 6)
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;
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
WHERE r.code IN ('super_admin', 'content_operator', 'content_reviewer', 'data_analyst')
AND m.id = 37
ON CONFLICT DO NOTHING;
+31
View File
@@ -0,0 +1,31 @@
-- Author: fruit.com.cn
-- 剧单:运营编排的短剧集合,归属某个分类,并可关联多部短剧
-- 幂等,仅提交文件,由发布流程执行
CREATE TABLE IF NOT EXISTS drama_playlist (
id BIGINT PRIMARY KEY,
classification_id BIGINT NOT NULL DEFAULT 0,
name VARCHAR(64) NOT NULL,
sort INTEGER NOT NULL DEFAULT 0,
status SMALLINT NOT NULL DEFAULT 1,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_drama_playlist_name ON drama_playlist (name);
CREATE INDEX IF NOT EXISTS idx_drama_playlist_classification ON drama_playlist (classification_id);
COMMENT ON TABLE drama_playlist IS '剧单;归属分类,可关联多部短剧';
CREATE TABLE IF NOT EXISTS drama_playlist_drama (
id BIGINT PRIMARY KEY,
playlist_id BIGINT NOT NULL,
drama_id BIGINT NOT NULL,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_playlist_drama ON drama_playlist_drama (playlist_id, drama_id);
CREATE INDEX IF NOT EXISTS idx_playlist_drama_drama ON drama_playlist_drama (drama_id);
COMMENT ON TABLE drama_playlist_drama IS '剧单与短剧的关联关系';
DROP TRIGGER IF EXISTS trg_drama_playlist_updated_at ON drama_playlist;
CREATE TRIGGER trg_drama_playlist_updated_at
BEFORE UPDATE ON drama_playlist
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
@@ -0,0 +1,18 @@
-- Author: fruit.com.cn
-- 剧单分类直接关联短剧(不再有「剧单」这一层)
-- 幂等,仅提交文件,由发布流程执行
CREATE TABLE IF NOT EXISTS drama_playlist_category_drama (
id BIGINT PRIMARY KEY,
category_id BIGINT NOT NULL,
drama_id BIGINT NOT NULL,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_playlist_category_drama
ON drama_playlist_category_drama (category_id, drama_id);
CREATE INDEX IF NOT EXISTS idx_playlist_category_drama_drama
ON drama_playlist_category_drama (drama_id);
COMMENT ON TABLE drama_playlist_category_drama IS '剧单分类与短剧的关联关系';
DROP TABLE IF EXISTS drama_playlist_drama;
DROP TABLE IF EXISTS drama_playlist;
@@ -0,0 +1,31 @@
-- Author: fruit.com.cn
-- 剧单专属分类(与「分类管理」的运营分类无关),并调整剧单归属字段
-- 幂等,仅提交文件,由发布流程执行
CREATE TABLE IF NOT EXISTS drama_playlist_category (
id BIGINT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
sort INTEGER NOT NULL DEFAULT 0,
status SMALLINT NOT NULL DEFAULT 1,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_drama_playlist_category_name
ON drama_playlist_category (name);
COMMENT ON TABLE drama_playlist_category IS '剧单专属分类;与运营分类(drama_classification)相互独立';
ALTER TABLE drama_playlist DROP COLUMN IF EXISTS classification_id;
ALTER TABLE drama_playlist ADD COLUMN IF NOT EXISTS playlist_category_id BIGINT NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_drama_playlist_category
ON drama_playlist (playlist_category_id);
COMMENT ON COLUMN drama_playlist.playlist_category_id IS '剧单分类;0 表示未分类';
DROP TRIGGER IF EXISTS trg_drama_playlist_category_updated_at ON drama_playlist_category;
CREATE TRIGGER trg_drama_playlist_category_updated_at
BEFORE UPDATE ON drama_playlist_category
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- Author: fruit.com.cn
-- 剧单分类增加描述字段(幂等)
ALTER TABLE drama_playlist_category ADD COLUMN IF NOT EXISTS description VARCHAR(255) NOT NULL DEFAULT '';
COMMENT ON COLUMN drama_playlist_category.description IS '分类描述';
@@ -0,0 +1,20 @@
-- Author: fruit.com.cn
-- 后台「转码设置」独立菜单(幂等,仅提交文件,由发布流程执行)
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;
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
WHERE r.code = 'super_admin' AND m.id = 35
ON CONFLICT DO NOTHING;
+40
View File
@@ -0,0 +1,40 @@
-- Author: fruit.com.cn
-- 服务条款与隐私协议:三语正文 + 后台菜单
CREATE TABLE IF NOT EXISTS legal_document (
id BIGINT PRIMARY KEY,
doc_code VARCHAR(16) NOT NULL,
lang VARCHAR(8) NOT NULL,
title VARCHAR(128) NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_legal_document_code_lang ON legal_document (doc_code, lang);
COMMENT ON TABLE legal_document IS '服务条款与隐私协议';
INSERT INTO legal_document (id, doc_code, lang, title, content) VALUES
(910001, 'terms', 'zh', '', '本页由水果短剧发布,可在管理后台用 Markdown 修改。'),
(910002, 'terms', 'en', '', 'This page is published by FruitShorts and can be edited as Markdown in the admin console.'),
(910003, 'terms', 'id', '', 'Halaman ini dipublikasikan oleh FruitShorts dan dapat diubah sebagai Markdown di konsol admin.'),
(910004, 'privacy', 'zh', '', '本页由水果短剧发布,可在管理后台用 Markdown 修改。'),
(910005, 'privacy', 'en', '', 'This page is published by FruitShorts and can be edited as Markdown in the admin console.'),
(910006, 'privacy', 'id', '', 'Halaman ini dipublikasikan oleh FruitShorts dan dapat diubah sebagai Markdown di konsol admin.')
ON CONFLICT (doc_code, lang) DO NOTHING;
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon,
permission_code, type, always_show, affix, sort) VALUES
(38, 6, '协议设置', 'legal-documents', 'legal-documents',
'views/systemManagement/LegalDocumentView', '', '',
'system:role', 'MENU', 0, 0, 8)
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;
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
WHERE r.code = 'super_admin' AND m.id = 38
ON CONFLICT DO NOTHING;
+58 -1
View File
@@ -869,9 +869,64 @@ CREATE INDEX IF NOT EXISTS idx_audit_log_biz ON audit_log (biz_type, biz_id, cre
COMMENT ON TABLE audit_log IS '业务对象变更审计(支付、权益、定价等敏感操作)';
-- ============================================================
-- 九、updated_at 触发器(与已有表保持一致)
-- 八之二、运营分类(与短剧类型 drama_category 相互独立)
-- ============================================================
CREATE TABLE IF NOT EXISTS drama_classification (
id BIGINT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
sort INTEGER NOT NULL DEFAULT 0,
status SMALLINT NOT NULL DEFAULT 1,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_drama_classification_name ON drama_classification (name);
COMMENT ON TABLE drama_classification IS '运营分类;与短剧类型(drama_category)相互独立';
CREATE TABLE IF NOT EXISTS drama_classification_drama (
id BIGINT PRIMARY KEY,
classification_id BIGINT NOT NULL,
drama_id BIGINT NOT NULL,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_classification_drama
ON drama_classification_drama (classification_id, drama_id);
CREATE INDEX IF NOT EXISTS idx_classification_drama_drama
ON drama_classification_drama (drama_id);
COMMENT ON TABLE drama_classification_drama IS '分类与短剧的关联关系';
-- ============================================================
-- 八之三、剧单(剧单有自己的一套分类,与运营分类无关)
-- ============================================================
CREATE TABLE IF NOT EXISTS drama_playlist_category (
id BIGINT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
description VARCHAR(255) NOT NULL DEFAULT '',
sort INTEGER NOT NULL DEFAULT 0,
status SMALLINT NOT NULL DEFAULT 1,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_drama_playlist_category_name
ON drama_playlist_category (name);
COMMENT ON TABLE drama_playlist_category IS '剧单专属分类;与运营分类(drama_classification)相互独立';
CREATE TABLE IF NOT EXISTS drama_playlist_category_drama (
id BIGINT PRIMARY KEY,
category_id BIGINT NOT NULL,
drama_id BIGINT NOT NULL,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_playlist_category_drama
ON drama_playlist_category_drama (category_id, drama_id);
CREATE INDEX IF NOT EXISTS idx_playlist_category_drama_drama
ON drama_playlist_category_drama (drama_id);
COMMENT ON TABLE drama_playlist_category_drama IS '剧单分类与短剧的关联关系';
-- ============================================================
-- 九、updated_at 触发器(与已有表保持一致)
-- ============================================================
DO $$
DECLARE
t TEXT;
@@ -885,6 +940,8 @@ BEGIN
'reconcile_task', 'creator', 'creator_document', 'content_submission',
'creator_stat_daily', 'creator_settlement',
'transcode_task', 'media_rendition', 'media_thumbnail',
'drama_classification',
'drama_playlist_category',
'ops_notice', 'ops_daily_stat'
]
LOOP
+11
View File
@@ -127,6 +127,17 @@ CREATE TABLE IF NOT EXISTS sys_config (
CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_config_key ON sys_config (config_key);
COMMENT ON TABLE sys_config IS '系统配置';
CREATE TABLE IF NOT EXISTS legal_document (
id BIGINT PRIMARY KEY,
doc_code VARCHAR(16) NOT NULL, -- terms / privacy
lang VARCHAR(8) NOT NULL, -- zh / en / id
title VARCHAR(128) NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_legal_document_code_lang ON legal_document (doc_code, lang);
COMMENT ON TABLE legal_document IS '服务条款与隐私协议';
CREATE TABLE IF NOT EXISTS sys_operation_log (
id BIGINT PRIMARY KEY,
admin_user_id BIGINT NOT NULL DEFAULT 0,