13 Commits
Author SHA1 Message Date
jianghaifeng 3df66c2f4c refactor(media): 抽出 MediaStorage 接口为对象存储切换铺路
backend-ci / test (push) Failing after 5m7s
本地磁盘实现 MediaStorageService 改为实现接口,五处注入点
改为依赖接口;行为不变。后期新增 S3 实现以配置切换即可。
2026-11-05 11:31:31 +08:00
jianghaifeng 32ada4c2bf feat(content): 短剧支持审核通过并上线一步流转
backend-ci / test (push) Failing after 5m8s
新增 APPROVE_PUBLISH 动作与 approve-publish 接口,草稿/审核中/已下线
可直接流转为已上线,事务内同步上线草稿分集并记录一条审核记录,
后台审核操作由三步减为一步。
2026-11-05 10:55:52 +08:00
Zhou Weidong eba7d400e1 fix(deploy): 看门狗飞书告警中文乱码改 UTF-8 发送
backend-ci / test (push) Failing after 4m39s
PS5 Invoke-RestMethod 对字符串 body 默认按 Latin-1 编码导致中文变 ???,
改为手动转 UTF-8 字节数组并声明 charset=utf-8。
2026-11-02 14:52:41 +08:00
Zhou Weidong 3faeba0589 fix(deploy): 补充 CORS 允许来源避免跨域被拒
app-api 增加 frostgiant 域名(含 nginx 非标准 80 端口)与公网 IP 来源;
admin-api 增加公网/局域网 IP 与 admin.frostgiant.vip。
Spring setAllowedOrigins 为精确字符串匹配,来源必须逐条列全。
2026-11-02 14:52:41 +08:00
Zhou Weidong 8a5a381b44 feat(catalog): 新增分类推荐榜快照接口
backend-ci / test (push) Failing after 4m45s
2026-11-02 14:45:43 +08:00
jianghaifeng afd8d00752 feat(catalog): 卡片下发完整简介不再截断
backend-ci / test (push) Canceled after 0s
2026-10-30 22:53:22 +08:00
jianghaifeng 958de5ef22 feat(config): /app/config 下发登录页背景地址
backend-ci / test (push) Canceled after 0s
2026-10-30 17:14:46 +08:00
jianghaifeng f2103f6604 feat(cover): 短剧新增横版封面字段并在进度接口下发
backend-ci / test (push) Canceled after 0s
- Drama/DramaSaveReqVO/DramaRespVO 新增 horizontalCover,打通后台上传链路
- AppDramaCardVO/AppDramaDetailVO 新增 coverLandscape
- ProgressRespVO 新增 dramaCoverLandscape,继续观看横幅可直接取横版图
2026-10-29 17:32:49 +08:00
jianghaifeng 0d4d510aee feat(content): 标签增加语义码并随卡片下发
backend-ci / test (push) Canceled after 0s
标签名称会随语言和运营文案变化,客户端按名称判断板块(如推荐页「上新」
横滑)一改名就失效。content_tag 新增 code 列并在后台维护,
卡片与详情以 tagCode 下发,已有「上新 / 热剧 / 新剧 / 热播」一并回填。
2026-10-29 15:45:34 +08:00
Zhou Weidong 4d796089a0 ops(grafana): 告警规则入库(服务下线/响应慢/磁盘满)
backend-ci / test (push) Failing after 4m38s
2026-10-29 10:53:07 +08:00
Zhou Weidong f71498339c ci: 新增 Gitea Actions 流水线(测试/分析/构建门禁)
backend-ci / test (push) Failing after 7m31s
2026-10-29 10:47:44 +08:00
Zhou Weidong e4fe82283c docs: 更新第一阶段进度表与尚未完成清单(C2/C3 标记已实现) 2026-10-29 10:47:44 +08:00
Zhou Weidong 26102332ac fix(deploy): 看门狗改由用户会话任务启动 Docker Desktop
以 SYSTEM 身份直接 Start-Process Docker Desktop.exe 会命中 WSL_E_LOCAL_SYSTEM_NOT_SUPPORTED,把 docker-desktop 发行版搞成僵死状态(VHD 已挂载无法终止),导致登录用户自己也起不来。改为触发以登录用户身份运行的 FruitShorts-DockerDesktop 计划任务(先停再起,规避 IgnoreNew)。
2026-10-29 10:11:02 +08:00
53 changed files with 1223 additions and 61 deletions

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
# Author: fruit.com.cn
# FruitServer 后端 CI:编译 + 测试门禁
name: backend-ci
on:
push:
branches: [develop, main]
pull_request:
branches: [develop, main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
cache: maven
- name: 编译 + 单测
run: mvn -B -T 1C test -DfailIfNoTests=false
- name: 打包(跳过测试)
if: success()
run: mvn -B package -DskipTests -q
+10 -10
View File
@@ -191,7 +191,7 @@ java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar
## 第一阶段实现进度 ## 第一阶段实现进度
> 最后更新:2026-10-16(后端 `develop` 分支 `4418a84`)。**未完成清单见文末「尚未完成」。** > 最后更新:2026-10-24(拉取 `develop` 最新代码后)。**未完成清单见文末「尚未完成」。**
| 模块 | 状态 | 说明 | | 模块 | 状态 | 说明 |
| --- | --- | --- | | --- | --- | --- |
@@ -200,11 +200,11 @@ java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar
| 03 内容发现与详情 | 已实现 | 首页/发现/分类/详情;内容分级、可播区域、版权窗口、标签字段 | | 03 内容发现与详情 | 已实现 | 首页/发现/分类/详情;内容分级、可播区域、版权窗口、标签字段 |
| 04 播放器与播放控制 | 已实现 | 播放票据 + 签名地址 + 进度;多码率 HLS 由转码 worker 产出(见 10) | | 04 播放器与播放控制 | 已实现 | 播放票据 + 签名地址 + 进度;多码率 HLS 由转码 worker 产出(见 10) |
| 05 记录、收藏与下载 | 已实现 | 进度、观看流水、收藏、离线下载授权与任务 | | 05 记录、收藏与下载 | 已实现 | 进度、观看流水、收藏、离线下载授权与任务 |
| 06 评论、点赞与分享 | 已实现 | 评论(游标翻页/审核/删除)、点赞、举报、敏感词;**分享埋点待 C3** | | 06 评论、点赞与分享 | 已实现 | 评论(游标翻页/审核/删除)、点赞、举报、敏感词、分享 |
| 07 创作者中心 | 已实现 | **在后台管理(Web 端 `ReelShortWeb`)**:工作台、内容编辑、分片上传与断点续传、提交审核与上下架、分集收费与试看、数据看板;不含提现/结算。**App 客户端不提供创作者中心**(口径 2026-10-16) | | 07 创作者中心 | 已实现 | **在后台管理(Web 端 `ReelShortWeb`)**:工作台、内容编辑、分片上传与断点续传、提交审核与上下架、分集收费与试看、数据看板;不含提现/结算。**App 客户端不提供创作者中心**(口径 2026-10-16) |
| 08 平台运营后台 | 已实现 | 用户管理、内容治理、订单与权益、创作者与内容审核、数据看板 + 四个后台页面 | | 08 平台运营后台 | 已实现 | 用户管理、内容治理、订单与权益、创作者与内容审核、数据看板 + 四个后台页面 |
| 09 分集收费与支付 | 部分 | **Google Play 票据校验闭环已实现**(校验→建单→发权益、幂等与防重放、恢复购买、退款拒付回收、对账);**Apple IAP 只留端口未实现**(印尼 iOS 占比低,文档口径为"预留") | | 09 分集收费与支付 | 部分 | **Google Play 票据校验闭环已实现**(校验→建单→发权益、幂等与防重放、恢复购买、退款拒付回收、对账);**Apple IAP 只留端口未实现**(印尼 iOS 占比低,文档口径为"预留") |
| 10 质量、数据与上线 | 部分 | **转码 worker(C1)已实现**;**Redis 缓存与限流(C2)、埋点(C3)、压测未做** | | 10 质量、数据与上线 | 部分 | **转码 worker(C1)、Redis 缓存与限流(C2)、埋点(C3)均已实现**;**压测未做** |
### 已完成批次(按提交顺序) ### 已完成批次(按提交顺序)
@@ -426,18 +426,18 @@ public Result<Long> create(@Valid @RequestBody DramaCategorySaveReqVO req) { ...
## 尚未完成 ## 尚未完成
> 最后更新:2026-10-16。完成后请同步本表与 [docs/api-phase1.md](docs/api-phase1.md)。 > 最后更新:2026-10-24。完成后请同步本表与 [docs/api-phase1.md](docs/api-phase1.md)。
### 一、后端研发(剩余) ### 一、后端研发(剩余)
> 已完成的项(不再列出):**C2 Redis 缓存与限流**(`framework/redis` 的 `RedisService` + `RateLimit` 切面)、**C3 埋点**(客户端埋点 SDK),均于 10-20 ~ 10-23 落地。
| # | 项 | 内容 | 依据 | | # | 项 | 内容 | 依据 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 1 | **C2 Redis 缓存与限流** | 首页/发现/分类等热点接口缓存(Cache-Aside)+ 验证码/注册/登录限流;生产 Redis 规格 `4C16G 主从 2~3 哨兵` | 任务清单 C2、`17-风险评估` R-10、`10-安全方案` §三 | | 1 | **Apple IAP 票据校验** | 目前只留端口与枚举,未做真实校验(印尼 iOS 占比低,文档口径为"预留") | `api-phase1` §1.8 |
| 2 | **C3 埋点** | 播放/支付/转化事件 + 数据字典对齐(含 06 的分享埋点) | 任务清单 C3、`api-phase1` §3 | | 2 | **压测** | 目标 `P95 < 50ms`(预热后);容量口径文档里有两套("1 万并发"与"2 万并发 + 3000 QPS"),需定稿 | `09-测试计划` §五、`13/14-容量` |
| 3 | **Apple IAP 票据校验** | 目前只留端口与枚举,未做真实校验(印尼 iOS 占比低,文档口径为"预留") | `api-phase1` §1.8 | | 3 | **对账数据源** | 结算金额/手续费/税只能从 Play Console 财务报告取(需报表权限);当前 `storeAmountSum` 恒为 `null` + `notes` 说明 | B1-2 待确认 |
| 4 | **压测** | 目标 `P95 < 50ms`(预热后);容量口径文档里有两套("1 万并发"与"2 万并发 + 3000 QPS"),需定稿 | `09-测试计划` §五、`13/14-容量` | | 4 | **退款口径完善** | 部分退款按比例回收、退款实时性(当前由每日对账 + 手动驱动,未接 RTDN 推送) | B1-2 待确认 |
| 5 | **对账数据源** | 结算金额/手续费/税只能从 Play Console 财务报告取(需报表权限);当前 `storeAmountSum` 恒为 `null` + `notes` 说明 | B1-2 待确认 |
| 6 | **退款口径完善** | 部分退款按比例回收、退款实时性(当前由每日对账 + 手动驱动,未接 RTDN 推送) | B1-2 待确认 |
### 二、上线前必补(P0 / Go-No-Go:非研发,但阻塞上线) ### 二、上线前必补(P0 / Go-No-Go:非研发,但阻塞上线)
+23 -6
View File
@@ -25,12 +25,15 @@ function Write-Log($msg) {
function Send-Alert($title, $msg) { function Send-Alert($title, $msg) {
if (-not $FeishuWebhook) { return } if (-not $FeishuWebhook) { return }
$body = @{ $json = @{
msg_type = "text" msg_type = "text"
content = @{ text = "[$title] $msg" } content = @{ text = "[$title] $msg" }
} | ConvertTo-Json -Depth 3 } | ConvertTo-Json -Depth 3
try { try {
Invoke-RestMethod -Uri $FeishuWebhook -Method Post -Body $body -ContentType "application/json" -TimeoutSec 10 # PS5 的 Invoke-RestMethod 对字符串 body 默认按 Latin-1 编码,中文会变成 ???,
# 必须手动转 UTF-8 字节数组发送
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
Invoke-RestMethod -Uri $FeishuWebhook -Method Post -Body $bytes -ContentType "application/json; charset=utf-8" -TimeoutSec 10
} catch { } catch {
Write-Log "飞书告警发送失败: $_" Write-Log "飞书告警发送失败: $_"
} }
@@ -101,11 +104,25 @@ if (-not $FeishuWebhook) {
} }
# Docker Desktop 登录后偶尔尚未就绪,先等待并尝试拉起。 # Docker Desktop 登录后偶尔尚未就绪,先等待并尝试拉起。
# 注意:本脚本以 SYSTEM 身份运行,绝不能直接 Start-Process Docker Desktop.exe。
# WSL 不支持本地系统账户运行(WSL_E_LOCAL_SYSTEM_NOT_SUPPORTED),
# 以 SYSTEM 启动会把 docker-desktop 发行版搞成僵死状态(VHD 已挂载无法终止),
# 结果是登录用户会话里的 Docker Desktop 也永远起不来。
# 因此改为触发以登录用户身份运行的 FruitShorts-DockerDesktop 计划任务。
if (-not (Test-Docker)) { if (-not (Test-Docker)) {
Write-Log "Docker 未就绪,尝试启动 Docker Desktop" Write-Log "Docker 未就绪,尝试通过用户会话启动 Docker Desktop"
$dockerExe = "C:\Program Files\Docker\Docker\Docker Desktop.exe" $desktopTask = "FruitShorts-DockerDesktop"
if (Test-Path $dockerExe) { try {
Start-Process -FilePath $dockerExe # Docker Desktop.exe 是任务进程树的一部分,GUI 活着时任务一直是 Running,
# 直接 Start-ScheduledTask 会被 IgnoreNew 忽略,所以先结束旧实例再拉起。
$t = Get-ScheduledTask -TaskName $desktopTask -ErrorAction Stop
if ($t.State -eq "Running") {
Stop-ScheduledTask -TaskName $desktopTask -ErrorAction SilentlyContinue
Start-Sleep -Seconds 5
}
Start-ScheduledTask -TaskName $desktopTask -ErrorAction Stop
} catch {
Write-Log "触发 Docker Desktop 启动任务失败: $_"
} }
for ($i = 0; $i -lt 18; $i++) { for ($i = 0; $i -lt 18; $i++) {
+5 -1
View File
@@ -78,6 +78,9 @@ services:
# 限流/缓存用 Redis;容器内 127.0.0.1 是容器自身,必须指向编排里的 redis # 限流/缓存用 Redis;容器内 127.0.0.1 是容器自身,必须指向编排里的 redis
REDIS_HOST: ${REDIS_HOST:-redis} REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379} REDIS_PORT: ${REDIS_PORT:-6379}
# CORS:公网入口 nginx 把 SSL 跑在 80 端口上(腾讯云安全组只放 80),
# 浏览器 fetch 带 Origin 时端口不匹配会被 Spring CORS 拒(必须精确字符串匹配)。
CORS_ALLOWED_ORIGINS: "https://api.frostgiant.vip:80,https://api.frostgiant.vip,http://api.frostgiant.vip,https://39.162.50.99:8081,http://localhost:3005,http://127.0.0.1:3005"
ports: ports:
- "8081:8081" - "8081:8081"
volumes: volumes:
@@ -108,7 +111,8 @@ services:
SERVER_PORT: "8080" SERVER_PORT: "8080"
SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: "2GB" SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: "2GB"
SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: "2GB" SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: "2GB"
CORS_ALLOWED_ORIGINS: "http://localhost:3005,http://127.0.0.1:3005,http://39.162.50.99:3005,http://192.168.31.250:3005" # 浏览后台时 Origin 是实际访问地址,必须精确列全:本机、局域网、公网 IP、域名。
CORS_ALLOWED_ORIGINS: "http://localhost:3005,http://127.0.0.1:3005,http://39.162.50.99:3005,http://192.168.31.250:3005,https://api.frostgiant.vip,https://admin.frostgiant.vip"
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}} DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
# 管理端仅内网,页面上直接播放/下载裸媒资地址(不签播放签名), # 管理端仅内网,页面上直接播放/下载裸媒资地址(不签播放签名),
# 因此这里不启用 /files/episode/** 的签名校验;公开的 app-api 仍保持开启。 # 因此这里不启用 /files/episode/** 的签名校验;公开的 app-api 仍保持开启。
+50 -3
View File
@@ -170,8 +170,9 @@ app.register.max-per-device-per-day
| GET | `/app/home/feed` | 首页:轮播 + 分区,卡片带 `preview` 可播预览集与 `language` 语种 | | GET | `/app/home/feed` | 首页:轮播 + 分区,卡片带 `preview` 可播预览集与 `language` 语种 |
| GET | `/app/discover/feed` | 发现页分区 | | GET | `/app/discover/feed` | 发现页分区 |
| GET | `/app/feed?types=ANIME,LIVE&categoryId=7&cursor=&size=20` | 推荐流;支持内容类型与分类叠加过滤,游标翻页 | | GET | `/app/feed?types=ANIME,LIVE&categoryId=7&cursor=&size=20` | 推荐流;支持内容类型与分类叠加过滤,游标翻页 |
| GET | `/app/categories` | 已启用首页分类。`name` 为中文,`locales` 为 `{zh,en,id}` | | GET | `/app/categories` | 已启用首页分类。名称在 `locales`;另带 `code`、`layoutType`(`GRID` / `RANKING`)、`revision` |
| GET | `/app/categories/{id}/dramas?pageIndex=&pageSize=` | 分类下已上线短剧,分页。`pageIndex` 默认 1,`pageSize` 默认 20、最大 50 | | GET | `/app/categories/{id}/dramas?pageIndex=&pageSize=` | 分类下已上线短剧,分页。`pageIndex` 默认 1,`pageSize` 默认 20、最大 50 |
| GET | `/app/categories/{id}/ranking` | 推荐榜快照;只接受 `layoutType=RANKING` 的已启用分类,按后台位置返回连续排名 |
| GET | `/app/drama-categories` | 已启用类型。`locales` 为 `{zh,en,id}` | | GET | `/app/drama-categories` | 已启用类型。`locales` 为 `{zh,en,id}` |
| GET | `/app/tags` | 已启用标签。`locales` 为 `{zh,en,id}` | | GET | `/app/tags` | 已启用标签。`locales` 为 `{zh,en,id}` |
| POST | `/app/dramas/page` | 全部短剧分页。请求体 `pageIndex`(默认 1)、`pageSize`(默认 20、最大 50)、`categoryIds`、`classificationIds`、`tagIds`。id 用 JSON 数组,不放查询串。同一组多选为或,组与组之间为且 | | POST | `/app/dramas/page` | 全部短剧分页。请求体 `pageIndex`(默认 1)、`pageSize`(默认 20、最大 50)、`categoryIds`、`classificationIds`、`tagIds`。id 用 JSON 数组,不放查询串。同一组多选为或,组与组之间为且 |
@@ -205,6 +206,10 @@ app.register.max-per-device-per-day
> 卡片文案只放语言表:`titleLocales`、`synopsisLocales`、`types`、`tagLocales`。 > 卡片文案只放语言表:`titleLocales`、`synopsisLocales`、`types`、`tagLocales`。
> `tagColor` 是标签背景色。中文取各表的 `zh`。 > `tagColor` 是标签背景色。中文取各表的 `zh`。
> >
> `tagCode` 是标签语义码(如 `NEW_ARRIVAL`),来自 `content_tag.code`。
> 客户端按码判断板块(推荐页「上新」横滑),`tagLocales` 只用于展示;
> 码为空时客户端才退回按文案匹配。后台标签管理可配置该码。
>
> 卡片新增 `dramaType`:`ANIME` 表示漫剧,`LIVE` 表示真人剧。 > 卡片新增 `dramaType`:`ANIME` 表示漫剧,`LIVE` 表示真人剧。
> 客户端字段仍返回主内容类型;后台多选值保存在 `drama.drama_types`, > 客户端字段仍返回主内容类型;后台多选值保存在 `drama.drama_types`,
> App 类型筛选按逗号列整词匹配,因此 `ANIME,LIVE` 多标签剧可被任一类型命中。 > App 类型筛选按逗号列整词匹配,因此 `ANIME,LIVE` 多标签剧可被任一类型命中。
@@ -218,6 +223,28 @@ app.register.max-per-device-per-day
> 只返回 `status = 1` 的剧单,排序与后台剧单排序一致,卡片顺序与后台关联顺序一致。 > 只返回 `status = 1` 的剧单,排序与后台剧单排序一致,卡片顺序与后台关联顺序一致。
> 剧单列表越界页返回空 `list`,`total` 仍是全部剧单数。详情越界页同样返回空 `list`。 > 剧单列表越界页返回空 `list`,`total` 仍是全部剧单数。详情越界页同样返回空 `list`。
#### 推荐榜(`GET /app/categories/{id}/ranking`)
- 分类列表中的 `layoutType=RANKING` 表示客户端必须使用榜单布局,禁止按分类名称判断。
- 榜单内容与顺序以 `drama_classification_drama.position asc` 为准;客户端不得按播放量重新排序。
- 已下架、未发布或不存在的短剧会被过滤,剩余项目的 `rank` 从 1 连续补齐。
- 最多返回 50 部;`revision` 在后台保存内容或顺序后递增,供缓存与曝光埋点使用。
- 响应 `list` 项结构为 `{rank, drama}`,其中 `drama` 沿用 `AppDramaCardVO`。
```json
{
"id": "2112807182243336193",
"code": "RECOMMEND_RANKING",
"locales": {"zh": "推荐榜", "en": "Top Picks", "id": "Pilihan Teratas"},
"revision": "8",
"generatedAt": "2026-11-02T10:00:00",
"total": 1,
"list": [
{"rank": 1, "drama": {"id": "2112807182243336194", "titleLocales": {"zh": "短剧 A"}}}
]
}
```
#### 推荐类型流(`GET /app/feed`) #### 推荐类型流(`GET /app/feed`)
- `types` 可选,逗号分隔,支持 `ANIME`、`LIVE` 或多选 `ANIME,LIVE`;不传或空值返回全部已上线内容。 - `types` 可选,逗号分隔,支持 `ANIME`、`LIVE` 或多选 `ANIME,LIVE`;不传或空值返回全部已上线内容。
@@ -462,6 +489,7 @@ app.register.max-per-device-per-day
"id": "Pengantin Pengganti: Kesayangan Presiden Direktur" "id": "Pengantin Pengganti: Kesayangan Presiden Direktur"
}, },
"dramaCover": "/files/drama_cover/12.jpg", "dramaCover": "/files/drama_cover/12.jpg",
"dramaCoverLandscape": "/files/drama_cover/12-wide.jpg",
"episodeNumber": 6, "episodeNumber": 6,
"totalEpisodes": 68, "totalEpisodes": 68,
"viewCount": "1800", "viewCount": "1800",
@@ -484,7 +512,8 @@ app.register.max-per-device-per-day
| --- | --- | --- | | --- | --- | --- |
| `dramaTitle` | string / null | 短剧中文标题,旧客户端回退用 | | `dramaTitle` | string / null | 短剧中文标题,旧客户端回退用 |
| `titleLocales` | object / null | 短剧标题语言表,key 为 `zh` / `en` / `id`。客户端按当前语言取,没有对应语言时回退 `zh` | | `titleLocales` | object / null | 短剧标题语言表,key 为 `zh` / `en` / `id`。客户端按当前语言取,没有对应语言时回退 `zh` |
| `dramaCover` | string / null | 短剧封面 | | `dramaCover` | string / null | 短剧竖版封面 |
| `dramaCoverLandscape` | string / null | 短剧横版封面,供继续观看横幅等横向位使用;为空时客户端回退 `dramaCover` |
| `episodeNumber` | int / null | 当前续看集号 `ep_no` | | `episodeNumber` | int / null | 当前续看集号 `ep_no` |
| `totalEpisodes` | int / null | 短剧总集数 | | `totalEpisodes` | int / null | 短剧总集数 |
| `viewCount` | Long(JSON 字符串)/ null | 短剧热度,取 `drama.view_count`。`playCount` 是用户自己的播放次数 | | `viewCount` | Long(JSON 字符串)/ null | 短剧热度,取 `drama.view_count`。`playCount` 是用户自己的播放次数 |
@@ -633,6 +662,7 @@ B1-2 的退款/拒付复验依赖它。服务端凭据(`packageName` + service
| `noticeEnabled` | `app.notice.enabled` | `true` | 公告入口开关 | | `noticeEnabled` | `app.notice.enabled` | `true` | 公告入口开关 |
| `coinUnlockEnabled` | `app.coin.unlock.enabled` | `true` | 金币解锁开关 | | `coinUnlockEnabled` | `app.coin.unlock.enabled` | `true` | 金币解锁开关 |
| `downloadEnabled` | `app.download.enabled` | `true` | 离线下载开关 | | `downloadEnabled` | `app.download.enabled` | `true` | 离线下载开关 |
| `loginBackgroundUrl` | `app.login.background.url` | `""` | 登录页全屏海报背景;留空 App 用内置默认图。**建议尺寸 1170×2532**(iPhone 14 Pro Max 3× 整屏竖图),后台走 `/admin/media/upload/` 任意 bizType 上传后把返回的相对路径(`/files/login_bg/xxx.jpg`)写入即可 |
**口径**:**只下发上述白名单键**(服务端硬编码白名单),后台配置里的其它键(可能含内部开关) **口径**:**只下发上述白名单键**(服务端硬编码白名单),后台配置里的其它键(可能含内部开关)
**一律不下发**;库里缺键时用「契约默认值」兜底,**不返回 null 让客户端自己猜默认值**。 **一律不下发**;库里缺键时用「契约默认值」兜底,**不返回 null 让客户端自己猜默认值**。
@@ -1262,6 +1292,7 @@ commentSummary,createdAt`。游客的 userId 为空、anonymous 为 true;评
| GET | `/admin/drama-classifications/{id}/dramas/candidates` | `drama:list` | 尚未关联到该分类的短剧(分页,供多选) | | GET | `/admin/drama-classifications/{id}/dramas/candidates` | `drama:list` | 尚未关联到该分类的短剧(分页,供多选) |
| POST | `/admin/drama-classifications/{id}/dramas` | `drama:update` | 批量关联短剧,返回实际新增数量 | | POST | `/admin/drama-classifications/{id}/dramas` | `drama:update` | 批量关联短剧,返回实际新增数量 |
| DELETE | `/admin/drama-classifications/{id}/dramas/{dramaId}` | `drama:update` | 从分类移出短剧(幂等) | | DELETE | `/admin/drama-classifications/{id}/dramas/{dramaId}` | `drama:update` | 从分类移出短剧(幂等) |
| PUT | `/admin/drama-classifications/{id}/ranking-items` | `drama:update` | 原子保存推荐榜完整顺序,最多 50 部;空数组表示清空 |
**列表响应** **列表响应**
@@ -1273,6 +1304,9 @@ commentSummary,createdAt`。游客的 userId 为空、anonymous 为 true;评
{ {
"id": "2112807182243336193", "id": "2112807182243336193",
"name": "本周必看", "name": "本周必看",
"code": "CLASSIFICATION_2112807182243336193",
"layoutType": "GRID",
"revision": "1",
"sort": 1, "sort": 1,
"status": 1, "status": 1,
"dramaCount": 12, "dramaCount": 12,
@@ -1288,12 +1322,20 @@ commentSummary,createdAt`。游客的 userId 为空、anonymous 为 true;评
**新建 / 编辑请求体** **新建 / 编辑请求体**
```json ```json
{ "name": "本周必看", "sort": 1, "status": 1 } {
"name": "推荐榜",
"code": "RECOMMEND_RANKING",
"layoutType": "RANKING",
"sort": 1,
"status": 1
}
``` ```
| 字段 | 必填 | 说明 | | 字段 | 必填 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `name` | 是 | 去首尾空格后保存,全局唯一;`@NotBlank` 提示「请填写分类名称」 | | `name` | 是 | 去首尾空格后保存,全局唯一;`@NotBlank` 提示「请填写分类名称」 |
| `code` | 否 | 稳定语义码,只允许大写字母、数字、下划线;不传时生成 `CLASSIFICATION_{id}` |
| `layoutType` | 否 | `GRID` 普通分类 / `RANKING` 推荐榜;默认 `GRID` |
| `sort` | 否 | 新建不传时取 `现有条数 + 1`;编辑不传(null)时保持原值 | | `sort` | 否 | 新建不传时取 `现有条数 + 1`;编辑不传(null)时保持原值 |
| `status` | 否 | `1` 启用 / `0` 停用;新建默认 `1`,编辑不传保持原值 | | `status` | 否 | `1` 启用 / `0` 停用;新建默认 `1`,编辑不传保持原值 |
@@ -1307,6 +1349,11 @@ POST /admin/drama-classifications/2112807182243336193/dramas
返回 `data` 为实际新增条数(整数)。已关联的、以及不存在的 `dramaId` 会被静默跳过,不报错; 返回 `data` 为实际新增条数(整数)。已关联的、以及不存在的 `dramaId` 会被静默跳过,不报错;
全部跳过时返回 `0`。`dramaIds` 为空数组 → `400 请选择短剧`。 全部跳过时返回 `0`。`dramaIds` 为空数组 → `400 请选择短剧`。
推荐榜应使用 `PUT /{id}/ranking-items` 一次提交完整有序 `dramaIds`。接口会去重、校验短剧存在,
按数组顺序写入 `position=1..N` 并递增分类 `revision`;`dramaIds` 必须显式传数组,空数组表示清空。
普通分类调用该接口返回 `200003`;推荐榜调用旧的 `POST /{id}/dramas` 或
`DELETE /{id}/dramas/{dramaId}` 同样返回 `200003`,防止绕过版本与排序更新。
**短剧列表项(`GET /{id}/dramas` 与 `/dramas/candidates` 共用)** **短剧列表项(`GET /{id}/dramas` 与 `/dramas/candidates` 共用)**
```json ```json
@@ -9,6 +9,7 @@ import com.fruitshorts.content.vo.DramaBriefVO;
import com.fruitshorts.content.vo.DramaClassificationRespVO; import com.fruitshorts.content.vo.DramaClassificationRespVO;
import com.fruitshorts.content.vo.DramaClassificationSaveReqVO; import com.fruitshorts.content.vo.DramaClassificationSaveReqVO;
import com.fruitshorts.content.vo.DramaIdsReqVO; import com.fruitshorts.content.vo.DramaIdsReqVO;
import com.fruitshorts.content.vo.DramaRankingItemsReqVO;
import com.fruitshorts.framework.log.OperationLog; import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@@ -104,6 +105,16 @@ public class DramaClassificationController {
return Result.ok(classificationService.bindDramas(id, req.getDramaIds())); return Result.ok(classificationService.bindDramas(id, req.getDramaIds()));
} }
@Operation(summary = "保存推荐榜完整内容与顺序")
@PutMapping("/{id}/ranking-items")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "保存推荐榜顺序")
public Result<Void> saveRankingItems(@PathVariable Long id,
@Valid @RequestBody DramaRankingItemsReqVO req) {
classificationService.saveRankingItems(id, req.getDramaIds());
return Result.ok();
}
@Operation(summary = "移除分类下的短剧") @Operation(summary = "移除分类下的短剧")
@DeleteMapping("/{id}/dramas/{dramaId}") @DeleteMapping("/{id}/dramas/{dramaId}")
@PreAuthorize("@ss.hasPermission('drama:update')") @PreAuthorize("@ss.hasPermission('drama:update')")
@@ -113,6 +113,15 @@ public class DramaController {
return Result.ok(); return Result.ok();
} }
@Operation(summary = "审核通过并上线")
@PostMapping("/{id}/approve-publish")
@PreAuthorize("@ss.hasPermission('drama:review')")
@OperationLog(module = "内容管理", action = "审核通过并上线")
public Result<Void> approvePublish(@PathVariable Long id, @RequestBody(required = false) DramaStatusReqVO req) {
changeStatus(id, "APPROVE_PUBLISH", req);
return Result.ok();
}
@Operation(summary = "上线") @Operation(summary = "上线")
@PostMapping("/{id}/publish") @PostMapping("/{id}/publish")
@PreAuthorize("@ss.hasPermission('drama:review')") @PreAuthorize("@ss.hasPermission('drama:review')")
@@ -3,11 +3,13 @@
package com.fruitshorts.app.controller; package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService; import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.service.AppRankingService;
import com.fruitshorts.app.vo.AppCategoryVO; import com.fruitshorts.app.vo.AppCategoryVO;
import com.fruitshorts.app.vo.AppDramaPageReqVO; import com.fruitshorts.app.vo.AppDramaPageReqVO;
import com.fruitshorts.app.vo.AppFeedVO; import com.fruitshorts.app.vo.AppFeedVO;
import com.fruitshorts.app.vo.AppDramaCardVO; import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.app.vo.AppPlaylistDetailVO; import com.fruitshorts.app.vo.AppPlaylistDetailVO;
import com.fruitshorts.app.vo.AppRankingVO;
import com.fruitshorts.common.pojo.CursorResult; import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result; import com.fruitshorts.common.pojo.Result;
@@ -37,6 +39,7 @@ public class AppCatalogController {
private static final int DRAMA_PAGE_SIZE = 20; private static final int DRAMA_PAGE_SIZE = 20;
private final AppCatalogService appCatalogService; private final AppCatalogService appCatalogService;
private final AppRankingService appRankingService;
@Operation(summary = "首页聚合:轮播 + 分区,一次请求渲染整页") @Operation(summary = "首页聚合:轮播 + 分区,一次请求渲染整页")
@GetMapping("/home/feed") @GetMapping("/home/feed")
@@ -75,6 +78,12 @@ public class AppCatalogController {
return Result.ok(appCatalogService.categoryDramas(id, pageIndex, pageSize)); return Result.ok(appCatalogService.categoryDramas(id, pageIndex, pageSize));
} }
@Operation(summary = "推荐榜:按后台配置顺序返回榜单快照")
@GetMapping("/categories/{id}/ranking")
public Result<AppRankingVO> categoryRanking(@PathVariable Long id) {
return Result.ok(appRankingService.ranking(id));
}
@Operation(summary = "已启用类型") @Operation(summary = "已启用类型")
@GetMapping("/drama-categories") @GetMapping("/drama-categories")
public Result<List<AppCategoryVO>> dramaCategories() { public Result<List<AppCategoryVO>> dramaCategories() {
@@ -34,7 +34,6 @@ import org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Arrays; import java.util.Arrays;
@@ -93,6 +92,11 @@ public class AppCatalogService {
c.getNameLocales(), c.getName(), c.getNameEn(), c.getNameId()); c.getNameLocales(), c.getName(), c.getNameEn(), c.getNameId());
AppCategoryVO vo = new AppCategoryVO(c.getId(), ContentLocaleTexts.value(locales, ContentLocaleTexts.ZH)); AppCategoryVO vo = new AppCategoryVO(c.getId(), ContentLocaleTexts.value(locales, ContentLocaleTexts.ZH));
vo.setLocales(locales); vo.setLocales(locales);
vo.setCode(c.getCode());
vo.setLayoutType(c.getLayoutType() == null
? DramaClassificationService.LAYOUT_GRID
: c.getLayoutType());
vo.setRevision(c.getRevision() == null ? 1L : c.getRevision());
return vo; return vo;
}) })
.toList(); .toList();
@@ -206,6 +210,7 @@ public class AppCatalogService {
detail.setTitleLocales(titles); detail.setTitleLocales(titles);
detail.setSynopsisLocales(synopses); detail.setSynopsisLocales(synopses);
detail.setCover(drama.getCoverUrl()); detail.setCover(drama.getCoverUrl());
detail.setCoverLandscape(drama.getHorizontalCoverUrl());
detail.setTypes(types); detail.setTypes(types);
fillDetailTag(detail, drama); fillDetailTag(detail, drama);
detail.setTotalEpisodes(drama.getTotalEpisodes()); detail.setTotalEpisodes(drama.getTotalEpisodes());
@@ -348,7 +353,8 @@ public class AppCatalogService {
drama.getSynopsisLocales(), drama.getSynopsis(), null, null); drama.getSynopsisLocales(), drama.getSynopsis(), null, null);
card.setTitleLocales(titles); card.setTitleLocales(titles);
card.setCover(drama.getCoverUrl()); card.setCover(drama.getCoverUrl());
card.setSynopsisLocales(shortLocales(synopses)); card.setCoverLandscape(drama.getHorizontalCoverUrl());
card.setSynopsisLocales(synopses);
card.setTypes(types); card.setTypes(types);
fillTag(card, drama, tags); fillTag(card, drama, tags);
card.setTotalEpisodes(drama.getTotalEpisodes()); card.setTotalEpisodes(drama.getTotalEpisodes());
@@ -411,6 +417,7 @@ public class AppCatalogService {
ContentTag tag = tagOf(drama, tags); ContentTag tag = tagOf(drama, tags);
Map<String, String> locales = tagLocales(tag); Map<String, String> locales = tagLocales(tag);
card.setTagLocales(locales); card.setTagLocales(locales);
card.setTagCode(tagCode(tag));
card.setTagColor(tag == null ? "" : tag.getBgColor()); card.setTagColor(tag == null ? "" : tag.getBgColor());
} }
@@ -418,9 +425,18 @@ public class AppCatalogService {
ContentTag tag = tagOf(drama, loadTags(List.of(drama))); ContentTag tag = tagOf(drama, loadTags(List.of(drama)));
Map<String, String> locales = tagLocales(tag); Map<String, String> locales = tagLocales(tag);
detail.setTagLocales(locales); detail.setTagLocales(locales);
detail.setTagCode(tagCode(tag));
detail.setTagColor(tag == null ? "" : tag.getBgColor()); detail.setTagColor(tag == null ? "" : tag.getBgColor());
} }
/** 语义码直接下发,客户端据此判断板块;未配置时给空串,由客户端按文案兜底。 */
private String tagCode(ContentTag tag) {
if (tag == null || tag.getCode() == null) {
return "";
}
return tag.getCode().trim();
}
private ContentTag tagOf(Drama drama, Map<Long, ContentTag> tags) { private ContentTag tagOf(Drama drama, Map<Long, ContentTag> tags) {
if (drama.getTagId() == null || drama.getTagId() <= 0L) { if (drama.getTagId() == null || drama.getTagId() <= 0L) {
return null; return null;
@@ -439,20 +455,4 @@ public class AppCatalogService {
return dramaCategoryService.localeMap(); return dramaCategoryService.localeMap();
} }
/** 卡片只带一句简介,避免列表响应体过大 */
private Map<String, String> shortLocales(Map<String, String> locales) {
Map<String, String> result = new LinkedHashMap<>();
if (locales == null) {
return result;
}
locales.forEach((code, text) -> result.put(code, shortSynopsis(text)));
return result;
}
private String shortSynopsis(String synopsis) {
if (synopsis == null) {
return "";
}
return synopsis.length() <= 60 ? synopsis : synopsis.substring(0, 60);
}
} }
@@ -35,6 +35,7 @@ public class AppConfigService {
KEYS.put("noticeEnabled", new KeySpec("app.notice.enabled", "true")); KEYS.put("noticeEnabled", new KeySpec("app.notice.enabled", "true"));
KEYS.put("coinUnlockEnabled", new KeySpec("app.coin.unlock.enabled", "true")); KEYS.put("coinUnlockEnabled", new KeySpec("app.coin.unlock.enabled", "true"));
KEYS.put("downloadEnabled", new KeySpec("app.download.enabled", "true")); KEYS.put("downloadEnabled", new KeySpec("app.download.enabled", "true"));
KEYS.put("loginBackgroundUrl", new KeySpec("app.login.background.url", ""));
} }
private final SysConfigMapper sysConfigMapper; private final SysConfigMapper sysConfigMapper;
@@ -0,0 +1,79 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.app.vo.AppRankingEntryVO;
import com.fruitshorts.app.vo.AppRankingVO;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.service.ContentLocaleTexts;
import com.fruitshorts.content.service.DramaClassificationService;
import com.fruitshorts.content.service.DramaService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** C 端推荐榜快照组装。 */
@Service
@RequiredArgsConstructor
public class AppRankingService {
private static final int MAX_RANKING_ITEMS = 50;
private final DramaClassificationService classificationService;
private final DramaService dramaService;
private final AppCatalogService catalogService;
/** 过滤未上线短剧后保持后台位置,排名连续补齐。 */
public AppRankingVO ranking(Long classificationId) {
var classification = classificationService.getEnabledById(classificationId);
if (!DramaClassificationService.LAYOUT_RANKING.equals(classification.getLayoutType())) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "该分类不是推荐榜");
}
List<Long> ids = classificationService.listDramaIdsByClassification(classificationId);
List<Drama> ordered = orderedPublished(ids).stream().limit(MAX_RANKING_ITEMS).toList();
List<AppDramaCardVO> cards = catalogService.toCards(ordered);
List<AppRankingEntryVO> entries = entries(cards);
Map<String, String> locales = ContentLocaleTexts.read(
classification.getNameLocales(), classification.getName(),
classification.getNameEn(), classification.getNameId());
AppRankingVO ranking = new AppRankingVO();
ranking.setId(classification.getId());
ranking.setCode(classification.getCode());
ranking.setLocales(locales);
ranking.setRevision(classification.getRevision() == null ? 1L : classification.getRevision());
ranking.setGeneratedAt(classification.getUpdatedAt());
ranking.setTotal(entries.size());
ranking.setList(entries);
return ranking;
}
private List<AppRankingEntryVO> entries(List<AppDramaCardVO> cards) {
List<AppRankingEntryVO> entries = new ArrayList<>();
for (int index = 0; index < cards.size(); index++) {
entries.add(new AppRankingEntryVO(index + 1, cards.get(index)));
}
return entries;
}
private List<Drama> orderedPublished(List<Long> ids) {
if (ids.isEmpty()) {
return List.of();
}
Map<Long, Integer> positions = new HashMap<>();
for (int index = 0; index < ids.size(); index++) {
positions.put(ids.get(index), index);
}
return dramaService.listPublishedByIds(ids).stream()
.sorted((left, right) -> Integer.compare(
positions.getOrDefault(left.getId(), Integer.MAX_VALUE),
positions.getOrDefault(right.getId(), Integer.MAX_VALUE)))
.toList();
}
}
@@ -11,10 +11,15 @@ import java.util.Map;
public class AppCategoryVO { public class AppCategoryVO {
private Long id; private Long id;
private String code;
private String layoutType;
private Long revision;
private Map<String, String> locales; private Map<String, String> locales;
public AppCategoryVO(Long id, String name) { public AppCategoryVO(Long id, String name) {
this.id = id; this.id = id;
this.layoutType = "GRID";
this.revision = 1L;
this.locales = name == null || name.isBlank() ? Map.of() : Map.of("zh", name); this.locales = name == null || name.isBlank() ? Map.of() : Map.of("zh", name);
} }
} }
@@ -15,11 +15,15 @@ public class AppDramaCardVO {
private Long id; private Long id;
private Map<String, String> titleLocales; private Map<String, String> titleLocales;
private String cover; private String cover;
/** 横版封面(16:9),横幅等横向位优先用它;空串表示未上传,客户端回退竖版。 */
private String coverLandscape;
private Map<String, String> synopsisLocales; private Map<String, String> synopsisLocales;
/** 短剧选中的全部类型,每项是一份语言表。 */ /** 短剧选中的全部类型,每项是一份语言表。 */
private List<Map<String, String>> types; private List<Map<String, String>> types;
/** 标签语言表,key 为 zh / en / id。 */ /** 标签语言表,key 为 zh / en / id。 */
private Map<String, String> tagLocales; private Map<String, String> tagLocales;
/** 标签语义码,如 NEW_ARRIVAL;客户端按码判断板块,文案仅用于展示。 */
private String tagCode;
/** 标签角标背景色,#RRGGBB。 */ /** 标签角标背景色,#RRGGBB。 */
private String tagColor; private String tagColor;
private Integer totalEpisodes; private Integer totalEpisodes;
@@ -14,8 +14,12 @@ public class AppDramaDetailVO {
private Map<String, String> titleLocales; private Map<String, String> titleLocales;
private Map<String, String> synopsisLocales; private Map<String, String> synopsisLocales;
private String cover; private String cover;
/** 横版封面(16:9);空串表示未上传。 */
private String coverLandscape;
private List<Map<String, String>> types; private List<Map<String, String>> types;
private Map<String, String> tagLocales; private Map<String, String> tagLocales;
/** 标签语义码,如 NEW_ARRIVAL;空表示未配置。 */
private String tagCode;
private String tagColor; private String tagColor;
private Integer totalEpisodes; private Integer totalEpisodes;
private Integer freeEpisodes; private Integer freeEpisodes;
@@ -0,0 +1,15 @@
// Author: fruit.com.cn
package com.fruitshorts.app.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
/** 推荐榜单项,排名由服务端确定。 */
@Data
@AllArgsConstructor
public class AppRankingEntryVO {
private Integer rank;
private AppDramaCardVO drama;
}
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
package com.fruitshorts.app.vo;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/** 推荐榜快照。 */
@Data
public class AppRankingVO {
private Long id;
private String code;
private Map<String, String> locales;
private Long revision;
private LocalDateTime generatedAt;
private Integer total;
private List<AppRankingEntryVO> list;
}
@@ -3,6 +3,7 @@ package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService; import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.service.AppEpisodeStatsService; import com.fruitshorts.app.service.AppEpisodeStatsService;
import com.fruitshorts.app.service.AppRankingService;
import com.fruitshorts.app.support.ControllerContractSupport; import com.fruitshorts.app.support.ControllerContractSupport;
import com.fruitshorts.app.vo.AppBannerVO; import com.fruitshorts.app.vo.AppBannerVO;
import com.fruitshorts.app.vo.AppCategoryVO; import com.fruitshorts.app.vo.AppCategoryVO;
@@ -10,6 +11,8 @@ import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.app.vo.AppDramaDetailVO; import com.fruitshorts.app.vo.AppDramaDetailVO;
import com.fruitshorts.app.vo.AppFeedVO; import com.fruitshorts.app.vo.AppFeedVO;
import com.fruitshorts.app.vo.AppSectionVO; import com.fruitshorts.app.vo.AppSectionVO;
import com.fruitshorts.app.vo.AppRankingEntryVO;
import com.fruitshorts.app.vo.AppRankingVO;
import com.fruitshorts.common.pojo.CursorResult; import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.common.exception.ErrorCode; import com.fruitshorts.common.exception.ErrorCode;
@@ -41,13 +44,16 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
class AppCatalogControllerContractTest { class AppCatalogControllerContractTest {
private AppCatalogService catalogService; private AppCatalogService catalogService;
private AppRankingService rankingService;
private MockMvc catalogMvc; private MockMvc catalogMvc;
private MockMvc dramaMvc; private MockMvc dramaMvc;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
catalogService = mock(AppCatalogService.class); catalogService = mock(AppCatalogService.class);
catalogMvc = ControllerContractSupport.mockMvcOf(new AppCatalogController(catalogService)); rankingService = mock(AppRankingService.class);
catalogMvc = ControllerContractSupport.mockMvcOf(
new AppCatalogController(catalogService, rankingService));
dramaMvc = ControllerContractSupport.mockMvcOf( dramaMvc = ControllerContractSupport.mockMvcOf(
new AppDramaController(catalogService, mock(PlaybackService.class), new AppDramaController(catalogService, mock(PlaybackService.class),
mock(EpisodeService.class), mock(CommentService.class), mock(EpisodeService.class), mock(CommentService.class),
@@ -122,15 +128,44 @@ class AppCatalogControllerContractTest {
@Test @Test
void should_return_category_list_when_categories_are_requested() throws Exception { void should_return_category_list_when_categories_are_requested() throws Exception {
AppCategoryVO category = new AppCategoryVO(3L, "都市"); AppCategoryVO category = new AppCategoryVO(3L, "都市");
category.setCode("RECOMMEND_RANKING");
category.setLayoutType("RANKING");
category.setRevision(7L);
given(catalogService.categories()).willReturn(List.of(category)); given(catalogService.categories()).willReturn(List.of(category));
catalogMvc.perform(get("/app/categories")) catalogMvc.perform(get("/app/categories"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data[0].id").value(3)) .andExpect(jsonPath("$.data[0].id").value(3))
.andExpect(jsonPath("$.data[0].code").value("RECOMMEND_RANKING"))
.andExpect(jsonPath("$.data[0].layoutType").value("RANKING"))
.andExpect(jsonPath("$.data[0].revision").value("7"))
.andExpect(jsonPath("$.data[0].locales.zh").value("都市")); .andExpect(jsonPath("$.data[0].locales.zh").value("都市"));
} }
@Test
void should_return_server_rank_when_ranking_is_requested() throws Exception {
AppDramaCardVO card = new AppDramaCardVO();
card.setId(12L);
card.setTitleLocales(Map.of("zh", "榜首短剧"));
AppRankingVO ranking = new AppRankingVO();
ranking.setId(3L);
ranking.setCode("RECOMMEND_RANKING");
ranking.setRevision(8L);
ranking.setTotal(1);
ranking.setList(List.of(new AppRankingEntryVO(1, card)));
given(rankingService.ranking(3L)).willReturn(ranking);
catalogMvc.perform(get("/app/categories/3/ranking"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.revision").value("8"))
.andExpect(jsonPath("$.data.list[0].rank").value(1))
.andExpect(jsonPath("$.data.list[0].drama.id").value("12"));
verify(rankingService).ranking(3L);
}
@Test @Test
void should_bind_path_variable_when_drama_detail_is_requested() throws Exception { void should_bind_path_variable_when_drama_detail_is_requested() throws Exception {
AppDramaDetailVO detail = new AppDramaDetailVO(); AppDramaDetailVO detail = new AppDramaDetailVO();
@@ -328,7 +328,9 @@ class AppCommentCountAnonymousSecurityTest {
@Bean @Bean
AppCatalogController appCatalogController(AppCatalogService appCatalogService) { AppCatalogController appCatalogController(AppCatalogService appCatalogService) {
return new AppCatalogController(appCatalogService); return new AppCatalogController(
appCatalogService,
mock(com.fruitshorts.app.service.AppRankingService.class));
} }
@Bean @Bean
@@ -90,6 +90,7 @@ class AppCreatorAndConfigContractTest {
whitelist.put("noticeEnabled", true); whitelist.put("noticeEnabled", true);
whitelist.put("coinUnlockEnabled", true); whitelist.put("coinUnlockEnabled", true);
whitelist.put("downloadEnabled", true); whitelist.put("downloadEnabled", true);
whitelist.put("loginBackgroundUrl", "");
given(appConfigService.appConfig()).willReturn(whitelist); given(appConfigService.appConfig()).willReturn(whitelist);
String body = configMvc.perform(get("/app/config")) String body = configMvc.perform(get("/app/config"))
@@ -97,6 +98,7 @@ class AppCreatorAndConfigContractTest {
.andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.agreementVersion").value("2026-10-01")) .andExpect(jsonPath("$.data.agreementVersion").value("2026-10-01"))
.andExpect(jsonPath("$.data.forceUpdate").value(false)) .andExpect(jsonPath("$.data.forceUpdate").value(false))
.andExpect(jsonPath("$.data.loginBackgroundUrl").value(""))
.andReturn().getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8); .andReturn().getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
// 没登录也能拿到(匿名) // 没登录也能拿到(匿名)
@@ -130,6 +130,25 @@ class AppCatalogServiceTest {
verify(commentService, never()).countByDrama(any()); verify(commentService, never()).countByDrama(any());
} }
@Test
void should_keep_full_synopsis_on_card_when_text_exceeds_card_length() {
String longSynopsis = "一纸婚约,开启了甜蜜又心动的同居生活。"
+ "真相只有一个,而她亲手揭开迷雾。一场意外替嫁,让她成为冷面总裁的契约新娘。";
Drama drama = new Drama();
drama.setId(1L);
drama.setTitle("第一部");
drama.setSynopsis(longSynopsis);
given(episodeService.firstPlayableByDramas(List.of(1L))).willReturn(Map.of());
given(dramaCategoryService.listBasic()).willReturn(List.of());
given(commentService.countByEpisodes(List.of())).willReturn(Map.of());
List<AppDramaCardVO> cards = catalogService.toCards(List.of(drama));
assertThat(cards).hasSize(1);
assertThat(cards.get(0).getSynopsisLocales())
.containsEntry("zh", longSynopsis);
}
@Test @Test
void should_filter_feed_by_drama_type_and_expose_it_on_card() { void should_filter_feed_by_drama_type_and_expose_it_on_card() {
Drama anime = drama(1L, "ANIME"); Drama anime = drama(1L, "ANIME");
@@ -0,0 +1,75 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.app.vo.AppRankingVO;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaClassification;
import com.fruitshorts.content.service.DramaClassificationService;
import com.fruitshorts.content.service.DramaService;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/** 推荐榜快照顺序与排名测试。 */
class AppRankingServiceTest {
@Test
void should_preserve_server_order_and_compact_rank_when_offline_items_are_filtered() {
DramaClassificationService classificationService = mock(DramaClassificationService.class);
DramaService dramaService = mock(DramaService.class);
AppCatalogService catalogService = mock(AppCatalogService.class);
AppRankingService service = new AppRankingService(
classificationService, dramaService, catalogService);
DramaClassification classification = rankingClassification();
Drama second = drama(22L);
Drama first = drama(11L);
given(classificationService.getEnabledById(3L)).willReturn(classification);
given(classificationService.listDramaIdsByClassification(3L))
.willReturn(List.of(11L, 99L, 22L));
given(dramaService.listPublishedByIds(List.of(11L, 99L, 22L)))
.willReturn(List.of(second, first));
AppDramaCardVO firstCard = card(11L);
AppDramaCardVO secondCard = card(22L);
given(catalogService.toCards(List.of(first, second)))
.willReturn(List.of(firstCard, secondCard));
AppRankingVO result = service.ranking(3L);
assertThat(result.getRevision()).isEqualTo(7L);
assertThat(result.getList()).extracting(item -> item.getDrama().getId())
.containsExactly(11L, 22L);
assertThat(result.getList()).extracting(item -> item.getRank())
.containsExactly(1, 2);
}
private DramaClassification rankingClassification() {
DramaClassification classification = new DramaClassification();
classification.setId(3L);
classification.setCode("RECOMMEND_RANKING");
classification.setName("推荐榜");
classification.setNameLocales("{\"zh\":\"推荐榜\"}");
classification.setLayoutType(DramaClassificationService.LAYOUT_RANKING);
classification.setRevision(7L);
return classification;
}
private Drama drama(Long id) {
Drama drama = new Drama();
drama.setId(id);
return drama;
}
private AppDramaCardVO card(Long id) {
AppDramaCardVO card = new AppDramaCardVO();
card.setId(id);
card.setTitleLocales(Map.of("zh", "短剧" + id));
return card;
}
}
@@ -24,6 +24,8 @@ public class ContentTag {
private String nameLocales; private String nameLocales;
/** 角标背景色,#RRGGBB。 */ /** 角标背景色,#RRGGBB。 */
private String bgColor; private String bgColor;
/** 语义码,如 NEW_ARRIVAL;客户端按码判断板块,不解析文案。空表示未配置。 */
private String code;
private Integer sort; private Integer sort;
private Integer status; private Integer status;
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -18,10 +18,16 @@ public class DramaClassification {
private Long id; private Long id;
/** 中文名称。 */ /** 中文名称。 */
private String name; private String name;
/** 稳定语义码,不随展示名称变化。 */
private String code;
private String nameEn; private String nameEn;
private String nameId; private String nameId;
/** 已选语言的名称。 */ /** 已选语言的名称。 */
private String nameLocales; private String nameLocales;
/** 客户端布局:GRID / RANKING。 */
private String layoutType;
/** 内容或顺序版本。 */
private Long revision;
private Integer sort; private Integer sort;
private Integer status; private Integer status;
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -18,5 +18,7 @@ public class DramaClassificationDrama {
private Long id; private Long id;
private Long classificationId; private Long classificationId;
private Long dramaId; private Long dramaId;
/** 分类内展示位置,从 1 开始。 */
private Integer position;
private LocalDateTime createdAt; private LocalDateTime createdAt;
} }
@@ -49,9 +49,12 @@ public class ContentTagService {
public Long create(ContentTagSaveReqVO req) { public Long create(ContentTagSaveReqVO req) {
Map<String, String> locales = namesOf(req); Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), null); ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), null);
String code = normalizeCode(req.getCode());
ensureCodeAvailable(code, null);
ContentTag entity = new ContentTag(); ContentTag entity = new ContentTag();
writeNames(entity, locales); writeNames(entity, locales);
entity.setBgColor(normalizeColor(req.getBgColor())); entity.setBgColor(normalizeColor(req.getBgColor()));
entity.setCode(code);
Long count = tagMapper.selectCount(null); Long count = tagMapper.selectCount(null);
entity.setSort(req.getSort() == null entity.setSort(req.getSort() == null
? (count == null ? 0 : count.intValue()) + 1 ? (count == null ? 0 : count.intValue()) + 1
@@ -69,6 +72,11 @@ public class ContentTagService {
if (req.getBgColor() != null) { if (req.getBgColor() != null) {
entity.setBgColor(normalizeColor(req.getBgColor())); entity.setBgColor(normalizeColor(req.getBgColor()));
} }
if (req.getCode() != null) {
String code = normalizeCode(req.getCode());
ensureCodeAvailable(code, id);
entity.setCode(code);
}
if (req.getSort() != null) { if (req.getSort() != null) {
entity.setSort(req.getSort()); entity.setSort(req.getSort());
} }
@@ -115,6 +123,23 @@ public class ContentTagService {
return color.trim().toUpperCase(); return color.trim().toUpperCase();
} }
/** 语义码统一大写;空字符串表示不配置。 */
private String normalizeCode(String code) {
return code == null ? "" : code.trim().toUpperCase();
}
private void ensureCodeAvailable(String code, Long excludeId) {
if (code.isEmpty()) {
return;
}
Long count = tagMapper.selectCount(Wrappers.<ContentTag>lambdaQuery()
.eq(ContentTag::getCode, code)
.ne(excludeId != null, ContentTag::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "语义码已存在");
}
}
private ContentTag getExisting(Long id) { private ContentTag getExisting(Long id) {
ContentTag entity = id == null ? null : tagMapper.selectById(id); ContentTag entity = id == null ? null : tagMapper.selectById(id);
if (entity == null) { if (entity == null) {
@@ -142,6 +167,7 @@ public class ContentTagService {
vo.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID)); vo.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
vo.setLocales(locales); vo.setLocales(locales);
vo.setBgColor(tag.getBgColor() == null || tag.getBgColor().isBlank() ? "#E5484D" : tag.getBgColor()); vo.setBgColor(tag.getBgColor() == null || tag.getBgColor().isBlank() ? "#E5484D" : tag.getBgColor());
vo.setCode(tag.getCode() == null ? "" : tag.getCode());
vo.setSort(tag.getSort()); vo.setSort(tag.getSort());
vo.setStatus(tag.getStatus()); vo.setStatus(tag.getStatus());
return vo; return vo;
@@ -27,7 +27,6 @@ import org.springframework.util.StringUtils;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors;
/** /**
* 运营分类服务。分类是独立于「短剧类型(drama_category)」的一套概念, * 运营分类服务。分类是独立于「短剧类型(drama_category)」的一套概念,
@@ -37,9 +36,15 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor @RequiredArgsConstructor
public class DramaClassificationService { public class DramaClassificationService {
public static final String LAYOUT_GRID = "GRID";
public static final String LAYOUT_RANKING = "RANKING";
private static final long INITIAL_REVISION = 1L;
private static final String DEFAULT_CODE_PREFIX = "CLASSIFICATION_";
private final DramaClassificationMapper classificationMapper; private final DramaClassificationMapper classificationMapper;
private final DramaClassificationDramaMapper classificationDramaMapper; private final DramaClassificationDramaMapper classificationDramaMapper;
private final DramaMapper dramaMapper; private final DramaMapper dramaMapper;
private final DramaRankingService rankingService;
public List<DramaClassificationRespVO> list() { public List<DramaClassificationRespVO> list() {
Map<Long, Integer> counts = countDramas(); Map<Long, Integer> counts = countDramas();
@@ -49,12 +54,15 @@ public class DramaClassificationService {
.stream().map(item -> { .stream().map(item -> {
DramaClassificationRespVO vo = new DramaClassificationRespVO(); DramaClassificationRespVO vo = new DramaClassificationRespVO();
vo.setId(item.getId()); vo.setId(item.getId());
vo.setCode(codeOf(item));
Map<String, String> locales = ContentLocaleTexts.read( Map<String, String> locales = ContentLocaleTexts.read(
item.getNameLocales(), item.getName(), item.getNameEn(), item.getNameId()); item.getNameLocales(), item.getName(), item.getNameEn(), item.getNameId());
vo.setName(locales.get(ContentLocaleTexts.ZH)); vo.setName(locales.get(ContentLocaleTexts.ZH));
vo.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN)); vo.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
vo.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID)); vo.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
vo.setLocales(locales); vo.setLocales(locales);
vo.setLayoutType(layoutOf(item));
vo.setRevision(revisionOf(item));
vo.setSort(item.getSort()); vo.setSort(item.getSort());
vo.setStatus(item.getStatus()); vo.setStatus(item.getStatus());
vo.setUpdatedAt(item.getUpdatedAt()); vo.setUpdatedAt(item.getUpdatedAt());
@@ -68,12 +76,18 @@ public class DramaClassificationService {
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), null); ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), null);
DramaClassification entity = new DramaClassification(); DramaClassification entity = new DramaClassification();
writeNames(entity, locales); writeNames(entity, locales);
writeClassificationSettings(entity, req);
Long count = classificationMapper.selectCount(null); Long count = classificationMapper.selectCount(null);
entity.setSort(req.getSort() == null entity.setSort(req.getSort() == null
? (count == null ? 0 : count.intValue()) + 1 ? (count == null ? 0 : count.intValue()) + 1
: req.getSort()); : req.getSort());
entity.setStatus(req.getStatus() == null ? 1 : req.getStatus()); entity.setStatus(req.getStatus() == null ? 1 : req.getStatus());
entity.setRevision(INITIAL_REVISION);
classificationMapper.insert(entity); classificationMapper.insert(entity);
if (!StringUtils.hasText(entity.getCode())) {
entity.setCode(DEFAULT_CODE_PREFIX + entity.getId());
classificationMapper.updateById(entity);
}
return entity.getId(); return entity.getId();
} }
@@ -82,6 +96,7 @@ public class DramaClassificationService {
Map<String, String> locales = namesOf(req); Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), id); ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), id);
writeNames(entity, locales); writeNames(entity, locales);
writeClassificationSettings(entity, req);
if (req.getSort() != null) { if (req.getSort() != null) {
entity.setSort(req.getSort()); entity.setSort(req.getSort());
} }
@@ -114,7 +129,7 @@ public class DramaClassificationService {
/** 批量关联短剧;已关联的自动跳过。 */ /** 批量关联短剧;已关联的自动跳过。 */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public int bindDramas(Long id, List<Long> dramaIds) { public int bindDramas(Long id, List<Long> dramaIds) {
getExisting(id); ensureGridMutation(getExisting(id));
if (dramaIds == null || dramaIds.isEmpty()) { if (dramaIds == null || dramaIds.isEmpty()) {
return 0; return 0;
} }
@@ -133,6 +148,7 @@ public class DramaClassificationService {
DramaClassificationDrama relation = new DramaClassificationDrama(); DramaClassificationDrama relation = new DramaClassificationDrama();
relation.setClassificationId(id); relation.setClassificationId(id);
relation.setDramaId(dramaId); relation.setDramaId(dramaId);
relation.setPosition(rankingService.nextPosition(id));
classificationDramaMapper.insert(relation); classificationDramaMapper.insert(relation);
added++; added++;
} }
@@ -141,15 +157,24 @@ public class DramaClassificationService {
/** 移出分类;短剧本身不受影响。 */ /** 移出分类;短剧本身不受影响。 */
public void unbindDrama(Long id, Long dramaId) { public void unbindDrama(Long id, Long dramaId) {
getExisting(id); ensureGridMutation(getExisting(id));
classificationDramaMapper.delete(Wrappers.<DramaClassificationDrama>lambdaQuery() classificationDramaMapper.delete(Wrappers.<DramaClassificationDrama>lambdaQuery()
.eq(DramaClassificationDrama::getClassificationId, id) .eq(DramaClassificationDrama::getClassificationId, id)
.eq(DramaClassificationDrama::getDramaId, dramaId)); .eq(DramaClassificationDrama::getDramaId, dramaId));
} }
/** 原子替换推荐榜内容与顺序,成功后递增版本。 */
@Transactional(rollbackFor = Exception.class)
public void saveRankingItems(Long id, List<Long> dramaIds) {
rankingService.saveItems(id, dramaIds);
}
private PageResult<DramaBriefVO> pageDramas(Long id, String keyword, private PageResult<DramaBriefVO> pageDramas(Long id, String keyword,
int pageIndex, int pageSize, boolean bound) { int pageIndex, int pageSize, boolean bound) {
getExisting(id); DramaClassification classification = getExisting(id);
if (bound && LAYOUT_RANKING.equals(layoutOf(classification))) {
return rankingService.page(id, keyword, pageIndex, pageSize);
}
Page<Drama> page = new Page<>(Math.max(1, pageIndex), Math.max(1, Math.min(pageSize, 100))); Page<Drama> page = new Page<>(Math.max(1, pageIndex), Math.max(1, Math.min(pageSize, 100)));
LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>lambdaQuery() LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>lambdaQuery()
.like(StringUtils.hasText(keyword), Drama::getTitle, keyword) .like(StringUtils.hasText(keyword), Drama::getTitle, keyword)
@@ -228,8 +253,12 @@ public class DramaClassificationService {
return entity; return entity;
} }
/** C 端:分类下关联的全部 dramaId(按关联创建时间 desc)。 */ /** C 端:普通分类保留历史倒序,推荐榜严格按 position 升序。 */
public List<Long> listDramaIdsByClassification(Long classificationId) { public List<Long> listDramaIdsByClassification(Long classificationId) {
DramaClassification classification = getExisting(classificationId);
if (LAYOUT_RANKING.equals(layoutOf(classification))) {
return rankingService.orderedDramaIds(classificationId);
}
return classificationDramaMapper.selectList( return classificationDramaMapper.selectList(
Wrappers.<DramaClassificationDrama>lambdaQuery() Wrappers.<DramaClassificationDrama>lambdaQuery()
.eq(DramaClassificationDrama::getClassificationId, classificationId) .eq(DramaClassificationDrama::getClassificationId, classificationId)
@@ -237,10 +266,14 @@ public class DramaClassificationService {
.orderByDesc(DramaClassificationDrama::getId)) .orderByDesc(DramaClassificationDrama::getId))
.stream() .stream()
.map(DramaClassificationDrama::getDramaId) .map(DramaClassificationDrama::getDramaId)
.collect(Collectors.toList()); .toList();
} }
private DramaBriefVO toBrief(Drama drama) { private DramaBriefVO toBrief(Drama drama) {
return toBrief(drama, null);
}
private DramaBriefVO toBrief(Drama drama, Integer position) {
DramaBriefVO vo = new DramaBriefVO(); DramaBriefVO vo = new DramaBriefVO();
vo.setId(drama.getId()); vo.setId(drama.getId());
vo.setTitle(drama.getTitle()); vo.setTitle(drama.getTitle());
@@ -248,7 +281,54 @@ public class DramaClassificationService {
vo.setStatus(drama.getStatus()); vo.setStatus(drama.getStatus());
vo.setTotalEpisodes(drama.getTotalEpisodes()); vo.setTotalEpisodes(drama.getTotalEpisodes());
vo.setSort(drama.getSort()); vo.setSort(drama.getSort());
vo.setPosition(position);
vo.setUpdatedAt(drama.getUpdatedAt()); vo.setUpdatedAt(drama.getUpdatedAt());
return vo; return vo;
} }
private void writeClassificationSettings(DramaClassification entity,
DramaClassificationSaveReqVO req) {
if (StringUtils.hasText(req.getCode())) {
String code = req.getCode().trim();
ensureCodeAvailable(code, entity.getId());
entity.setCode(code);
}
if (StringUtils.hasText(req.getLayoutType())) {
entity.setLayoutType(req.getLayoutType());
} else if (!StringUtils.hasText(entity.getLayoutType())) {
entity.setLayoutType(LAYOUT_GRID);
}
}
private void ensureCodeAvailable(String code, Long excludeId) {
Long count = classificationMapper.selectCount(Wrappers.<DramaClassification>lambdaQuery()
.eq(DramaClassification::getCode, code)
.ne(excludeId != null, DramaClassification::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "分类编码已存在");
}
}
private String codeOf(DramaClassification classification) {
return StringUtils.hasText(classification.getCode())
? classification.getCode()
: DEFAULT_CODE_PREFIX + classification.getId();
}
private String layoutOf(DramaClassification classification) {
return StringUtils.hasText(classification.getLayoutType())
? classification.getLayoutType()
: LAYOUT_GRID;
}
private long revisionOf(DramaClassification classification) {
return classification.getRevision() == null ? INITIAL_REVISION : classification.getRevision();
}
private void ensureGridMutation(DramaClassification classification) {
if (LAYOUT_RANKING.equals(layoutOf(classification))) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"推荐榜请使用完整排序接口维护内容");
}
}
} }
@@ -0,0 +1,161 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.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 lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
/** 推荐榜内容、顺序和版本服务。 */
@Service
@RequiredArgsConstructor
public class DramaRankingService {
private static final int MAX_RANKING_ITEMS = 50;
private static final long INITIAL_REVISION = 1L;
private final DramaClassificationMapper classificationMapper;
private final DramaClassificationDramaMapper relationMapper;
private final DramaMapper dramaMapper;
/** 原子替换推荐榜内容与顺序,成功后递增版本。 */
@Transactional(rollbackFor = Exception.class)
public void saveItems(Long classificationId, List<Long> dramaIds) {
DramaClassification classification = getRanking(classificationId);
List<Long> orderedIds = orderedIds(dramaIds);
ensureDramasExist(orderedIds);
relationMapper.delete(Wrappers.<DramaClassificationDrama>lambdaQuery()
.eq(DramaClassificationDrama::getClassificationId, classificationId));
for (int index = 0; index < orderedIds.size(); index++) {
relationMapper.insert(relation(classificationId, orderedIds.get(index), index + 1));
}
long revision = classification.getRevision() == null
? INITIAL_REVISION
: classification.getRevision();
classification.setRevision(revision + 1);
classificationMapper.updateById(classification);
}
/** 推荐榜后台分页,严格按持久化位置返回。 */
public PageResult<DramaBriefVO> page(Long classificationId, String keyword,
int pageIndex, int pageSize) {
List<DramaClassificationDrama> relations = orderedRelations(classificationId);
if (relations.isEmpty()) {
return PageResult.empty();
}
List<Long> ids = relations.stream().map(DramaClassificationDrama::getDramaId).toList();
Map<Long, Drama> dramas = dramaMapper.selectBatchIds(ids).stream()
.collect(Collectors.toMap(Drama::getId, Function.identity()));
List<DramaBriefVO> all = relations.stream()
.filter(item -> dramas.containsKey(item.getDramaId()))
.map(item -> toBrief(dramas.get(item.getDramaId()), item.getPosition()))
.filter(item -> !StringUtils.hasText(keyword) || item.getTitle().contains(keyword))
.toList();
return pageOf(all, pageIndex, pageSize);
}
/** 分类内全部短剧 id,严格按 position 升序。 */
public List<Long> orderedDramaIds(Long classificationId) {
return orderedRelations(classificationId).stream()
.map(DramaClassificationDrama::getDramaId)
.toList();
}
/** 新增关联时使用的末尾位置。 */
public int nextPosition(Long classificationId) {
return orderedRelations(classificationId).stream()
.map(DramaClassificationDrama::getPosition)
.filter(Objects::nonNull)
.max(Integer::compareTo)
.orElse(0) + 1;
}
private DramaClassification getRanking(Long id) {
DramaClassification classification = id == null ? null : classificationMapper.selectById(id);
if (classification == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "分类不存在");
}
if (!DramaClassificationService.LAYOUT_RANKING.equals(classification.getLayoutType())) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "只有推荐榜分类可以保存榜单顺序");
}
return classification;
}
private List<Long> orderedIds(List<Long> dramaIds) {
LinkedHashSet<Long> unique = new LinkedHashSet<>();
if (dramaIds != null) {
dramaIds.stream().filter(Objects::nonNull).forEach(unique::add);
}
if (unique.size() > MAX_RANKING_ITEMS) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "推荐榜最多 50 部短剧");
}
return List.copyOf(unique);
}
private void ensureDramasExist(List<Long> dramaIds) {
if (dramaIds.isEmpty()) {
return;
}
if (dramaMapper.selectBatchIds(dramaIds).size() != dramaIds.size()) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "榜单中包含不存在的短剧");
}
}
private List<DramaClassificationDrama> orderedRelations(Long classificationId) {
return relationMapper.selectList(Wrappers.<DramaClassificationDrama>lambdaQuery()
.eq(DramaClassificationDrama::getClassificationId, classificationId)
.orderByAsc(DramaClassificationDrama::getPosition)
.orderByDesc(DramaClassificationDrama::getId));
}
private DramaClassificationDrama relation(Long classificationId, Long dramaId, int position) {
DramaClassificationDrama relation = new DramaClassificationDrama();
relation.setClassificationId(classificationId);
relation.setDramaId(dramaId);
relation.setPosition(position);
return relation;
}
private DramaBriefVO toBrief(Drama drama, Integer position) {
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.setPosition(position);
vo.setUpdatedAt(drama.getUpdatedAt());
return vo;
}
private PageResult<DramaBriefVO> pageOf(List<DramaBriefVO> items, int pageIndex, int pageSize) {
int safeIndex = Math.max(1, pageIndex);
int safeSize = Math.max(1, Math.min(pageSize, 100));
int fromIndex = (safeIndex - 1) * safeSize;
if (fromIndex >= items.size()) {
return new PageResult<>(items.size(), List.of());
}
int toIndex = Math.min(fromIndex + safeSize, items.size());
return new PageResult<>(items.size(), items.subList(fromIndex, toIndex));
}
}
@@ -162,6 +162,12 @@ public class DramaService {
requireState(current, "APPROVE", Set.of(ContentStatus.REVIEWING.getCode())); requireState(current, "APPROVE", Set.of(ContentStatus.REVIEWING.getCode()));
yield ContentStatus.APPROVED.getCode(); yield ContentStatus.APPROVED.getCode();
} }
case "APPROVE_PUBLISH" -> {
// 一步审核通过并上线,减少后台操作步骤
requireState(current, "APPROVE_PUBLISH", Set.of(ContentStatus.DRAFT.getCode(),
ContentStatus.REVIEWING.getCode(), ContentStatus.OFFLINE.getCode()));
yield ContentStatus.PUBLISHED.getCode();
}
case "REJECT" -> { case "REJECT" -> {
requireState(current, "REJECT", Set.of(ContentStatus.REVIEWING.getCode())); requireState(current, "REJECT", Set.of(ContentStatus.REVIEWING.getCode()));
yield ContentStatus.DRAFT.getCode(); yield ContentStatus.DRAFT.getCode();
@@ -464,10 +470,13 @@ public class DramaService {
if (isNew || req.getCover() != null) { if (isNew || req.getCover() != null) {
drama.setCoverUrl(req.getCover() == null ? "" : req.getCover()); drama.setCoverUrl(req.getCover() == null ? "" : req.getCover());
} }
if (isNew || req.getHorizontalCover() != null) {
drama.setHorizontalCoverUrl(
req.getHorizontalCover() == null ? "" : req.getHorizontalCover());
}
if (isNew || req.getTrailerUrl() != null) { if (isNew || req.getTrailerUrl() != null) {
drama.setTrailerUrl(req.getTrailerUrl() == null ? "" : req.getTrailerUrl()); drama.setTrailerUrl(req.getTrailerUrl() == null ? "" : req.getTrailerUrl());
} }
drama.setHorizontalCoverUrl(drama.getHorizontalCoverUrl() == null ? "" : drama.getHorizontalCoverUrl());
if (isNew || req.getLanguage() != null) { if (isNew || req.getLanguage() != null) {
drama.setLanguage(req.getLanguage() == null ? "zh-CN" : req.getLanguage()); drama.setLanguage(req.getLanguage() == null ? "zh-CN" : req.getLanguage());
} }
@@ -530,6 +539,7 @@ public class DramaService {
vo.setSynopsisLocales(synopses); vo.setSynopsisLocales(synopses);
vo.setSubtitle(drama.getSubtitle()); vo.setSubtitle(drama.getSubtitle());
vo.setCover(drama.getCoverUrl()); vo.setCover(drama.getCoverUrl());
vo.setHorizontalCover(drama.getHorizontalCoverUrl());
vo.setTrailerUrl(drama.getTrailerUrl()); vo.setTrailerUrl(drama.getTrailerUrl());
vo.setLanguage(drama.getLanguage()); vo.setLanguage(drama.getLanguage());
vo.setDramaType(dramaTypes.get(0)); vo.setDramaType(dramaTypes.get(0));
@@ -14,6 +14,8 @@ public class ContentTagRespVO {
private String nameId; private String nameId;
private java.util.Map<String, String> locales; private java.util.Map<String, String> locales;
private String bgColor; private String bgColor;
/** 语义码,如 NEW_ARRIVAL;空表示未配置。 */
private String code;
private Integer sort; private Integer sort;
private Integer status; private Integer status;
} }
@@ -21,6 +21,10 @@ public class ContentTagSaveReqVO {
@Pattern(regexp = "^$|^#[0-9A-Fa-f]{6}$", message = "背景色格式应为 #RRGGBB") @Pattern(regexp = "^$|^#[0-9A-Fa-f]{6}$", message = "背景色格式应为 #RRGGBB")
private String bgColor; private String bgColor;
/** 语义码,如 NEW_ARRIVAL;客户端按码判断板块,留空表示按文案兜底。 */
@Pattern(regexp = "^$|^[A-Z][A-Z0-9_]{0,31}$", message = "语义码需为大写字母、数字或下划线")
private String code;
private Integer sort; private Integer sort;
private Integer status; private Integer status;
} }
@@ -16,5 +16,7 @@ public class DramaBriefVO {
private String status; private String status;
private Integer totalEpisodes; private Integer totalEpisodes;
private Integer sort; private Integer sort;
/** 分类内展示位置。 */
private Integer position;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
} }
@@ -12,9 +12,12 @@ public class DramaClassificationRespVO {
private Long id; private Long id;
private String name; private String name;
private String code;
private String nameEn; private String nameEn;
private String nameId; private String nameId;
private java.util.Map<String, String> locales; private java.util.Map<String, String> locales;
private String layoutType;
private Long revision;
private Integer sort; private Integer sort;
private Integer status; private Integer status;
private Integer dramaCount; private Integer dramaCount;
@@ -3,6 +3,7 @@
package com.fruitshorts.content.vo; package com.fruitshorts.content.vo;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.Pattern;
import lombok.Data; import lombok.Data;
/** 分类新增/编辑请求体。 */ /** 分类新增/编辑请求体。 */
@@ -17,6 +18,13 @@ public class DramaClassificationSaveReqVO {
/** 勾选的语言名称,key 为 zh / en / id。 */ /** 勾选的语言名称,key 为 zh / en / id。 */
private java.util.Map<String, String> locales; private java.util.Map<String, String> locales;
@Size(max = 64, message = "分类编码最多 64 个字符")
@Pattern(regexp = "^[A-Z0-9_]*$", message = "分类编码只能包含大写字母、数字和下划线")
private String code;
@Pattern(regexp = "^(GRID|RANKING)$", message = "展示类型只支持 GRID 或 RANKING")
private String layoutType;
private Integer sort; private Integer sort;
private Integer status; private Integer status;
} }
@@ -0,0 +1,18 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
/** 推荐榜完整有序短剧列表。 */
@Data
public class DramaRankingItemsReqVO {
@NotNull(message = "请提交推荐榜短剧列表")
@Size(max = 50, message = "推荐榜最多 50 部短剧")
private List<Long> dramaIds;
}
@@ -27,6 +27,8 @@ public class DramaRespVO {
private String description; private String description;
private java.util.Map<String, String> synopsisLocales; private java.util.Map<String, String> synopsisLocales;
private String cover; private String cover;
/** 横版封面(16:9)。 */
private String horizontalCover;
/** 整剧预告片地址 */ /** 整剧预告片地址 */
private String trailerUrl; private String trailerUrl;
private String language; private String language;
@@ -26,6 +26,8 @@ public class DramaSaveReqVO {
/** 勾选语言的简介。 */ /** 勾选语言的简介。 */
private java.util.Map<String, String> synopsisLocales; private java.util.Map<String, String> synopsisLocales;
private String cover; private String cover;
/** 横版封面(16:9),用于横幅等横向位;不传则保持原值。 */
private String horizontalCover;
/** 整剧预告片地址(复用已有的 drama.trailer_url 列) */ /** 整剧预告片地址(复用已有的 drama.trailer_url 列) */
private String trailerUrl; private String trailerUrl;
private String language; private String language;
@@ -0,0 +1,82 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import com.fruitshorts.common.exception.ServiceException;
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 org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/** 运营分类布局隔离测试。 */
class DramaClassificationServiceTest {
@Test
void should_keep_legacy_relation_order_when_classification_is_grid() {
Fixture fixture = fixture(DramaClassificationService.LAYOUT_GRID);
given(fixture.relationMapper().selectList(any())).willReturn(List.of(
relation(21L), relation(13L)));
List<Long> ids = fixture.service().listDramaIdsByClassification(7L);
assertThat(ids).containsExactly(21L, 13L);
verify(fixture.rankingService(), never()).orderedDramaIds(any());
}
@Test
void should_use_position_order_when_classification_is_ranking() {
Fixture fixture = fixture(DramaClassificationService.LAYOUT_RANKING);
given(fixture.rankingService().orderedDramaIds(7L)).willReturn(List.of(13L, 21L));
List<Long> ids = fixture.service().listDramaIdsByClassification(7L);
assertThat(ids).containsExactly(13L, 21L);
verify(fixture.relationMapper(), never()).selectList(any());
}
@Test
void should_reject_legacy_bind_when_classification_is_ranking() {
Fixture fixture = fixture(DramaClassificationService.LAYOUT_RANKING);
assertThatThrownBy(() -> fixture.service().bindDramas(7L, List.of(13L)))
.isInstanceOf(ServiceException.class)
.hasMessageContaining("推荐榜请使用完整排序接口");
}
private Fixture fixture(String layoutType) {
DramaClassificationMapper classificationMapper = mock(DramaClassificationMapper.class);
DramaClassificationDramaMapper relationMapper = mock(DramaClassificationDramaMapper.class);
DramaMapper dramaMapper = mock(DramaMapper.class);
DramaRankingService rankingService = mock(DramaRankingService.class);
DramaClassification classification = new DramaClassification();
classification.setId(7L);
classification.setLayoutType(layoutType);
given(classificationMapper.selectById(7L)).willReturn(classification);
DramaClassificationService service = new DramaClassificationService(
classificationMapper, relationMapper, dramaMapper, rankingService);
return new Fixture(service, relationMapper, rankingService);
}
private DramaClassificationDrama relation(Long dramaId) {
DramaClassificationDrama relation = new DramaClassificationDrama();
relation.setDramaId(dramaId);
return relation;
}
private record Fixture(DramaClassificationService service,
DramaClassificationDramaMapper relationMapper,
DramaRankingService rankingService) {
}
}
@@ -0,0 +1,60 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
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 org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.List;
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.times;
import static org.mockito.Mockito.verify;
/** 推荐榜内容保存与版本测试。 */
class DramaRankingServiceTest {
@Test
void should_replace_items_in_request_order_and_increment_revision() {
DramaClassificationMapper classificationMapper = mock(DramaClassificationMapper.class);
DramaClassificationDramaMapper relationMapper = mock(DramaClassificationDramaMapper.class);
DramaMapper dramaMapper = mock(DramaMapper.class);
DramaRankingService service = new DramaRankingService(
classificationMapper, relationMapper, dramaMapper);
DramaClassification classification = new DramaClassification();
classification.setId(7L);
classification.setLayoutType(DramaClassificationService.LAYOUT_RANKING);
classification.setRevision(4L);
given(classificationMapper.selectById(7L)).willReturn(classification);
given(dramaMapper.selectBatchIds(List.of(22L, 11L)))
.willReturn(List.of(drama(11L), drama(22L)));
service.saveItems(7L, List.of(22L, 11L, 22L));
ArgumentCaptor<DramaClassificationDrama> captor =
ArgumentCaptor.forClass(DramaClassificationDrama.class);
verify(relationMapper, times(2)).insert(captor.capture());
assertThat(captor.getAllValues()).extracting(DramaClassificationDrama::getDramaId)
.containsExactly(22L, 11L);
assertThat(captor.getAllValues()).extracting(DramaClassificationDrama::getPosition)
.containsExactly(1, 2);
assertThat(classification.getRevision()).isEqualTo(5L);
verify(classificationMapper).updateById(classification);
verify(relationMapper).delete(any());
}
private Drama drama(Long id) {
Drama drama = new Drama();
drama.setId(id);
return drama;
}
}
@@ -4,11 +4,13 @@ package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode; import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper; import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper; import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper; import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
import com.fruitshorts.content.enums.ContentStatus;
import com.fruitshorts.content.vo.DramaSaveReqVO; import com.fruitshorts.content.vo.DramaSaveReqVO;
import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
@@ -25,6 +27,7 @@ import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
@@ -159,6 +162,45 @@ class DramaServiceTest {
verify(dramaEpisodeMapper, never()).updateById(any(DramaEpisode.class)); verify(dramaEpisodeMapper, never()).updateById(any(DramaEpisode.class));
} }
@Test
void should_publish_directly_from_draft_when_approve_publish() {
Drama drama = givenDramaWithStatus(ContentStatus.DRAFT.getCode());
dramaService.changeStatus(DRAMA_ID, "APPROVE_PUBLISH", "", 1L, "admin");
assertThat(drama.getStatus()).isEqualTo(ContentStatus.PUBLISHED.getCode());
assertThat(drama.getPublishedAt()).isNotNull();
verify(dramaMapper).updateById(drama);
// 上线时同步把草稿分集发布
verify(dramaEpisodeMapper).update(any(), any());
}
@Test
void should_publish_directly_from_reviewing_when_approve_publish() {
Drama drama = givenDramaWithStatus(ContentStatus.REVIEWING.getCode());
dramaService.changeStatus(DRAMA_ID, "APPROVE_PUBLISH", "", 1L, "admin");
assertThat(drama.getStatus()).isEqualTo(ContentStatus.PUBLISHED.getCode());
}
@Test
void should_reject_approve_publish_when_already_published() {
givenDramaWithStatus(ContentStatus.PUBLISHED.getCode());
assertThatThrownBy(() -> dramaService.changeStatus(DRAMA_ID, "APPROVE_PUBLISH", "", 1L, "admin"))
.isInstanceOf(ServiceException.class);
}
private Drama givenDramaWithStatus(String status) {
Drama drama = new Drama();
drama.setId(DRAMA_ID);
drama.setStatus(status);
given(dramaMapper.selectById(DRAMA_ID)).willReturn(drama);
given(dramaMapper.updateById(any(Drama.class))).willReturn(1);
return drama;
}
private void givenExistingDrama(int freeEpisodes, int unlockCoin) { private void givenExistingDrama(int freeEpisodes, int unlockCoin) {
Drama drama = new Drama(); Drama drama = new Drama();
drama.setId(DRAMA_ID); drama.setId(DRAMA_ID);
@@ -19,7 +19,7 @@ import org.springframework.util.StringUtils;
public class EpisodeTranscodeBindingService { public class EpisodeTranscodeBindingService {
private final MediaAssetMapper mediaAssetMapper; private final MediaAssetMapper mediaAssetMapper;
private final MediaStorageService storageService; private final MediaStorage storageService;
private final TranscodeService transcodeService; private final TranscodeService transcodeService;
/** /**
@@ -38,7 +38,7 @@ public class MediaService {
Set.of(".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".avif"); Set.of(".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".avif");
private final MediaProperties properties; private final MediaProperties properties;
private final MediaStorageService storageService; private final MediaStorage storageService;
private final MediaAssetMapper mediaAssetMapper; private final MediaAssetMapper mediaAssetMapper;
private final MediaUploadTaskMapper mediaUploadTaskMapper; private final MediaUploadTaskMapper mediaUploadTaskMapper;
private final TranscodeService transcodeService; private final TranscodeService transcodeService;
@@ -104,7 +104,7 @@ public class MediaService {
"分片未传完(" + chunks + "/" + task.getTotalChunks() + ")"); "分片未传完(" + chunks + "/" + task.getTotalChunks() + ")");
} }
MediaStorageService.MergeResult merged = MediaStorage.MergeResult merged =
storageService.merge(req.getUploadId(), task.getObjectKey(), task.getTotalChunks()); storageService.merge(req.getUploadId(), task.getObjectKey(), task.getTotalChunks());
String publicUrl = storageService.toPublicUrl(task.getObjectKey()); String publicUrl = storageService.toPublicUrl(task.getObjectKey());
@@ -0,0 +1,58 @@
// Author: fruit.com.cn
package com.fruitshorts.media.service;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Path;
/**
* 媒资存储抽象:当前实现为本地磁盘({@link MediaStorageService}),
* 后期新增 S3 实现后以配置切换,业务代码不感知。
*
* <p>S3 实现要点:分片走 multipart upload 或本地暂存后 putObject;
* {@link #fileFor(String)} 改成「下载到临时目录 / 上传回对象存储」的适配(ffmpeg 需要本地文件);
* {@link #toPublicUrl(String)} 返回 CDN / 对象存储域名地址。
*/
public interface MediaStorage {
/** 合并结果:落地路径、大小与 MD5。 */
record MergeResult(Path path, long size, String md5) {
}
/** 存储根目录(本地实现为磁盘目录;S3 实现为本地暂存目录)。 */
Path rootDir();
/**
* 把 objectKey 解析成本地可读写的路径。转码产物落地用它。
* 实现方必须校验 objectKey 不允许跑出根目录。
*/
Path fileFor(String objectKey);
/** 把对外 URL 反解成 objectKey(去掉前缀与签名查询串)。 */
String objectKeyOf(String publicUrl);
/** 分片临时目录。 */
Path chunkDir(String uploadId);
/** 保存一个分片。 */
void saveChunk(String uploadId, int index, MultipartFile file);
/** 已收到的分片数。 */
int countChunks(String uploadId);
/** 按分片序号顺序合并成正式文件,返回大小与 MD5。 */
MergeResult merge(String uploadId, String objectKey, int totalChunks);
/** 清理分片临时目录。 */
void cleanChunkDir(String uploadId);
/** 删除一个媒资文件。 */
void delete(String objectKey);
/** 生成对象键:bizType/yyyyMM/uuid.ext。 */
String buildObjectKey(String bizType, String originalName);
/** objectKey 转对外访问 URL。 */
String toPublicUrl(String objectKey);
}
@@ -28,20 +28,17 @@ import java.util.UUID;
/** /**
* 本地磁盘媒资存储:分片写临时目录,完成后合并成正式文件。 * 本地磁盘媒资存储:分片写临时目录,完成后合并成正式文件。
* 之后要换成对象存储时,只需替换本类实现。 * 是 {@link MediaStorage} 的本地实现;切对象存储时新增实现类即可,业务代码只依赖接口。
*/ */
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class MediaStorageService { public class MediaStorageService implements MediaStorage {
private static final DateTimeFormatter MONTH_DIR = DateTimeFormatter.ofPattern("yyyyMM"); private static final DateTimeFormatter MONTH_DIR = DateTimeFormatter.ofPattern("yyyyMM");
private final MediaProperties properties; private final MediaProperties properties;
public record MergeResult(Path path, long size, String md5) {
}
public Path rootDir() { public Path rootDir() {
Path root = Paths.get(properties.getRootDir()).toAbsolutePath().normalize(); Path root = Paths.get(properties.getRootDir()).toAbsolutePath().normalize();
try { try {
@@ -50,7 +50,7 @@ public class MediaTranscodeOutputService {
private final MediaAssetMapper mediaAssetMapper; private final MediaAssetMapper mediaAssetMapper;
private final TranscodeService transcodeService; private final TranscodeService transcodeService;
private final MediaStorageService storageService; private final MediaStorage storageService;
private final PlaybackSigner signer; private final PlaybackSigner signer;
public List<MediaRenditionRespVO> renditions(Long mediaAssetId) { public List<MediaRenditionRespVO> renditions(Long mediaAssetId) {
@@ -19,7 +19,7 @@ import java.util.Map;
public class ThumbnailUrlResolver { public class ThumbnailUrlResolver {
private final TranscodeService transcodeService; private final TranscodeService transcodeService;
private final MediaStorageService storageService; private final MediaStorage storageService;
private final PlaybackSigner signer; private final PlaybackSigner signer;
/** 单集播放使用。 */ /** 单集播放使用。 */
@@ -6,7 +6,7 @@ import com.fruitshorts.media.config.MediaTranscodeProfile;
import com.fruitshorts.media.dal.entity.MediaThumbnail; import com.fruitshorts.media.dal.entity.MediaThumbnail;
import com.fruitshorts.media.dal.entity.TranscodeTask; import com.fruitshorts.media.dal.entity.TranscodeTask;
import com.fruitshorts.media.service.MediaService; import com.fruitshorts.media.service.MediaService;
import com.fruitshorts.media.service.MediaStorageService; import com.fruitshorts.media.service.MediaStorage;
import com.fruitshorts.media.service.MediaTranscodeProfileService; import com.fruitshorts.media.service.MediaTranscodeProfileService;
import com.fruitshorts.media.service.EpisodeTranscodeCompletionPort; import com.fruitshorts.media.service.EpisodeTranscodeCompletionPort;
import com.fruitshorts.media.service.TranscodeErrorTruncator; import com.fruitshorts.media.service.TranscodeErrorTruncator;
@@ -45,7 +45,7 @@ public class TranscodeWorker {
private final TranscodeService transcodeService; private final TranscodeService transcodeService;
private final MediaService mediaService; private final MediaService mediaService;
private final MediaStorageService storageService; private final MediaStorage storageService;
private final TranscodeRunner transcodeRunner; private final TranscodeRunner transcodeRunner;
private final MediaTranscodeProfileService profileService; private final MediaTranscodeProfileService profileService;
private final EpisodeTranscodeCompletionPort episodeCompletionPort; private final EpisodeTranscodeCompletionPort episodeCompletionPort;
@@ -626,6 +626,7 @@ public class WatchProgressService {
vo.setTitleLocales(titles); vo.setTitleLocales(titles);
vo.setDramaTitle(ContentLocaleTexts.value(titles, ContentLocaleTexts.ZH)); vo.setDramaTitle(ContentLocaleTexts.value(titles, ContentLocaleTexts.ZH));
vo.setDramaCover(drama.getCoverUrl()); vo.setDramaCover(drama.getCoverUrl());
vo.setDramaCoverLandscape(drama.getHorizontalCoverUrl());
vo.setTotalEpisodes(drama.getTotalEpisodes()); vo.setTotalEpisodes(drama.getTotalEpisodes());
vo.setViewCount(drama.getViewCount()); vo.setViewCount(drama.getViewCount());
} }
@@ -19,6 +19,8 @@ public class ProgressRespVO {
private Map<String, String> titleLocales; private Map<String, String> titleLocales;
/** 短剧封面,仅列表类接口批量装配。 */ /** 短剧封面,仅列表类接口批量装配。 */
private String dramaCover; private String dramaCover;
/** 短剧横版封面,仅列表类接口批量装配;为空时客户端回退 dramaCover。 */
private String dramaCoverLandscape;
/** 当前续看集号。 */ /** 当前续看集号。 */
private Integer episodeNumber; private Integer episodeNumber;
/** 短剧总集数;短剧不存在时为空。 */ /** 短剧总集数;短剧不存在时为空。 */
@@ -8,6 +8,7 @@ import com.fruitshorts.media.dal.entity.MediaUploadTask;
import com.fruitshorts.media.dal.mapper.MediaAssetMapper; import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
import com.fruitshorts.media.dal.mapper.MediaUploadTaskMapper; import com.fruitshorts.media.dal.mapper.MediaUploadTaskMapper;
import com.fruitshorts.media.service.MediaService; import com.fruitshorts.media.service.MediaService;
import com.fruitshorts.media.service.MediaStorage;
import com.fruitshorts.media.service.MediaStorageService; import com.fruitshorts.media.service.MediaStorageService;
import com.fruitshorts.media.service.EpisodeVideoSourcePort; import com.fruitshorts.media.service.EpisodeVideoSourcePort;
import com.fruitshorts.media.service.TranscodeService; import com.fruitshorts.media.service.TranscodeService;
@@ -63,7 +64,7 @@ class MediaServiceTranscodePipelineTest {
givenCompletedUpload("video.mp4"); givenCompletedUpload("video.mp4");
given(storageService.countChunks("upload-1")).willReturn(1); given(storageService.countChunks("upload-1")).willReturn(1);
given(storageService.merge("upload-1", "episode/202610/video.mp4", 1)) given(storageService.merge("upload-1", "episode/202610/video.mp4", 1))
.willReturn(new MediaStorageService.MergeResult( .willReturn(new MediaStorage.MergeResult(
Path.of("/tmp/video.mp4"), 1024L, "md5")); Path.of("/tmp/video.mp4"), 1024L, "md5"));
given(storageService.toPublicUrl("episode/202610/video.mp4")) given(storageService.toPublicUrl("episode/202610/video.mp4"))
.willReturn(SOURCE_URL); .willReturn(SOURCE_URL);
@@ -79,7 +80,7 @@ class MediaServiceTranscodePipelineTest {
givenCompletedUpload("cover.jpg"); givenCompletedUpload("cover.jpg");
given(storageService.countChunks("upload-1")).willReturn(1); given(storageService.countChunks("upload-1")).willReturn(1);
given(storageService.merge("upload-1", "episode/202610/cover.jpg", 1)) given(storageService.merge("upload-1", "episode/202610/cover.jpg", 1))
.willReturn(new MediaStorageService.MergeResult( .willReturn(new MediaStorage.MergeResult(
Path.of("/tmp/cover.jpg"), 512L, "md5")); Path.of("/tmp/cover.jpg"), 512L, "md5"));
given(storageService.toPublicUrl("episode/202610/cover.jpg")) given(storageService.toPublicUrl("episode/202610/cover.jpg"))
.willReturn("/files/episode/202610/cover.jpg"); .willReturn("/files/episode/202610/cover.jpg");
+128
View File
@@ -0,0 +1,128 @@
# Author: fruit.com.cn
# Grafana 告警规则:服务下线 / 响应慢 / 磁盘满 自动通知。
# 需要在 Grafana UI 里配一个 Contact Point(飞书 webhook)并绑定到这些规则。
apiVersion: 1
groups:
- orgId: 1
name: fruitshorts-health
folder: FruitShorts
interval: 30s
rules:
# ── 服务下线 ──
- uid: fruitshorts-app-down
title: "app-api 下线"
condition: C
data:
- refId: A
relativeTimeRange: { from: 120, to: 0 }
datasourceUid: prometheus
model:
expr: 'probe_success{instance=~"http://app-api:8081/.*"} == 0'
refId: A
- refId: C
relativeTimeRange: { from: 0, to: 0 }
datasourceUid: __expr__
model:
conditions:
- evaluator: { params: [0.5], type: gt }
operator: { type: and }
query: { params: [A] }
reducer: { type: last }
type: query
refId: C
noDataState: Alerting
for: 2m
annotations:
summary: "app-api (8081) 已下线超过 2 分钟"
labels:
severity: critical
team: backend
- uid: fruitshorts-admin-down
title: "admin-api 下线"
condition: C
data:
- refId: A
relativeTimeRange: { from: 120, to: 0 }
datasourceUid: prometheus
model:
expr: 'probe_success{instance=~"http://admin-api:8080/.*"} == 0'
refId: A
- refId: C
relativeTimeRange: { from: 0, to: 0 }
datasourceUid: __expr__
model:
conditions:
- evaluator: { params: [0.5], type: gt }
operator: { type: and }
query: { params: [A] }
reducer: { type: last }
type: query
refId: C
noDataState: Alerting
for: 2m
annotations:
summary: "admin-api (8080) 已下线超过 2 分钟"
labels:
severity: critical
team: backend
# ── 响应慢 ──
- uid: fruitshorts-slow-response
title: "接口响应过慢"
condition: C
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus
model:
expr: 'histogram_quantile(0.95, rate(probe_duration_seconds_bucket[5m])) > 2'
refId: A
- refId: C
relativeTimeRange: { from: 0, to: 0 }
datasourceUid: __expr__
model:
conditions:
- evaluator: { params: [0.5], type: gt }
operator: { type: and }
query: { params: [A] }
reducer: { type: last }
type: query
refId: C
for: 5m
annotations:
summary: "接口 P95 响应时间超过 2 秒"
labels:
severity: warning
team: backend
# ── 容器重启频繁 ──
- uid: fruitshorts-container-restart
title: "容器频繁重启"
condition: C
data:
- refId: A
relativeTimeRange: { from: 600, to: 0 }
datasourceUid: prometheus
model:
expr: 'increase(container_start_time_seconds[10m]) > 3'
refId: A
- refId: C
relativeTimeRange: { from: 0, to: 0 }
datasourceUid: __expr__
model:
conditions:
- evaluator: { params: [0.5], type: gt }
operator: { type: and }
query: { params: [A] }
reducer: { type: last }
type: query
refId: C
for: 1m
annotations:
summary: "有容器在 10 分钟内重启超过 3 次"
labels:
severity: warning
team: infra
+21
View File
@@ -0,0 +1,21 @@
-- Author: fruit.com.cn
-- 标签语义码:客户端按稳定码判断板块归属(如「上新」横滑),文案可随意改。
ALTER TABLE content_tag ADD COLUMN IF NOT EXISTS code VARCHAR(32) NOT NULL DEFAULT '';
-- 同一个语义码只能给一个标签,空码不参与唯一性约束。
CREATE UNIQUE INDEX IF NOT EXISTS uk_content_tag_code
ON content_tag (code) WHERE code <> '';
-- 已有标签回填语义码(幂等:只补空的)。
UPDATE content_tag SET code = 'NEW_ARRIVAL'
WHERE code = '' AND (name = '上新' OR name_en = 'New Arrival');
UPDATE content_tag SET code = 'HOT'
WHERE code = '' AND (name = '热剧' OR name_en = 'Hot');
UPDATE content_tag SET code = 'NEW_RELEASE'
WHERE code = '' AND (name = '新剧' OR name_en = 'New Movie');
UPDATE content_tag SET code = 'TRENDING'
WHERE code = '' AND (name = '热播' OR name_en = 'Rebo');
@@ -0,0 +1,50 @@
-- Author: fruit.com.cn
-- 推荐榜:运营分类增加稳定语义、展示类型和版本,关联关系增加明确位置
ALTER TABLE drama_classification
ADD COLUMN IF NOT EXISTS code VARCHAR(64),
ADD COLUMN IF NOT EXISTS layout_type VARCHAR(16) NOT NULL DEFAULT 'GRID',
ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 1;
UPDATE drama_classification
SET code = 'CLASSIFICATION_' || id
WHERE code IS NULL OR BTRIM(code) = '';
UPDATE drama_classification
SET code = 'RECOMMEND_RANKING',
layout_type = 'RANKING'
WHERE id = (
SELECT id
FROM drama_classification
WHERE name IN ('推荐榜', '榜单')
ORDER BY sort ASC, id ASC
LIMIT 1
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_drama_classification_code
ON drama_classification (code);
ALTER TABLE drama_classification_drama
ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0;
WITH ranked AS (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY classification_id
ORDER BY created_at DESC, id DESC
) AS next_position
FROM drama_classification_drama
)
UPDATE drama_classification_drama relation
SET position = ranked.next_position
FROM ranked
WHERE relation.id = ranked.id
AND relation.position = 0;
CREATE INDEX IF NOT EXISTS idx_classification_drama_position
ON drama_classification_drama (classification_id, position, id);
COMMENT ON COLUMN drama_classification.code IS '稳定语义码,不随多语言名称变化';
COMMENT ON COLUMN drama_classification.layout_type IS '客户端布局:GRID/RANKING';
COMMENT ON COLUMN drama_classification.revision IS '内容或顺序版本,变更后递增';
COMMENT ON COLUMN drama_classification_drama.position IS '分类内展示位置,从 1 开始';
+7
View File
@@ -875,24 +875,31 @@ COMMENT ON TABLE audit_log IS '业务对象变更审计(支付、权益、定
CREATE TABLE IF NOT EXISTS drama_classification ( CREATE TABLE IF NOT EXISTS drama_classification (
id BIGINT PRIMARY KEY, id BIGINT PRIMARY KEY,
name VARCHAR(64) NOT NULL, name VARCHAR(64) NOT NULL,
code VARCHAR(64),
layout_type VARCHAR(16) NOT NULL DEFAULT 'GRID',
revision BIGINT NOT NULL DEFAULT 1,
sort INTEGER NOT NULL DEFAULT 0, sort INTEGER NOT NULL DEFAULT 0,
status SMALLINT NOT NULL DEFAULT 1, status SMALLINT NOT NULL DEFAULT 1,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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); CREATE UNIQUE INDEX IF NOT EXISTS uk_drama_classification_name ON drama_classification (name);
CREATE UNIQUE INDEX IF NOT EXISTS uk_drama_classification_code ON drama_classification (code);
COMMENT ON TABLE drama_classification IS '运营分类;与短剧类型(drama_category)相互独立'; COMMENT ON TABLE drama_classification IS '运营分类;与短剧类型(drama_category)相互独立';
CREATE TABLE IF NOT EXISTS drama_classification_drama ( CREATE TABLE IF NOT EXISTS drama_classification_drama (
id BIGINT PRIMARY KEY, id BIGINT PRIMARY KEY,
classification_id BIGINT NOT NULL, classification_id BIGINT NOT NULL,
drama_id BIGINT NOT NULL, drama_id BIGINT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE UNIQUE INDEX IF NOT EXISTS uk_classification_drama CREATE UNIQUE INDEX IF NOT EXISTS uk_classification_drama
ON drama_classification_drama (classification_id, drama_id); ON drama_classification_drama (classification_id, drama_id);
CREATE INDEX IF NOT EXISTS idx_classification_drama_drama CREATE INDEX IF NOT EXISTS idx_classification_drama_drama
ON drama_classification_drama (drama_id); ON drama_classification_drama (drama_id);
CREATE INDEX IF NOT EXISTS idx_classification_drama_position
ON drama_classification_drama (classification_id, position, id);
COMMENT ON TABLE drama_classification_drama IS '分类与短剧的关联关系'; COMMENT ON TABLE drama_classification_drama IS '分类与短剧的关联关系';
-- ============================================================ -- ============================================================