Author SHA1 Message Date
Zhou Weidong 15b540cdd6 chore(merge): 合并内容多语言与转码通知 2026-10-24 16:57:31 +08:00
Zhou Weidong 50b3dd4a8e docs(api): 同步多语言筛选与转码通知契约 2026-10-24 16:53:26 +08:00
Zhou Weidong 1d34e47673 feat(admin): 增加工作台指标和转码通知 2026-10-24 16:53:26 +08:00
Zhou Weidong 2367a5d12b feat(app): 全部短剧改为请求体分页 2026-10-24 16:53:26 +08:00
Zhou Weidong ebc5f890fd feat(playback): 观看记录返回标题多语言 2026-10-24 16:53:26 +08:00
Zhou Weidong e60c47123c feat(content): 短剧分类和标签支持多语言 2026-10-24 16:53:26 +08:00
Zhou Weidong 5c20cf1ab9 Merge branch feat/app-version-and-c-end 2026-10-23 16:45:55 +08:00
Zhou Weidong 45cf405285 feat(api): 加C端分类频道和剧单详情接口 2026-10-23 16:45:28 +08:00
Zhou Weidong 4f80a691d1 feat(app-version): 加批量创建接口 2026-10-23 16:45:20 +08:00
Zhou Weidong 47f19a98eb chore(merge): 合并 App 版本管理后端 2026-10-23 15:27:35 +08:00
Zhou Weidong 62792413df feat(app): 新增后台 App 版本管理接口 2026-10-23 15:27:35 +08:00
Zhou Weidong 34c775620d chore(merge): 合并 compose Redis 依赖修复 2026-10-23 14:58:46 +08:00
Zhou Weidong b3774e0112 fix(deploy): compose 编排补充 REDIS_HOST 依赖 2026-10-23 14:58:46 +08:00
Zhou Weidong 0e6f7531d4 chore(merge): 合并大盘趋势与分布接口 2026-10-23 14:58:33 +08:00
Zhou Weidong 73a8db8498 feat(dashboard): 大盘趋势与分布接口 2026-10-23 14:58:33 +08:00
Zhou Weidong cf657707f9 chore(merge): 合并转码补齐功能 2026-10-23 14:14:39 +08:00
Zhou Weidong 40728e685b feat(media): 新增缺失转码产物自动补齐 2026-10-23 14:14:39 +08:00
81 changed files with 3146 additions and 111 deletions

No files matched your search

+11
View File
@@ -73,6 +73,9 @@ services:
SERVER_PORT: "8081"
# 默认连编排里的 db;要连外部库就在 .env 里覆盖 DB_URL
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
# 限流/缓存用 Redis;容器内 127.0.0.1 是容器自身,必须指向编排里的 redis
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
ports:
- "8081:8081"
volumes:
@@ -81,6 +84,8 @@ services:
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8081/healthz || exit 1"]
interval: 30s
@@ -106,6 +111,8 @@ services:
# 管理端仅内网,页面上直接播放/下载裸媒资地址(不签播放签名),
# 因此这里不启用 /files/episode/** 的签名校验;公开的 app-api 仍保持开启。
FRUITSHORTS_MEDIA_PROTECTED_PREFIXES: ""
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
ports:
# docs/12-上线检查清单「管理端仅内网」:只对局域网开放;不在路由器做端口转发,公网访问不到
- "8080:8080"
@@ -134,12 +141,16 @@ services:
env_file: .env
environment:
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
volumes:
# 与两个入口同一个媒资根,否则找不到源文件
- ./data/uploads:/data/uploads
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
# worker 不监听端口(server.port=0),用进程存活判断
test: ["CMD-SHELL", "pgrep -f app.jar >/dev/null || exit 1"]
+53 -2
View File
@@ -168,10 +168,30 @@ app.register.max-per-device-per-day
| GET | `/app/home/feed` | 首页:轮播 + 分区,卡片带 `preview` 可播预览集与 `language` 语种 |
| GET | `/app/discover/feed` | 发现页分区 |
| GET | `/app/feed?types=ANIME,LIVE&categoryId=7&cursor=&size=20` | 推荐流;支持内容类型与分类叠加过滤,游标翻页 |
| GET | `/app/categories` | 分类列表 |
| GET | `/app/categories` | 已启用首页分类。`name` 为中文,`locales` 为 `{zh,en,id}` |
| GET | `/app/categories/{id}/dramas?pageIndex=&pageSize=` | 分类下已上线短剧,分页。`pageIndex` 默认 1,`pageSize` 默认 20、最大 50 |
| GET | `/app/drama-categories` | 已启用类型。`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 数组,不放查询串。同一组多选为或,组与组之间为且 |
| GET | `/app/playlists?pageIndex=&pageSize=` | 已启用剧单列表,分页。`pageIndex` 默认 1,`pageSize` 默认 10、最大 20 |
| GET | `/app/playlists/{id}?pageIndex=&pageSize=` | 剧单详情。短剧按页返回。`pageIndex` 默认 1,`pageSize` 默认 20、最大 50。下架或不存在返回 `200002` |
| GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) |
| GET | `/app/search?keyword=&pageIndex=&pageSize=` | **匿名**。搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 |
`POST /app/dramas/page` 请求体:
```json
{
"pageIndex": 1,
"pageSize": 20,
"categoryIds": ["2112443900437336065"],
"classificationIds": ["2112807182243336193"],
"tagIds": ["2113500000000000002"]
}
```
省略的 id 数组表示这一组不筛选。`pageIndex` 小于 1 按 1,`pageSize` 小于 1 按 1,大于 50 按 50。响应仍是 `{total, list}`,卡片结构与首页一致。
> 卡片(`AppDramaCardVO`)统一新增 `language` 字段(取值如 `zh-CN` / `id`),
> 首页、发现页、分类、收藏、搜索等所有下发卡片的场景都带该字段,客户端可据此做语言筛选。
>
@@ -180,9 +200,21 @@ app.register.max-per-device-per-day
> `drama_episode.like_count` 与 `content_comment.episode_id` 统计(排除逻辑删除与 `status = 'deleted'`);
> `viewCount` 热度仍取整部剧。首页、发现、分类、收藏、搜索等列表卡片结构一致。
>
> 卡片文案只放语言表:`titleLocales`、`synopsisLocales`、`types`、`tagLocales`。
> `tagColor` 是标签背景色。中文取各表的 `zh`。
>
> 卡片新增 `dramaType`:`ANIME` 表示漫剧,`LIVE` 表示真人剧。
> 客户端字段仍返回主内容类型;后台多选值保存在 `drama.drama_types`,
> App 类型筛选按逗号列整词匹配,因此 `ANIME,LIVE` 多标签剧可被任一类型命中。
>
> `GET /app/playlists` 与 `GET /app/playlists/{id}` 匿名可访问。列表返回 `{total, list}`。
> 列表项与详情的名称在 `locales`,描述在 `descriptionLocales`,key 为 `zh` / `en` / `id`。
> `name` 与 `description` 等于中文。客户端按当前语言取对应 key,没有则用 `zh`。
> 其余字段:`id`、`dramaCount`(已上线短剧条数),
> 列表里每条剧单的 `list` 只是短剧第 1 页、每页 10 条。短剧翻页走详情:
> `pageIndex`、`pageSize` 会原样回在详情里,`dramaCount` 是短剧总数,`list` 是当前页卡片。
> 只返回 `status = 1` 的剧单,排序与后台剧单排序一致,卡片顺序与后台关联顺序一致。
> 剧单列表越界页返回空 `list`,`total` 仍是全部剧单数。详情越界页同样返回空 `list`。
#### 推荐类型流(`GET /app/feed`)
@@ -356,6 +388,11 @@ app.register.max-per-device-per-day
"dramaId": "12",
"episodeId": "88",
"dramaTitle": "替嫁新娘:总裁的掌心宠",
"titleLocales": {
"zh": "替嫁新娘:总裁的掌心宠",
"en": "The Stand-in Bride: The CEO's Beloved",
"id": "Pengantin Pengganti: Kesayangan Presiden Direktur"
},
"dramaCover": "/files/drama_cover/12.jpg",
"episodeNumber": 6,
"totalEpisodes": 68,
@@ -377,7 +414,8 @@ app.register.max-per-device-per-day
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `dramaTitle` | string / null | 短剧标题 |
| `dramaTitle` | string / null | 短剧中文标题,旧客户端回退用 |
| `titleLocales` | object / null | 短剧标题语言表,key 为 `zh` / `en` / `id`。客户端按当前语言取,没有对应语言时回退 `zh` |
| `dramaCover` | string / null | 短剧封面 |
| `episodeNumber` | int / null | 当前续看集号 `ep_no` |
| `totalEpisodes` | int / null | 短剧总集数 |
@@ -656,6 +694,16 @@ GET /app/notices?pageIndex=1&pageSize=20
- 列表响应返回 `dramaTypes[]`、`categoryIds[]` 与按选中顺序映射的 `categoryNames[]`;
分类名缺失时回退 `genre`。
- 后台与 App 过滤均使用 `(',' || column || ',') LIKE '%,value,%'` 整词匹配。
- 短剧另有单选标签 `tagId`。不传保持原值,传 `0` 清空。列表返回 `tagId`、`tagName`、`tagNameEn`、`tagNameId`。后台编辑不再提交内容类型。
**标签**
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/content-tags?enabledOnly=` | `drama:list` | 标签列表。`enabledOnly=true` 只返回启用项 |
| POST | `/admin/content-tags` | `drama:create` | 新建。`locales` 勾选语言,中文必填,英文和印尼语可选 |
| PUT | `/admin/content-tags/{id}` | `drama:update` | 编辑 |
| DELETE | `/admin/content-tags/{id}` | `drama:delete` | 删除,并清空已选该标签的短剧 |
**剧集**
@@ -827,6 +875,7 @@ GET /app/notices?pageIndex=1&pageSize=20
| --- | --- | --- | --- |
| GET | `/admin/dashboard/overview` | `dashboard:view` | 总览:用户 / 内容 / 播放 / 收入 / 成本 |
| GET | `/admin/dashboard/transcode` | `dashboard:view` | 转码队列积压与失败任务明细(含 `errorMsg` / `retryCount`) |
| GET | `/admin/notifications/transcode` | `drama:list` | 转码通知:`running` / `success` / `failed` 角标,以及各状态最近 20 条(含 `dramaTitle`、`episodeNo`、`episodeTitle`) |
**口径(与代码注释一致,前端 tooltip 可直接复用)**
@@ -834,6 +883,8 @@ GET /app/notices?pageIndex=1&pageSize=20
| --- | --- | --- |
| 用户 | `total` / `newToday` / `new7d` | `app_user` 未删除总数;按 `created_at` 落在当天 / 近 7 天 |
| 用户 | `active7d` | `last_login_at` 在近 7 天内。**不是 DAU**,日活需要逐日登录快照,当前无产出 |
| 用户 | `androidActive7d` / `iosActive7d` | `app_user_device` 中 `status=active` 且 `last_seen_at` 在近 7 天的去重设备,平台分别为 `ANDROID` / `IOS` |
| 用户 | `retention1d` / `retention7d` | 次日留存:昨日注册且今天仍登录的比例。7 日留存:8 至 14 天前注册、近 7 天仍登录的比例。队列为空返回 `null`,取值 0 到 1 |
| 用户 | `banned` / `limited` / `deactivated` | 按 `status` 计数 |
| 内容 | `drama*` | 短剧按 `status` 计数(`reviewing` 为待审、`published` 为已上线) |
| 内容 | `episodeTotal` / `Published` / `WithMedia` | 剧集总数 / 已上线数 / `video_url` 非空数(均排除逻辑删除) |
+19
View File
@@ -0,0 +1,19 @@
# 标签管理(替换短剧内容类型)
## 决定
不做「语言开关」总控。分类和剧单已经用 `name` / `nameEn` / `nameId` 三列。标签沿用同一套,避免两套国际化。语言总控要改短剧标题、简介、剧集标题,单独开一轮。
## 本轮
- 短剧编辑去掉「内容类型」。库里的 `drama_type` 先留着,推荐流的 `ANIME` / `LIVE` 筛选不受影响,后台不再改它。
- 内容管理下新增「标签管理」。
- 标签字段:中文名、英文名、印尼语名、排序、启用状态。三个名称都必填。
- 短剧 `tag_id` 单选,可空。编辑页用下拉,只列出启用中的标签。
- 已有短剧按原内容类型回填:`ANIME` → 漫剧,`LIVE` → 真人剧。
## 接口
- `GET/POST /admin/content-tags`,`PUT/DELETE /admin/content-tags/{id}`,权限沿用 `drama:list` / `create` / `update` / `delete`。
- 短剧保存增加 `tagId`。不传则保持原值;传 `0` 表示清空。
- 短剧列表返回 `tagId`、`tagName`、`tagNameEn`、`tagNameId`。
@@ -0,0 +1,98 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.system.service.AppVersionService;
import com.fruitshorts.system.vo.AppVersionQueryReqVO;
import com.fruitshorts.system.vo.AppVersionRespVO;
import com.fruitshorts.system.vo.AppVersionSaveReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 后台 - App 版本管理。 */
@Tag(name = "后台-App 版本")
@RestController
@RequestMapping("/admin/app-versions")
@RequiredArgsConstructor
public class AppVersionAdminController {
private final AppVersionService appVersionService;
@Operation(summary = "版本分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('app-version:list')")
public Result<PageResult<AppVersionRespVO>> page(@Validated AppVersionQueryReqVO req) {
return Result.ok(appVersionService.page(req));
}
@Operation(summary = "版本详情")
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('app-version:list')")
public Result<AppVersionRespVO> detail(@PathVariable Long id) {
return Result.ok(appVersionService.detail(id));
}
@Operation(summary = "新建版本(默认发布)")
@PostMapping
@PreAuthorize("@ss.hasPermission('app-version:create')")
@OperationLog(module = "版本管理", action = "新建版本")
public Result<Long> create(@Valid @RequestBody AppVersionSaveReqVO req) {
return Result.ok(appVersionService.create(req));
}
@Operation(summary = "批量新建版本(一次事务)")
@PostMapping("/batch")
@PreAuthorize("@ss.hasPermission('app-version:create')")
@OperationLog(module = "版本管理", action = "批量新建版本")
public Result<java.util.List<Long>> batchCreate(
@NotEmpty(message = "请至少传一条")
@Size(max = 16, message = "单次不超过 16 条")
@Valid @RequestBody java.util.List<@Valid AppVersionSaveReqVO> reqs) {
return Result.ok(appVersionService.batchCreate(reqs));
}
@Operation(summary = "编辑版本")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('app-version:update')")
@OperationLog(module = "版本管理", action = "编辑版本")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody AppVersionSaveReqVO req) {
appVersionService.update(id, req);
return Result.ok();
}
@Operation(summary = "发布 / 下架")
@PutMapping("/{id}/status")
@PreAuthorize("@ss.hasPermission('app-version:update')")
@OperationLog(module = "版本管理", action = "版本上下架")
public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
appVersionService.updateStatus(id, status);
return Result.ok();
}
@Operation(summary = "删除版本")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('app-version:delete')")
@OperationLog(module = "版本管理", action = "删除版本")
public Result<Void> delete(@PathVariable Long id) {
appVersionService.delete(id);
return Result.ok();
}
}
@@ -0,0 +1,72 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.service.ContentTagService;
import com.fruitshorts.content.vo.ContentTagRespVO;
import com.fruitshorts.content.vo.ContentTagSaveReqVO;
import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 短剧标签管理。短剧编辑时单选其中一个启用标签。 */
@Tag(name = "后台-标签")
@RestController
@RequestMapping("/admin/content-tags")
@RequiredArgsConstructor
public class ContentTagController {
private final ContentTagService contentTagService;
@Operation(summary = "标签列表")
@GetMapping
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<ContentTagRespVO>> list(
@RequestParam(required = false) Boolean enabledOnly) {
if (Boolean.TRUE.equals(enabledOnly)) {
return Result.ok(contentTagService.listEnabled());
}
return Result.ok(contentTagService.list());
}
@Operation(summary = "新建标签")
@PostMapping
@PreAuthorize("@ss.hasPermission('drama:create')")
@OperationLog(module = "内容管理", action = "新增标签")
public Result<Long> create(@Valid @RequestBody ContentTagSaveReqVO req) {
return Result.ok(contentTagService.create(req));
}
@Operation(summary = "编辑标签")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "编辑标签")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody ContentTagSaveReqVO req) {
contentTagService.update(id, req);
return Result.ok();
}
@Operation(summary = "删除标签")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:delete')")
@OperationLog(module = "内容管理", action = "删除标签")
public Result<Void> delete(@PathVariable Long id) {
contentTagService.delete(id);
return Result.ok();
}
}
@@ -3,6 +3,8 @@
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.DashboardService;
import com.fruitshorts.admin.vo.DashboardDistributionRespVO;
import com.fruitshorts.admin.vo.DashboardTrendRespVO;
import com.fruitshorts.admin.vo.TranscodeDashboardRespVO;
import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation;
@@ -44,4 +46,20 @@ public class DashboardAdminController {
public Result<TranscodeDashboardRespVO> transcode() {
return Result.ok(dashboardService.transcode());
}
/** 近 7 天每日趋势(用户注册 / 播放事件 / 转码任务)。 */
@Operation(summary = "大盘趋势(近 7 天每日数据,给折线图用)")
@GetMapping("/trend")
@PreAuthorize("@ss.hasPermission('dashboard:view')")
public Result<DashboardTrendRespVO> trend() {
return Result.ok(dashboardService.trend());
}
/** 分布:短剧状态 / 转码状态 / 片源覆盖 / 待处理审核。 */
@Operation(summary = "大盘分布(给饼图用)")
@GetMapping("/distribution")
@PreAuthorize("@ss.hasPermission('dashboard:view')")
public Result<DashboardDistributionRespVO> distribution() {
return Result.ok(dashboardService.distribution());
}
}
@@ -15,6 +15,8 @@ import com.fruitshorts.media.vo.UploadCompleteReqVO;
import com.fruitshorts.media.vo.UploadInitReqVO;
import com.fruitshorts.media.vo.UploadInitRespVO;
import com.fruitshorts.media.vo.UploadProgressRespVO;
import com.fruitshorts.admin.service.TranscodeBackfillAdminService;
import com.fruitshorts.admin.vo.TranscodeBackfillRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@@ -41,6 +43,7 @@ public class MediaController {
private final MediaService mediaService;
private final MediaTranscodeProfileService profileService;
private final TranscodeBackfillAdminService backfillAdminService;
@Operation(summary = "初始化分片上传")
@PostMapping("/upload/init")
@@ -110,4 +113,14 @@ public class MediaController {
ImpersonationGuard.rejectWrite();
return Result.ok(profileService.save(profile));
}
@Operation(summary = "手动补齐转码:扫描缺产物/帧图的分集并入队")
@PostMapping("/transcode/backfill")
@PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "后台管理", action = "补齐转码")
public Result<TranscodeBackfillRespVO> backfill(
@RequestParam(value = "batchSize", required = false) Integer batchSize) {
ImpersonationGuard.rejectWrite();
return Result.ok(backfillAdminService.runOnce(batchSize));
}
}
@@ -0,0 +1,33 @@
// Author: fruit.com.cn
// 后台转码通知接口。
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.TranscodeNoticeService;
import com.fruitshorts.admin.vo.TranscodeNoticeRespVO;
import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 后台转码通知。 */
@Tag(name = "后台-转码通知")
@RestController
@RequestMapping("/admin/notifications")
@RequiredArgsConstructor
public class TranscodeNoticeController {
private final TranscodeNoticeService transcodeNoticeService;
/** 转码中、成功、失败的角标和最近任务,含短剧与集数。 */
@Operation(summary = "转码通知(角标与最近任务)")
@GetMapping("/transcode")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<TranscodeNoticeRespVO> transcode() {
return Result.ok(transcodeNoticeService.notices());
}
}
@@ -3,6 +3,8 @@
package com.fruitshorts.admin.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.admin.vo.DashboardDistributionRespVO;
import com.fruitshorts.admin.vo.DashboardTrendRespVO;
import com.fruitshorts.admin.vo.TranscodeDashboardRespVO;
import com.fruitshorts.commerce.dal.entity.CoinLedger;
import com.fruitshorts.commerce.dal.entity.Entitlement;
@@ -44,6 +46,8 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* 运营大盘(只读聚合)。
*
@@ -54,7 +58,11 @@ import java.util.Map;
* <ul>
* <li>用户:`total` = app_user 未删除总数;`newToday`/`new7d` = 按 created_at 落在当天 / 近 7 天;
* `active7d` = last_login_at 在近 7 天内(**不是 DAU**:真正的日活需要按天留存登录快照,
* 当前没有逐日快照数据源,见 notes);`banned`/`limited` = 按 status 计数。</li>
* 当前没有逐日快照数据源,见 notes);`androidActive7d`/`iosActive7d` =
* `app_user_device` 中状态为 active、近 7 天有心跳的去重设备数;
* `retention1d` = 昨日注册用户中今天仍登录的比例,分母为 0 时返回 null;
* `retention7d` = 8 至 14 天前注册的用户中,近 7 天仍登录的比例,分母为 0 时返回 null;
* `banned`/`limited` = 按 status 计数。</li>
* <li>内容:短剧按 status 计数(`reviewing` 视为待审、`published` 视为已上线);
* 剧集总数与已上线数;`episodeWithMedia` = video_url 非空的剧集数。</li>
* <li>播放:`viewTotal`/`playTotal` = 短剧表上累计计数列的求和(累计口径,不限定时间范围);
@@ -72,6 +80,9 @@ import java.util.Map;
public class DashboardService {
private static final int ACTIVE_WINDOW_DAYS = 7;
private static final int RETENTION_WINDOW_DAYS = 7;
private static final String PLATFORM_ANDROID = "ANDROID";
private static final String PLATFORM_IOS = "IOS";
private final AppUserMapper appUserMapper;
private final AppUserOtpMapper appUserOtpMapper;
@@ -86,6 +97,7 @@ public class DashboardService {
private final CoinLedgerMapper coinLedgerMapper;
private final EntitlementMapper entitlementMapper;
private final TranscodeService transcodeService;
private final JdbcTemplate jdbcTemplate;
public Map<String, Object> overview() {
LocalDateTime now = LocalDateTime.now();
@@ -117,6 +129,58 @@ public class DashboardService {
return data;
}
/** 近 7 天每日趋势,给折线图用。 */
public DashboardTrendRespVO trend() {
int days = 7;
LocalDate today = LocalDate.now();
List<String> dates = new ArrayList<>(days);
List<Long> newUsers = new ArrayList<>(days);
List<Long> playEvents = new ArrayList<>(days);
List<Long> newTranscodes = new ArrayList<>(days);
List<Long> transcodeSuccess = new ArrayList<>(days);
for (int i = days - 1; i >= 0; i--) {
LocalDate d = today.minusDays(i);
dates.add(d.toString());
newUsers.add(countForDate("SELECT COUNT(*) FROM app_user WHERE deleted = 0 AND DATE(created_at) = ?", d));
playEvents.add(countForDate("SELECT COUNT(*) FROM watch_history WHERE DATE(watched_at) = ?", d));
newTranscodes.add(countForDate("SELECT COUNT(*) FROM transcode_task WHERE DATE(created_at) = ?", d));
transcodeSuccess.add(countForDate("SELECT COUNT(*) FROM transcode_task WHERE status = 'SUCCESS' AND DATE(created_at) = ?", d));
}
return new DashboardTrendRespVO(dates, newUsers, playEvents, newTranscodes, transcodeSuccess);
}
/** 各域状态分布,给饼图用。 */
public DashboardDistributionRespVO distribution() {
DashboardDistributionRespVO vo = new DashboardDistributionRespVO();
vo.setDramaStatus(groupBy("SELECT status, COUNT(*) FROM drama WHERE deleted = 0 GROUP BY status"));
vo.setTranscodeStatus(groupBy("SELECT status, COUNT(*) FROM transcode_task GROUP BY status"));
vo.setEpisodeMedia(groupBy("""
SELECT CASE WHEN video_url IS NOT NULL AND video_url <> '' THEN '有片源' ELSE '无片源' END AS bucket,
COUNT(*) FROM drama_episode WHERE deleted = 0 GROUP BY bucket"""));
List<DashboardDistributionRespVO.Item> pending = new ArrayList<>();
pending.add(new DashboardDistributionRespVO.Item("评论待审", orZero(commentMapper.selectCount(
Wrappers.<ContentComment>lambdaQuery().eq(ContentComment::getStatus, "pending")))));
pending.add(new DashboardDistributionRespVO.Item("举报待处理", orZero(reportMapper.selectCount(
Wrappers.<ContentReport>lambdaQuery().eq(ContentReport::getStatus, "pending")))));
vo.setPendingModeration(pending);
return vo;
}
/** 按日期查计数,空结果返回 0。 */
private long countForDate(String sql, LocalDate date) {
Long count = jdbcTemplate.queryForObject(sql, Long.class, date);
return count == null ? 0L : count;
}
/** 两列 GROUP BY 查询返回 Map<key, count>。 */
private Map<String, Long> groupBy(String sql) {
Map<String, Long> result = new LinkedHashMap<>();
jdbcTemplate.query(sql, rs -> {
result.put(rs.getString(1), rs.getLong(2));
});
return result;
}
private Map<String, Object> userMetrics(LocalDateTime todayStart, LocalDateTime sevenDaysAgo) {
Map<String, Object> user = new LinkedHashMap<>();
user.put("total", countUser(Wrappers.<AppUser>lambdaQuery()));
@@ -130,9 +194,47 @@ public class DashboardService {
user.put("limited", countUser(Wrappers.<AppUser>lambdaQuery().eq(AppUser::getStatus, "limited")));
user.put("deactivated", countUser(Wrappers.<AppUser>lambdaQuery()
.eq(AppUser::getStatus, "deactivated")));
putActiveDevices(user, sevenDaysAgo);
user.put("retention1d", retentionRate(todayStart.minusDays(1), todayStart, todayStart));
user.put("retention7d", retentionRate(
todayStart.minusDays(RETENTION_WINDOW_DAYS * 2L),
todayStart.minusDays(RETENTION_WINDOW_DAYS),
sevenDaysAgo));
return user;
}
/** 近 7 天仍活跃的安卓、iOS 设备,按设备号去重。 */
private void putActiveDevices(Map<String, Object> user, LocalDateTime since) {
Map<String, Long> counts = new LinkedHashMap<>();
jdbcTemplate.query("""
SELECT UPPER(platform), COUNT(DISTINCT device_id)
FROM app_user_device
WHERE deleted = 0 AND status = 'active' AND last_seen_at >= ?
GROUP BY UPPER(platform)
""", rs -> {
counts.put(rs.getString(1), rs.getLong(2));
}, since);
user.put("androidActive7d", counts.getOrDefault(PLATFORM_ANDROID, 0L));
user.put("iosActive7d", counts.getOrDefault(PLATFORM_IOS, 0L));
}
/** 队列留存率。注册队列为空时返回 null,避免把没有样本显示成 0%。 */
private Double retentionRate(LocalDateTime cohortStart, LocalDateTime cohortEnd, LocalDateTime returnedSince) {
Long cohort = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM app_user
WHERE deleted = 0 AND created_at >= ? AND created_at < ?
""", Long.class, cohortStart, cohortEnd);
if (cohort == null || cohort == 0L) {
return null;
}
Long returned = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM app_user
WHERE deleted = 0 AND created_at >= ? AND created_at < ? AND last_login_at >= ?
""", Long.class, cohortStart, cohortEnd, returnedSince);
long back = returned == null ? 0L : returned;
return Math.round(back * 1000.0 / cohort) / 1000.0;
}
private Map<String, Object> contentMetrics() {
Map<String, Object> content = new LinkedHashMap<>();
content.put("dramaTotal", dramaMapper.selectCount(null));
@@ -227,6 +329,8 @@ public class DashboardService {
List<String> notes = new ArrayList<>();
notes.add("DAU 暂无数据源:需要按天留存登录快照(ops_daily_stat 已建表但当前无产出),"
+ "现以 active7d(近 7 天登录过)近似");
notes.add("androidActive7d / iosActive7d 是近 7 天 last_seen_at 仍活跃的去重设备,不是安装量");
notes.add("retention1d 是昨日注册且今天仍登录的比例;retention7d 是 8 至 14 天前注册、近 7 天仍登录的比例;队列为空时为 null");
notes.add("CDN 流量与带宽成本暂无数据源:需接入 CDN 账单或访问日志,cdnTrafficBytes 恒为 null");
notes.add("收入金额在商店未接入前恒为 0:订单表无真实商店订单,金币解锁收入请参考 coinConsumed");
notes.add("viewTotal/playTotal 是短剧表上的累计计数,与 playEvents7d 的流水口径不可相加");
@@ -0,0 +1,112 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.service;
import com.fruitshorts.admin.vo.TranscodeBackfillRespVO;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.media.config.MediaTranscodeProfile;
import com.fruitshorts.media.dal.entity.MediaAsset;
import com.fruitshorts.media.dal.entity.TranscodeTask;
import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
import com.fruitshorts.media.service.MediaTranscodeOutputService;
import com.fruitshorts.media.service.MediaTranscodeProfileService;
import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.media.vo.TranscodeTriggerRespVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/**
* 手动触发一轮转码补齐巡检:找出"有片源但缺少码率产物或缩略图"的分集并入队。
*
* <p>与 worker 的定时巡检(TranscodeBackfillScanner)共用同一条候选 SQL 与 trigger 语义;
* 区别在于手动触发允许重试最近一次 FAILED 的任务——运营点按钮就是要补齐,包括失败重跑。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TranscodeBackfillAdminService {
private static final int MIN_BATCH_SIZE = 1;
private static final int MAX_BATCH_SIZE = 100;
private static final int DEFAULT_BATCH_SIZE = 20;
private final DramaEpisodeMapper episodeMapper;
private final TranscodeService transcodeService;
private final MediaAssetMapper mediaAssetMapper;
private final MediaTranscodeOutputService outputService;
private final MediaTranscodeProfileService profileService;
/**
* 扫描一批缺产物的分集并入队。
*
* @param requestedBatchSize 单轮上限,超出范围时收敛到默认值
* @return 入队结果与明细
*/
public TranscodeBackfillRespVO runOnce(Integer requestedBatchSize) {
int batchSize = normalizeBatchSize(requestedBatchSize);
List<DramaEpisode> candidates = episodeMapper.selectTranscodeBackfillCandidates(
0L, batchSize, framesRequired());
List<TranscodeBackfillRespVO.Item> items = new ArrayList<>();
if (candidates == null || candidates.isEmpty()) {
return new TranscodeBackfillRespVO(0, items);
}
for (DramaEpisode episode : candidates) {
triggerIfNeeded(episode, items);
}
log.info("后台手动补齐转码:候选 {} 个,实际入队 {} 个",
candidates.size(), items.size());
return new TranscodeBackfillRespVO(items.size(), items);
}
private void triggerIfNeeded(DramaEpisode episode,
List<TranscodeBackfillRespVO.Item> items) {
Long mediaAssetId = resolveMediaAssetId(episode);
if (mediaAssetId == null) {
log.warn("分集 {} 找不到对应媒资,跳过补齐转码", episode.getId());
return;
}
TranscodeTask latest = transcodeService.findLatestTaskByMediaAssetId(mediaAssetId);
if (latest != null && (TranscodeService.STATUS_QUEUED.equals(latest.getStatus())
|| TranscodeService.STATUS_RUNNING.equals(latest.getStatus()))) {
return;
}
MediaAsset asset = mediaAssetMapper.selectById(mediaAssetId);
if (asset == null) {
log.warn("分集 {} 绑定的媒资 {} 不存在,跳过补齐转码",
episode.getId(), mediaAssetId);
return;
}
TranscodeTriggerRespVO result = transcodeService.trigger(
mediaAssetId, asset.getSourceUrl(), episode.getDramaId(), episode.getId());
items.add(new TranscodeBackfillRespVO.Item(
String.valueOf(episode.getId()),
result == null ? "" : String.valueOf(result.getTaskId())));
}
/** 优先用分集已绑定的媒资,老数据没有绑定则按视频地址反查。 */
private Long resolveMediaAssetId(DramaEpisode episode) {
Long videoAssetId = episode.getVideoAssetId();
if (videoAssetId != null && videoAssetId > 0) {
return videoAssetId;
}
return outputService.resolveMediaAssetId(episode.getVideoUrl());
}
/** 关闭雪碧图时不会产出缩略图,此时只看码率产物,避免误判重复转码。 */
private boolean framesRequired() {
MediaTranscodeProfile profile = profileService.currentProfile();
return profile != null && profile.getSprite() != null && profile.getSprite().isEnabled();
}
private int normalizeBatchSize(Integer requestedBatchSize) {
if (requestedBatchSize == null || requestedBatchSize <= 0) {
return DEFAULT_BATCH_SIZE;
}
return Math.min(requestedBatchSize, MAX_BATCH_SIZE);
}
}
@@ -0,0 +1,84 @@
// Author: fruit.com.cn
// 转码通知:按状态计数,并带出短剧与集数。
package com.fruitshorts.admin.service;
import com.fruitshorts.admin.vo.TranscodeNoticeRespVO;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 转码通知。
* 转码中、成功、失败分别计数,列表带出短剧名和集数。
*/
@Service
public class TranscodeNoticeService {
private static final int NOTICE_LIMIT = 20;
private static final String STATUS_RUNNING = "RUNNING";
private static final String STATUS_SUCCESS = "SUCCESS";
private static final String STATUS_FAILED = "FAILED";
private static final String UNKNOWN_DRAMA = "未关联短剧";
private final JdbcTemplate jdbcTemplate;
public TranscodeNoticeService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
/** 角标数量和最近 20 条转码中、成功、失败任务。 */
public TranscodeNoticeRespVO notices() {
TranscodeNoticeRespVO data = new TranscodeNoticeRespVO();
fillCounts(data);
data.setRunningList(latest(STATUS_RUNNING));
data.setSuccessList(latest(STATUS_SUCCESS));
data.setFailedList(latest(STATUS_FAILED));
return data;
}
private void fillCounts(TranscodeNoticeRespVO data) {
jdbcTemplate.query("""
SELECT status, COUNT(*)
FROM transcode_task
WHERE deleted = 0 AND status IN (?, ?, ?)
GROUP BY status
""", rs -> {
long count = rs.getLong(2);
switch (rs.getString(1)) {
case STATUS_RUNNING -> data.setRunning(count);
case STATUS_SUCCESS -> data.setSuccess(count);
case STATUS_FAILED -> data.setFailed(count);
default -> {
}
}
}, STATUS_RUNNING, STATUS_SUCCESS, STATUS_FAILED);
}
private List<TranscodeNoticeRespVO.Item> latest(String status) {
return jdbcTemplate.query("""
SELECT t.id, t.status, t.progress, t.error_msg, t.updated_at,
COALESCE(NULLIF(d.title, ''), NULLIF(d2.title, ''), ?) AS drama_title,
e.ep_no, e.title
FROM transcode_task t
LEFT JOIN drama_episode e ON e.id = t.episode_id AND e.deleted = 0
LEFT JOIN drama d ON d.id = t.drama_id AND d.deleted = 0
LEFT JOIN drama d2 ON d2.id = e.drama_id AND d2.deleted = 0
WHERE t.deleted = 0 AND t.status = ?
ORDER BY t.updated_at DESC
LIMIT ?
""", (rs, row) -> {
TranscodeNoticeRespVO.Item item = new TranscodeNoticeRespVO.Item();
item.setId(rs.getLong("id"));
item.setStatus(rs.getString("status"));
item.setProgress(rs.getInt("progress"));
item.setErrorMsg(rs.getString("error_msg"));
item.setUpdatedAt(rs.getTimestamp("updated_at").toLocalDateTime());
item.setDramaTitle(rs.getString("drama_title"));
item.setEpisodeNo((Integer) rs.getObject("ep_no"));
item.setEpisodeTitle(rs.getString("title"));
return item;
}, UNKNOWN_DRAMA, status, NOTICE_LIMIT);
}
}
@@ -0,0 +1,39 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
/** 运营大盘分布(给饼图用)。 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DashboardDistributionRespVO {
/** 短剧状态分布:key = 状态码,value = 数量;含 total 汇总。 */
private Map<String, Long> dramaStatus;
/** 转码任务状态分布。 */
private Map<String, Long> transcodeStatus;
/** 有片源 / 无片源剧集分布。 */
private Map<String, Long> episodeMedia;
/** 互动待处理:评论待审、举报待审。 */
private List<Item> pendingModeration;
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class Item {
private String label;
private Long value;
}
}
@@ -0,0 +1,31 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/** 运营大盘趋势(近 N 天每日数据,给折线图用)。 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DashboardTrendRespVO {
/** 日期标签,格式 yyyy-MM-dd,从最早到最新。 */
private List<String> dates;
/** 每日新增用户数(与 dates 对齐)。 */
private List<Long> newUsers;
/** 每日播放事件数(watch_history 行数,与 dates 对齐)。 */
private List<Long> playEvents;
/** 每日新增转码任务数(transcode_task 行数,与 dates 对齐)。 */
private List<Long> newTranscodes;
/** 每日转码成功数(与 dates 对齐)。 */
private List<Long> transcodeSuccess;
}
@@ -0,0 +1,32 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/** 一轮转码补齐巡检的结果。 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TranscodeBackfillRespVO {
/** 本轮实际补入队的分集数。 */
private int triggered;
/** 补入队的分集与任务明细。 */
private List<Item> items;
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class Item {
private String episodeId;
private String taskId;
}
}
@@ -0,0 +1,35 @@
// Author: fruit.com.cn
// 转码通知响应:角标数量和最近任务。
package com.fruitshorts.admin.vo;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/** 转码通知:角标数量和最近任务。 */
@Data
public class TranscodeNoticeRespVO {
private long running;
private long success;
private long failed;
private List<Item> runningList = new ArrayList<>();
private List<Item> successList = new ArrayList<>();
private List<Item> failedList = new ArrayList<>();
/** 一条转码通知,带短剧和集数。 */
@Data
public static class Item {
private Long id;
private String status;
private String dramaTitle;
private Integer episodeNo;
private String episodeTitle;
private Integer progress;
private String errorMsg;
private LocalDateTime updatedAt;
}
}
@@ -45,7 +45,9 @@ class MediaTranscodeProfileContractTest {
objectMapper = new ObjectMapper();
MediaTranscodeProfileService profileService = new MediaTranscodeProfileService(
configService, objectMapper, new TranscodeProperties());
MediaController controller = new MediaController(mock(MediaService.class), profileService);
MediaController controller = new MediaController(
mock(MediaService.class), profileService,
mock(com.fruitshorts.admin.service.TranscodeBackfillAdminService.class));
mockMvc = ControllerContractSupport.mockMvcOf(controller);
given(configService.get(eq(MediaTranscodeProfileService.CONFIG_KEY), anyString()))
.willReturn("");
@@ -2,6 +2,7 @@
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.TranscodeBackfillAdminService;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
@@ -32,7 +33,8 @@ class MediaTranscodeTriggerContractTest {
void setUp() {
mediaService = mock(MediaService.class);
MediaController controller = new MediaController(
mediaService, mock(MediaTranscodeProfileService.class));
mediaService, mock(MediaTranscodeProfileService.class),
mock(TranscodeBackfillAdminService.class));
mockMvc = ControllerContractSupport.mockMvcOf(controller);
}
@@ -0,0 +1,53 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.TranscodeNoticeService;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.admin.vo.TranscodeNoticeRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class TranscodeNoticeContractTest {
private TranscodeNoticeService transcodeNoticeService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
transcodeNoticeService = mock(TranscodeNoticeService.class);
mockMvc = ControllerContractSupport.mockMvcOf(new TranscodeNoticeController(transcodeNoticeService));
}
@Test
void should_return_drama_and_episode_when_transcode_notice_is_requested() throws Exception {
TranscodeNoticeRespVO data = new TranscodeNoticeRespVO();
data.setRunning(1L);
data.setSuccess(2L);
data.setFailed(3L);
TranscodeNoticeRespVO.Item item = new TranscodeNoticeRespVO.Item();
item.setDramaTitle("重生");
item.setEpisodeNo(4);
item.setEpisodeTitle("第4集");
data.setFailedList(List.of(item));
given(transcodeNoticeService.notices()).willReturn(data);
mockMvc.perform(get("/admin/notifications/transcode"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.running").value(1))
.andExpect(jsonPath("$.data.success").value(2))
.andExpect(jsonPath("$.data.failed").value(3))
.andExpect(jsonPath("$.data.failedList[0].dramaTitle").value("重生"))
.andExpect(jsonPath("$.data.failedList[0].episodeNo").value(4))
.andExpect(jsonPath("$.data.failedList[0].episodeTitle").value("第4集"));
}
}
@@ -0,0 +1,67 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.service;
import com.fruitshorts.admin.vo.TranscodeNoticeRespVO;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowCallbackHandler;
import org.springframework.jdbc.core.RowMapper;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class TranscodeNoticeServiceTest {
@Test
void should_attach_drama_and_episode_when_failed_tasks_are_loaded() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
doAnswer(invocation -> {
assertTrue(invocation.getArgument(0, String.class).contains("?"));
return null;
}).when(jdbc).query(anyString(), any(RowCallbackHandler.class), eq("RUNNING"), eq("SUCCESS"), eq("FAILED"));
when(jdbc.query(anyString(), any(RowMapper.class), any(), anyString(), any()))
.thenAnswer(invocation -> {
String sql = invocation.getArgument(0, String.class);
String status = invocation.getArgument(3, String.class);
if (!"FAILED".equals(status)) {
return java.util.List.of();
}
assertTrue(sql.contains("drama_title"));
assertTrue(sql.contains("?"));
RowMapper<?> mapper = invocation.getArgument(1);
ResultSet rs = failedRow();
return java.util.List.of(mapper.mapRow(rs, 0));
});
TranscodeNoticeRespVO data = new TranscodeNoticeService(jdbc).notices();
assertEquals(1, data.getFailedList().size());
assertEquals("重生", data.getFailedList().get(0).getDramaTitle());
assertEquals(4, data.getFailedList().get(0).getEpisodeNo());
assertEquals("第4集", data.getFailedList().get(0).getEpisodeTitle());
}
private ResultSet failedRow() throws Exception {
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(9L);
when(rs.getString("status")).thenReturn("FAILED");
when(rs.getInt("progress")).thenReturn(0);
when(rs.getString("error_msg")).thenReturn("boom");
when(rs.getTimestamp("updated_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2026, 10, 24, 12, 0)));
when(rs.getString("drama_title")).thenReturn("重生");
when(rs.getObject("ep_no")).thenReturn(4);
when(rs.getString("title")).thenReturn("第4集");
return rs;
}
}
@@ -4,15 +4,21 @@ package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.vo.AppCategoryVO;
import com.fruitshorts.app.vo.AppDramaPageReqVO;
import com.fruitshorts.app.vo.AppFeedVO;
import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.app.vo.AppPlaylistDetailVO;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@@ -27,6 +33,9 @@ import java.util.List;
@RequiredArgsConstructor
public class AppCatalogController {
private static final int DRAMA_PAGE_INDEX = 1;
private static final int DRAMA_PAGE_SIZE = 20;
private final AppCatalogService appCatalogService;
@Operation(summary = "首页聚合:轮播 + 分区,一次请求渲染整页")
@@ -56,4 +65,56 @@ public class AppCatalogController {
public Result<List<AppCategoryVO>> categories() {
return Result.ok(appCatalogService.categories());
}
@Operation(summary = "分类频道:按分类 ID 分页查短剧卡片")
@GetMapping("/categories/{id}/dramas")
public Result<PageResult<AppDramaCardVO>> categoryDramas(
@PathVariable Long id,
@RequestParam(defaultValue = "1") int pageIndex,
@RequestParam(defaultValue = "20") int pageSize) {
return Result.ok(appCatalogService.categoryDramas(id, pageIndex, pageSize));
}
@Operation(summary = "已启用类型")
@GetMapping("/drama-categories")
public Result<List<AppCategoryVO>> dramaCategories() {
return Result.ok(appCatalogService.dramaCategories());
}
@Operation(summary = "已启用标签")
@GetMapping("/tags")
public Result<List<AppCategoryVO>> tags() {
return Result.ok(appCatalogService.tags());
}
/**
* 全部短剧分页。类型、分类、标签放在请求体,避免长 id 进入查询串。
* 同一组多选为或,不同组同时传入为且。
*/
@Operation(summary = "全部短剧:类型、分类、标签多选,分页")
@PostMapping("/dramas/page")
public Result<PageResult<AppDramaCardVO>> dramas(@RequestBody(required = false) AppDramaPageReqVO req) {
AppDramaPageReqVO body = req == null ? new AppDramaPageReqVO() : req;
int pageIndex = body.getPageIndex() == null ? DRAMA_PAGE_INDEX : body.getPageIndex();
int pageSize = body.getPageSize() == null ? DRAMA_PAGE_SIZE : body.getPageSize();
return Result.ok(appCatalogService.libraryDramas(
body.getCategoryIds(), body.getClassificationIds(), body.getTagIds(), pageIndex, pageSize));
}
@Operation(summary = "剧单列表:已启用剧单及其已上线短剧卡片,分页")
@GetMapping("/playlists")
public Result<PageResult<AppPlaylistDetailVO>> playlists(
@RequestParam(defaultValue = "1") int pageIndex,
@RequestParam(defaultValue = "10") int pageSize) {
return Result.ok(appCatalogService.playlists(pageIndex, pageSize));
}
@Operation(summary = "剧单详情:剧单基本信息 + 该页已上线短剧卡片")
@GetMapping("/playlists/{id}")
public Result<AppPlaylistDetailVO> playlistDetail(
@PathVariable Long id,
@RequestParam(defaultValue = "1") int pageIndex,
@RequestParam(defaultValue = "20") int pageSize) {
return Result.ok(appCatalogService.playlistDetail(id, pageIndex, pageSize));
}
}
@@ -8,14 +8,21 @@ import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.app.vo.AppDramaDetailVO;
import com.fruitshorts.app.vo.AppEpisodeVO;
import com.fruitshorts.app.vo.AppFeedVO;
import com.fruitshorts.app.vo.AppPlaylistDetailVO;
import com.fruitshorts.app.vo.AppSectionVO;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.ContentBanner;
import com.fruitshorts.content.dal.entity.ContentTag;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.enums.BannerPosition;
import com.fruitshorts.content.service.BannerService;
import com.fruitshorts.content.service.ContentLocaleTexts;
import com.fruitshorts.content.service.ContentTagService;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaClassificationService;
import com.fruitshorts.content.service.DramaPlaylistCategoryService;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.service.CommentService;
@@ -27,6 +34,7 @@ import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Arrays;
@@ -44,38 +52,112 @@ public class AppCatalogService {
private static final int MAX_CATEGORY_SECTIONS = 4;
private static final int DEFAULT_FEED_SIZE = 20;
private static final int MAX_FEED_SIZE = 50;
private static final int DEFAULT_PLAYLIST_PAGE_SIZE = 10;
private static final int MAX_PLAYLIST_PAGE_SIZE = 20;
private static final int DEFAULT_PLAYLIST_DRAMA_PAGE_SIZE = 20;
private static final int MAX_PLAYLIST_DRAMA_PAGE_SIZE = 50;
private static final int PLAYLIST_DRAMA_PREVIEW_SIZE = 10;
private final DramaService dramaService;
private final EpisodeService episodeService;
private final BannerService bannerService;
private final DramaCategoryService dramaCategoryService;
private final ContentTagService contentTagService;
private final DramaClassificationService dramaClassificationService;
private final DramaPlaylistCategoryService dramaPlaylistCategoryService;
private final PlaybackSigner playbackSigner;
private final CommentService commentService;
private final ThumbnailUrlResolver thumbnailUrlResolver;
public AppFeedVO homeFeed() {
Map<Long, String> categoryNames = categoryNameMap();
Map<Long, Map<String, String>> categoryLocales = categoryLocaleMap();
List<AppSectionVO> sections = new ArrayList<>();
addIfNotEmpty(sections, "hot", "热门推荐",
dramaService.listPublishedHot(HOME_SECTION_SIZE), categoryNames);
dramaService.listPublishedHot(HOME_SECTION_SIZE), categoryLocales);
addIfNotEmpty(sections, "new", "新剧上线",
dramaService.listPublishedLatest(HOME_SECTION_SIZE), categoryNames);
sections.addAll(categorySections(HOME_SECTION_SIZE, categoryNames));
dramaService.listPublishedLatest(HOME_SECTION_SIZE), categoryLocales);
sections.addAll(categorySections(HOME_SECTION_SIZE, categoryLocales));
return new AppFeedVO(banners(BannerPosition.HOME_TOP.name()), sections);
}
public AppFeedVO discoverFeed() {
return new AppFeedVO(banners(BannerPosition.DISCOVER.name()),
categorySections(DISCOVER_SECTION_SIZE, categoryNameMap()));
categorySections(DISCOVER_SECTION_SIZE, categoryLocaleMap()));
}
/** 分类频道:运营自定义的精选/新剧/榜单等(drama_classification)。 */
public List<AppCategoryVO> categories() {
return dramaCategoryService.listBasic().stream()
.filter(category -> category.getStatus() == null || category.getStatus() == 1)
.map(category -> new AppCategoryVO(category.getId(), category.getName()))
return dramaClassificationService.listEnabled().stream()
.map(c -> {
Map<String, String> locales = ContentLocaleTexts.read(
c.getNameLocales(), c.getName(), c.getNameEn(), c.getNameId());
AppCategoryVO vo = new AppCategoryVO(c.getId(), ContentLocaleTexts.value(locales, ContentLocaleTexts.ZH));
vo.setLocales(locales);
return vo;
})
.toList();
}
/** 已启用类型,名称只放 locales。 */
public List<AppCategoryVO> dramaCategories() {
return dramaCategoryService.list().stream()
.filter(item -> item.getStatus() != null && item.getStatus() == 1)
.map(item -> option(item.getId(), item.getName(), item.getLocales()))
.toList();
}
/** 已启用标签,名称只放 locales。 */
public List<AppCategoryVO> tags() {
return contentTagService.listEnabled().stream()
.map(item -> option(item.getId(), item.getName(), item.getLocales()))
.toList();
}
/** 全部短剧分页。同一行多选是或,不同类型之间是且。 */
public PageResult<AppDramaCardVO> libraryDramas(List<Long> categoryIds, List<Long> classificationIds,
List<Long> tagIds, int pageIndex, int pageSize) {
PageResult<Drama> page = dramaService.pagePublishedLibrary(
categoryIds, classificationIds, tagIds, pageIndex, pageSize);
return new PageResult<>(page.getTotal(), toCards(page.getList()));
}
private AppCategoryVO option(Long id, String name, Map<String, String> locales) {
AppCategoryVO vo = new AppCategoryVO(id, name);
if (locales != null && !locales.isEmpty()) {
vo.setLocales(locales);
}
return vo;
}
/** 分类频道:按分类分页查已上线短剧卡片(通过分类关联表过滤)。 */
public PageResult<AppDramaCardVO> categoryDramas(Long classificationId,
int pageIndex, int pageSize) {
dramaClassificationService.getEnabledById(classificationId); // 校验存在/启用
List<Long> allIds = dramaClassificationService.listDramaIdsByClassification(classificationId);
if (allIds.isEmpty()) {
return PageResult.empty();
}
// 只取已上线的,再按关联表原始顺序排
Map<Long, Integer> orderMap = new HashMap<>();
for (int i = 0; i < allIds.size(); i++) orderMap.put(allIds.get(i), i);
List<Drama> published = dramaService.listPublishedByIds(allIds);
List<Drama> ordered = published.stream()
.sorted((a, b) -> Integer.compare(
orderMap.getOrDefault(a.getId(), Integer.MAX_VALUE),
orderMap.getOrDefault(b.getId(), Integer.MAX_VALUE)))
.toList();
int safeIndex = Math.max(1, pageIndex);
int safeSize = Math.max(1, Math.min(pageSize, 50));
long total = ordered.size();
int fromIndex = (safeIndex - 1) * safeSize;
if (fromIndex >= ordered.size()) {
return new PageResult<>(total, List.of());
}
int toIndex = Math.min(fromIndex + safeSize, ordered.size());
List<Drama> pageRows = ordered.subList(fromIndex, toIndex);
return new PageResult<>(total, cards(pageRows, categoryLocaleMap()));
}
public CursorResult<AppDramaCardVO> feed(
String types, Long categoryId, Long cursor, Integer size) {
int pageSize = size == null ? DEFAULT_FEED_SIZE : Math.max(1, Math.min(size, MAX_FEED_SIZE));
@@ -93,7 +175,7 @@ public class AppCatalogService {
boolean hasMore = rows.size() > pageSize;
List<Drama> pageRows = hasMore ? rows.subList(0, pageSize) : rows;
Long nextCursor = hasMore ? pageRows.get(pageRows.size() - 1).getId() : null;
return new CursorResult<>(cards(pageRows, categoryNameMap()), nextCursor, hasMore);
return new CursorResult<>(cards(pageRows, categoryLocaleMap()), nextCursor, hasMore);
}
/**
@@ -104,7 +186,7 @@ public class AppCatalogService {
if (dramas == null || dramas.isEmpty()) {
return List.of();
}
return cards(dramas, categoryNameMap());
return cards(dramas, categoryLocaleMap());
}
public AppDramaDetailVO dramaDetail(Long dramaId) {
@@ -114,15 +196,18 @@ public class AppCatalogService {
episodes.stream().map(DramaEpisode::getId).sorted().toList());
Map<Long, String> thumbnailUrls = thumbnailUrlResolver.resolveAll(
episodes.stream().map(DramaEpisode::getId).sorted().toList());
String categoryName = categoryNameMap().getOrDefault(drama.getCategoryId(), "");
List<Map<String, String>> types = dramaCategoryService.localesForDrama(drama, categoryLocaleMap());
AppDramaDetailVO detail = new AppDramaDetailVO();
detail.setId(drama.getId());
detail.setTitle(drama.getTitle());
detail.setSynopsis(drama.getSynopsis());
Map<String, String> titles = ContentLocaleTexts.read(drama.getTitleLocales(), drama.getTitle(), null, null);
Map<String, String> synopses = ContentLocaleTexts.read(
drama.getSynopsisLocales(), drama.getSynopsis(), null, null);
detail.setTitleLocales(titles);
detail.setSynopsisLocales(synopses);
detail.setCover(drama.getCoverUrl());
detail.setGenre(drama.getGenre());
detail.setCategory(categoryName);
detail.setTypes(types);
fillDetailTag(detail, drama);
detail.setTotalEpisodes(drama.getTotalEpisodes());
detail.setFreeEpisodes(drama.getFreeEpisodes());
detail.setUnlockCoin(drama.getUnlockCoin());
@@ -137,11 +222,72 @@ public class AppCatalogService {
return detail;
}
/** C 端剧单列表:按页返回已启用剧单,每条只含该页剧单的已上线短剧。 */
public PageResult<AppPlaylistDetailVO> playlists(int pageIndex, int pageSize) {
var all = dramaPlaylistCategoryService.listEnabled();
int safeIndex = Math.max(1, pageIndex);
int safeSize = pageSize <= 0
? DEFAULT_PLAYLIST_PAGE_SIZE
: Math.min(pageSize, MAX_PLAYLIST_PAGE_SIZE);
int fromIndex = (safeIndex - 1) * safeSize;
if (fromIndex >= all.size()) {
return new PageResult<>(all.size(), List.of());
}
int toIndex = Math.min(fromIndex + safeSize, all.size());
List<AppPlaylistDetailVO> page = new ArrayList<>();
for (var category : all.subList(fromIndex, toIndex)) {
page.add(playlistDetail(category.getId(), 1, PLAYLIST_DRAMA_PREVIEW_SIZE));
}
return new PageResult<>(all.size(), page);
}
/** C 端剧单详情:剧单基本信息 + 该页已上线短剧卡片。 */
public AppPlaylistDetailVO playlistDetail(Long playlistId, int pageIndex, int pageSize) {
var category = dramaPlaylistCategoryService.getEnabledById(playlistId);
List<Long> allIds = dramaPlaylistCategoryService.listDramaIdsByCategory(playlistId);
List<Drama> published = dramaService.listPublishedByIds(allIds);
// 保留关联表原始顺序:按 allIds 位置重新排序
Map<Long, Integer> orderMap = new HashMap<>();
for (int i = 0; i < allIds.size(); i++) {
orderMap.put(allIds.get(i), i);
}
List<Drama> ordered = published.stream()
.sorted((a, b) -> {
Integer ia = orderMap.getOrDefault(a.getId(), Integer.MAX_VALUE);
Integer ib = orderMap.getOrDefault(b.getId(), Integer.MAX_VALUE);
return Integer.compare(ia, ib);
})
.toList();
int safeIndex = Math.max(1, pageIndex);
int safeSize = pageSize <= 0
? DEFAULT_PLAYLIST_DRAMA_PAGE_SIZE
: Math.min(pageSize, MAX_PLAYLIST_DRAMA_PAGE_SIZE);
int from = (safeIndex - 1) * safeSize;
List<Drama> pageRows = from >= ordered.size()
? List.of()
: ordered.subList(from, Math.min(from + safeSize, ordered.size()));
Map<String, String> locales = ContentLocaleTexts.read(
category.getNameLocales(), category.getName(), category.getNameEn(), category.getNameId());
Map<String, String> descriptions = ContentLocaleTexts.read(
category.getDescriptionLocales(), category.getDescription(),
category.getDescriptionEn(), category.getDescriptionId());
AppPlaylistDetailVO vo = new AppPlaylistDetailVO();
vo.setId(category.getId());
vo.setLocales(locales);
vo.setDescriptionLocales(descriptions);
vo.setDramaCount(ordered.size());
vo.setPageIndex(safeIndex);
vo.setPageSize(safeSize);
vo.setList(cards(pageRows, categoryLocaleMap()));
return vo;
}
private List<AppBannerVO> banners(String position) {
return bannerService.listActive(position).stream().map(this::toBanner).toList();
}
private List<AppSectionVO> categorySections(int size, Map<Long, String> categoryNames) {
private List<AppSectionVO> categorySections(int size, Map<Long, Map<String, String>> categoryLocales) {
List<AppSectionVO> sections = new ArrayList<>();
for (DramaCategoryRespVO category : dramaCategoryService.listBasic()) {
if (sections.size() >= MAX_CATEGORY_SECTIONS) {
@@ -151,17 +297,17 @@ public class AppCatalogService {
if (!dramas.isEmpty()) {
sections.add(new AppSectionVO(
"category-" + category.getId(),
category.getName(),
cards(dramas, categoryNames)));
category.getLocales(),
cards(dramas, categoryLocales)));
}
}
return sections;
}
private void addIfNotEmpty(List<AppSectionVO> sections, String code, String title,
List<Drama> dramas, Map<Long, String> categoryNames) {
List<Drama> dramas, Map<Long, Map<String, String>> categoryLocales) {
if (!dramas.isEmpty()) {
sections.add(new AppSectionVO(code, title, cards(dramas, categoryNames)));
sections.add(new AppSectionVO(code, title, cards(dramas, categoryLocales)));
}
}
@@ -175,30 +321,36 @@ public class AppCatalogService {
}
/** 整屏卡片共用一次剧集查询,取每部剧的首个可播免费集作为预览。 */
private List<AppDramaCardVO> cards(List<Drama> dramas, Map<Long, String> categoryNames) {
private List<AppDramaCardVO> cards(List<Drama> dramas, Map<Long, Map<String, String>> categoryLocales) {
List<Long> ids = dramas.stream().map(Drama::getId).toList();
Map<Long, DramaEpisode> previews = episodeService.firstPlayableByDramas(ids);
Map<Long, Long> commentCounts = commentService.countByEpisodes(
previews.values().stream().map(DramaEpisode::getId).sorted().toList());
Map<Long, String> thumbnailUrls = thumbnailUrlResolver.resolveAll(
previews.values().stream().map(DramaEpisode::getId).sorted().toList());
Map<Long, ContentTag> tags = loadTags(dramas);
return dramas.stream()
.map(drama -> toCard(
drama, categoryNames, previews.get(drama.getId()), commentCounts,
drama, categoryLocales, tags, previews.get(drama.getId()), commentCounts,
thumbnailUrls))
.toList();
}
private AppDramaCardVO toCard(Drama drama, Map<Long, String> categoryNames,
private AppDramaCardVO toCard(Drama drama, Map<Long, Map<String, String>> categoryLocales,
Map<Long, ContentTag> tags,
DramaEpisode preview, Map<Long, Long> commentCounts,
Map<Long, String> thumbnailUrls) {
List<Map<String, String>> types = dramaCategoryService.localesForDrama(drama, categoryLocales);
AppDramaCardVO card = new AppDramaCardVO();
card.setId(drama.getId());
card.setTitle(drama.getTitle());
Map<String, String> titles = ContentLocaleTexts.read(drama.getTitleLocales(), drama.getTitle(), null, null);
Map<String, String> synopses = ContentLocaleTexts.read(
drama.getSynopsisLocales(), drama.getSynopsis(), null, null);
card.setTitleLocales(titles);
card.setCover(drama.getCoverUrl());
card.setSynopsis(shortSynopsis(drama.getSynopsis()));
card.setGenre(drama.getGenre());
card.setCategory(categoryNames.getOrDefault(drama.getCategoryId(), ""));
card.setSynopsisLocales(shortLocales(synopses));
card.setTypes(types);
fillTag(card, drama, tags);
card.setTotalEpisodes(drama.getTotalEpisodes());
card.setIsHot(drama.getIsHot() != null && drama.getIsHot() == 1);
card.setViewCount(drama.getViewCount());
@@ -245,13 +397,58 @@ public class AppCatalogService {
return episode.getVideoUrl() == null ? "" : episode.getVideoUrl();
}
private Map<Long, String> categoryNameMap() {
Map<Long, String> names = new HashMap<>();
dramaCategoryService.listBasic().forEach(category -> names.put(category.getId(), category.getName()));
return names;
private Map<Long, ContentTag> loadTags(List<Drama> dramas) {
List<Long> ids = dramas.stream()
.map(Drama::getTagId)
.filter(id -> id != null && id > 0L)
.distinct()
.toList();
Map<Long, ContentTag> tags = contentTagService.findByIds(ids);
return tags == null ? Map.of() : tags;
}
private void fillTag(AppDramaCardVO card, Drama drama, Map<Long, ContentTag> tags) {
ContentTag tag = tagOf(drama, tags);
Map<String, String> locales = tagLocales(tag);
card.setTagLocales(locales);
card.setTagColor(tag == null ? "" : tag.getBgColor());
}
private void fillDetailTag(AppDramaDetailVO detail, Drama drama) {
ContentTag tag = tagOf(drama, loadTags(List.of(drama)));
Map<String, String> locales = tagLocales(tag);
detail.setTagLocales(locales);
detail.setTagColor(tag == null ? "" : tag.getBgColor());
}
private ContentTag tagOf(Drama drama, Map<Long, ContentTag> tags) {
if (drama.getTagId() == null || drama.getTagId() <= 0L) {
return null;
}
return tags.get(drama.getTagId());
}
private Map<String, String> tagLocales(ContentTag tag) {
if (tag == null) {
return Map.of();
}
return ContentLocaleTexts.read(tag.getNameLocales(), tag.getName(), tag.getNameEn(), tag.getNameId());
}
private Map<Long, Map<String, String>> categoryLocaleMap() {
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 "";
@@ -2,13 +2,19 @@
package com.fruitshorts.app.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.Map;
/** 首页分类。名称只放在 locales。 */
@Data
@AllArgsConstructor
public class AppCategoryVO {
private Long id;
private String name;
private Map<String, String> locales;
public AppCategoryVO(Long id, String name) {
this.id = id;
this.locales = name == null || name.isBlank() ? Map.of() : Map.of("zh", name);
}
}
@@ -5,20 +5,26 @@ package com.fruitshorts.app.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
import java.util.Map;
/** 列表卡片,用于首页/发现页/分类分区 */
@Data
public class AppDramaCardVO {
private Long id;
private String title;
private Map<String, String> titleLocales;
private String cover;
private String synopsis;
private String genre;
private String category;
private Map<String, String> synopsisLocales;
/** 短剧选中的全部类型,每项是一份语言表。 */
private List<Map<String, String>> types;
/** 标签语言表,key 为 zh / en / id。 */
private Map<String, String> tagLocales;
/** 标签角标背景色,#RRGGBB。 */
private String tagColor;
private Integer totalEpisodes;
private Boolean isHot;
private Long viewCount;
/** 短剧累计点赞数:drama.like_count */
private Long likeCount;
/** 短剧累计收藏数:drama.favorite_count */
private Long favoriteCount;
@@ -5,16 +5,18 @@ package com.fruitshorts.app.vo;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
public class AppDramaDetailVO {
private Long id;
private String title;
private String synopsis;
private Map<String, String> titleLocales;
private Map<String, String> synopsisLocales;
private String cover;
private String genre;
private String category;
private List<Map<String, String>> types;
private Map<String, String> tagLocales;
private String tagColor;
private Integer totalEpisodes;
private Integer freeEpisodes;
private Integer unlockCoin;
@@ -0,0 +1,21 @@
// Author: fruit.com.cn
package com.fruitshorts.app.vo;
import lombok.Data;
import java.util.List;
/** 全部短剧分页筛选。多选 id 放在请求体里,避免查询串过长。 */
@Data
public class AppDramaPageReqVO {
private Integer pageIndex;
private Integer pageSize;
/** 类型 id,多选为或。 */
private List<Long> categoryIds;
/** 分类 id,多选为或。 */
private List<Long> classificationIds;
/** 标签 id,多选为或。 */
private List<Long> tagIds;
}
@@ -0,0 +1,24 @@
// Author: fruit.com.cn
package com.fruitshorts.app.vo;
import lombok.Data;
import java.util.List;
import java.util.Map;
/** C 端剧单详情:基本信息 + 该剧单下已上线的短剧卡片。 */
@Data
public class AppPlaylistDetailVO {
private Long id;
private Map<String, String> locales;
private Map<String, String> descriptionLocales;
/** 该剧单下已上线短剧的实际条数(被下架/下线的不计入) */
private Integer dramaCount;
/** 当前短剧页码,从 1 开始 */
private Integer pageIndex;
/** 当前短剧页大小 */
private Integer pageSize;
private List<AppDramaCardVO> list;
}
@@ -2,18 +2,28 @@
package com.fruitshorts.app.vo;
import lombok.AllArgsConstructor;
import com.fruitshorts.content.service.ContentLocaleTexts;
import lombok.Data;
import java.util.List;
import java.util.Map;
/** 首页/发现页的一个横滑分区 */
@Data
@AllArgsConstructor
public class AppSectionVO {
/** 分区标识:hot / new / category-{id},客户端可据此做埋点与样式 */
private String code;
private String title;
private Map<String, String> titleLocales;
private List<AppDramaCardVO> dramas;
public AppSectionVO(String code, String title, List<AppDramaCardVO> dramas) {
this(code, Map.of(ContentLocaleTexts.ZH, title == null ? "" : title), dramas);
}
public AppSectionVO(String code, Map<String, String> titleLocales, List<AppDramaCardVO> dramas) {
this.code = code;
this.titleLocales = titleLocales;
this.dramas = dramas;
}
}
@@ -17,15 +17,18 @@ import com.fruitshorts.playback.service.PlaybackService;
import com.fruitshorts.interaction.service.CommentService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.hamcrest.Matchers.nullValue;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -58,7 +61,7 @@ class AppCatalogControllerContractTest {
banner.setLinkValue("12");
AppDramaCardVO card = new AppDramaCardVO();
card.setId(12L);
card.setTitle("命运的相遇");
card.setTitleLocales(Map.of("zh", "命运的相遇"));
card.setViewCount(12400L);
card.setLikeCount(3200L);
card.setFavoriteCount(1800L);
@@ -94,7 +97,7 @@ class AppCatalogControllerContractTest {
void should_return_typed_cursor_feed() throws Exception {
AppDramaCardVO card = new AppDramaCardVO();
card.setId(12L);
card.setTitle("漫剧卡片");
card.setTitleLocales(Map.of("zh", "漫剧卡片"));
card.setDramaType("ANIME");
given(catalogService.feed("ANIME", 7L, null, 20))
.willReturn(new CursorResult<>(List.of(card), 12L, true));
@@ -122,14 +125,14 @@ class AppCatalogControllerContractTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data[0].id").value(3))
.andExpect(jsonPath("$.data[0].name").value("都市"));
.andExpect(jsonPath("$.data[0].locales.zh").value("都市"));
}
@Test
void should_bind_path_variable_when_drama_detail_is_requested() throws Exception {
AppDramaDetailVO detail = new AppDramaDetailVO();
detail.setId(42L);
detail.setTitle("命运的相遇");
detail.setTitleLocales(Map.of("zh", "命运的相遇"));
detail.setViewCount(12400L);
detail.setLikeCount(3200L);
detail.setFavoriteCount(1800L);
@@ -142,7 +145,7 @@ class AppCatalogControllerContractTest {
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").value("42"))
.andExpect(jsonPath("$.data.id").isString())
.andExpect(jsonPath("$.data.title").value("命运的相遇"))
.andExpect(jsonPath("$.data.titleLocales.zh").value("命运的相遇"))
.andExpect(jsonPath("$.data.viewCount").value("12400"))
.andExpect(jsonPath("$.data.likeCount").value("3200"))
.andExpect(jsonPath("$.data.favoriteCount").value("1800"))
@@ -161,4 +164,23 @@ class AppCatalogControllerContractTest {
.andExpect(jsonPath("$.code").value(ErrorCode.DATA_NOT_FOUND.getCode()))
.andExpect(jsonPath("$.data").value(nullValue()));
}
@Test
void should_bind_filter_ids_from_body_when_drama_page_is_posted() throws Exception {
AppDramaCardVO card = new AppDramaCardVO();
card.setId(9L);
card.setTitleLocales(Map.of("zh", "重生"));
given(catalogService.libraryDramas(List.of(2L), List.of(3L), List.of(4L), 1, 20))
.willReturn(new com.fruitshorts.common.pojo.PageResult<>(1L, List.of(card)));
catalogMvc.perform(post("/app/dramas/page")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"pageIndex\":1,\"pageSize\":20,"
+ "\"categoryIds\":[\"2\"],\"classificationIds\":[\"3\"],\"tagIds\":[\"4\"]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value("1"))
.andExpect(jsonPath("$.data.list[0].titleLocales.zh").value("重生"));
verify(catalogService).libraryDramas(List.of(2L), List.of(3L), List.of(4L), 1, 20);
}
}
@@ -7,7 +7,10 @@ import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.BannerService;
import com.fruitshorts.content.service.ContentTagService;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaClassificationService;
import com.fruitshorts.content.service.DramaPlaylistCategoryService;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.service.CommentService;
@@ -45,12 +48,15 @@ class AppCatalogServiceTest {
episodeService = mock(EpisodeService.class);
BannerService bannerService = mock(BannerService.class);
dramaCategoryService = mock(DramaCategoryService.class);
DramaClassificationService dramaClassificationService = mock(DramaClassificationService.class);
DramaPlaylistCategoryService dramaPlaylistCategoryService = mock(DramaPlaylistCategoryService.class);
PlaybackSigner playbackSigner = mock(PlaybackSigner.class);
thumbnailUrlResolver = mock(ThumbnailUrlResolver.class);
given(thumbnailUrlResolver.resolveAll(any())).willReturn(Map.of());
commentService = mock(CommentService.class);
catalogService = new AppCatalogService(dramaService, episodeService, bannerService,
dramaCategoryService, playbackSigner, commentService, thumbnailUrlResolver);
dramaCategoryService, mock(ContentTagService.class), dramaClassificationService, dramaPlaylistCategoryService,
playbackSigner, commentService, thumbnailUrlResolver);
}
@Test
@@ -43,6 +43,8 @@ class DramaServiceFavoriteCountTest {
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
mock(DramaCategoryService.class),
mock(com.fruitshorts.content.dal.mapper.ContentTagMapper.class),
mock(com.fruitshorts.content.service.ContentTagService.class),
mock(EpisodePriceSync.class));
given(dramaMapper.update(isNull(), any())).willReturn(1);
ArgumentCaptor<Wrapper<Drama>> captor = ArgumentCaptor.forClass(Wrapper.class);
@@ -47,6 +47,8 @@ class DramaServiceFeedTest {
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
mock(DramaCategoryService.class),
mock(com.fruitshorts.content.dal.mapper.ContentTagMapper.class),
mock(com.fruitshorts.content.service.ContentTagService.class),
mock(EpisodePriceSync.class));
}
@@ -48,6 +48,8 @@ class DramaServiceK88Test {
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
mock(DramaCategoryService.class),
mock(com.fruitshorts.content.dal.mapper.ContentTagMapper.class),
mock(com.fruitshorts.content.service.ContentTagService.class),
mock(EpisodePriceSync.class));
}
@@ -56,6 +56,8 @@ class DramaServiceMultiTagTest {
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
categoryService,
mock(com.fruitshorts.content.dal.mapper.ContentTagMapper.class),
mock(com.fruitshorts.content.service.ContentTagService.class),
mock(EpisodePriceSync.class));
}
@@ -38,6 +38,9 @@ public class SecurityConfig {
"/app/discover/**",
"/app/feed",
"/app/categories/**",
"/app/drama-categories",
"/app/tags",
"/app/playlists/**",
"/app/banners/**",
"/app/dramas/**",
"/app/search",
@@ -0,0 +1,31 @@
// Author: fruit.com.cn
package com.fruitshorts.content.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 短剧标签。名称分中文、英文、印尼语。 */
@Data
@TableName("content_tag")
public class ContentTag {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 中文名称。 */
private String name;
private String nameEn;
private String nameId;
/** 已选语言的名称。 */
private String nameLocales;
/** 角标背景色,#RRGGBB。 */
private String bgColor;
private Integer sort;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -26,6 +26,10 @@ public class Drama {
private String categoryIds;
private String genre;
private String synopsis;
/** 标题语言表,JSON。 */
private String titleLocales;
/** 简介语言表,JSON。 */
private String synopsisLocales;
private String coverUrl;
private String horizontalCoverUrl;
private String language;
@@ -33,6 +37,8 @@ public class Drama {
private String dramaType;
/** 内容类型多选,逗号分隔(ANIME/LIVE),drama_type 保留第一个 */
private String dramaTypes;
/** 单选标签,对应 content_tag.id。 */
private Long tagId;
private Integer totalEpisodes;
private Integer freeEpisodes;
private Integer unlockCoin;
@@ -14,6 +14,10 @@ public class DramaCategory {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String name;
private String nameEn;
private String nameId;
/** 已选语言的名称。 */
private String nameLocales;
private Integer sort;
private Integer status;
}
@@ -16,7 +16,12 @@ public class DramaClassification {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 中文名称。 */
private String name;
private String nameEn;
private String nameId;
/** 已选语言的名称。 */
private String nameLocales;
private Integer sort;
private Integer status;
private LocalDateTime createdAt;
@@ -16,9 +16,18 @@ public class DramaPlaylistCategory {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 中文名称,列表和唯一性都用这一列。 */
private String name;
/** 分类描述,便于运营说明该分类的用途 */
private String nameEn;
private String nameId;
/** 中文描述。 */
private String description;
private String descriptionEn;
private String descriptionId;
/** 已选语言的名称。 */
private String nameLocales;
/** 已选语言的描述。 */
private String descriptionLocales;
private Integer sort;
private Integer status;
private LocalDateTime createdAt;
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
package com.fruitshorts.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.content.dal.entity.ContentTag;
public interface ContentTagMapper extends BaseMapper<ContentTag> {
}
@@ -4,6 +4,46 @@ package com.fruitshorts.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface DramaEpisodeMapper extends BaseMapper<DramaEpisode> {
/**
* 查询需要补转码的分集:有片源但没有可用码率产物,或缺少缩略图轴,且当前没有排队/执行中的任务。
*
* @param afterId 上一批处理到的分集 ID,用于游标轮转,避免固定前 N 条长期占用名额
* @param limit 单批上限
* @param requireFrames 是否把"缺缩略图"也视为缺失;关闭雪碧图时传 false
*/
@Select("""
<script>
SELECT e.id, e.drama_id, e.video_asset_id, e.video_url
FROM drama_episode e
WHERE e.deleted = 0
AND e.id &gt; #{afterId}
AND (e.video_asset_id &gt; 0
OR (e.video_url IS NOT NULL AND e.video_url &lt;&gt; ''))
AND NOT EXISTS (
SELECT 1 FROM transcode_task t
WHERE t.episode_id = e.id AND t.status IN ('QUEUED', 'RUNNING'))
AND (
NOT EXISTS (
SELECT 1 FROM media_rendition r
WHERE r.episode_id = e.id AND r.deleted = 0 AND r.status = 'ready')
<if test="requireFrames">
OR NOT EXISTS (
SELECT 1 FROM media_thumbnail m
WHERE m.episode_id = e.id AND m.deleted = 0)
</if>
)
ORDER BY e.id ASC
LIMIT #{limit}
</script>
""")
List<DramaEpisode> selectTranscodeBackfillCandidates(@Param("afterId") long afterId,
@Param("limit") int limit,
@Param("requireFrames") boolean requireFrames);
}
@@ -0,0 +1,173 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** 内容名称的可选语言。中文必填,英文和印尼语按勾选保存。 */
public final class ContentLocaleTexts {
public static final String ZH = "zh";
public static final String EN = "en";
public static final String ID = "id";
private static final int NAME_LIMIT = 64;
private static final int TITLE_LIMIT = 80;
private static final int DESCRIPTION_LIMIT = 255;
private static final int SYNOPSIS_LIMIT = 1000;
private static final List<String> CODES = List.of(ZH, EN, ID);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final TypeReference<Map<String, String>> MAP_TYPE = new TypeReference<>() {
};
private ContentLocaleTexts() {
}
public static Map<String, String> names(Map<String, String> locales, String name,
String nameEn, String nameId) {
Map<String, String> source = locales == null ? legacy(name, nameEn, nameId) : locales;
return normalize(source, NAME_LIMIT, "名称");
}
public static Map<String, String> titles(Map<String, String> locales, String title) {
Map<String, String> source = locales == null ? legacy(title, null, null) : locales;
return normalize(source, TITLE_LIMIT, "名称");
}
public static Map<String, String> descriptions(Map<String, String> locales, Map<String, String> names,
String description, String descriptionEn,
String descriptionId) {
return optional(locales, names, description, descriptionEn, descriptionId, DESCRIPTION_LIMIT, "描述");
}
public static Map<String, String> synopses(Map<String, String> locales, Map<String, String> titles,
String synopsis) {
return optional(locales, titles, synopsis, null, null, SYNOPSIS_LIMIT, "简介");
}
private static Map<String, String> optional(Map<String, String> locales, Map<String, String> required,
String zh, String en, String id, int limit, String field) {
Map<String, String> source = locales == null ? legacy(zh, en, id) : locales;
Map<String, String> result = new LinkedHashMap<>();
for (String code : required.keySet()) {
String value = trim(source.get(code));
if (value.length() > limit) {
throw tooLong(code, field, limit);
}
if (!value.isEmpty()) {
result.put(code, value);
}
}
return result;
}
public static Map<String, String> read(String json, String zh, String en, String id) {
Map<String, String> parsed = parse(json);
Map<String, String> result = new LinkedHashMap<>();
put(result, ZH, first(parsed.get(ZH), zh));
put(result, EN, first(parsed.get(EN), en));
put(result, ID, first(parsed.get(ID), id));
return result;
}
public static String json(Map<String, String> locales) {
try {
return MAPPER.writeValueAsString(locales == null ? Map.of() : locales);
} catch (Exception ex) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "语言内容无法保存");
}
}
public static String value(Map<String, String> locales, String code) {
String text = locales == null ? null : locales.get(code);
return text == null ? "" : text;
}
private static Map<String, String> normalize(Map<String, String> source, int limit, String field) {
Map<String, String> result = new LinkedHashMap<>();
for (String code : CODES) {
if (!source.containsKey(code)) {
continue;
}
String value = trim(source.get(code));
if (value.isEmpty()) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请填写" + label(code) + field);
}
if (value.length() > limit) {
throw tooLong(code, field, limit);
}
result.put(code, value);
}
rejectUnknown(source);
if (!result.containsKey(ZH)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请选择中文并填写中文" + field);
}
return result;
}
private static Map<String, String> legacy(String zh, String en, String id) {
Map<String, String> source = new LinkedHashMap<>();
source.put(ZH, zh);
if (!trim(en).isEmpty()) {
source.put(EN, en);
}
if (!trim(id).isEmpty()) {
source.put(ID, id);
}
return source;
}
private static void rejectUnknown(Map<String, String> source) {
for (String code : source.keySet()) {
if (!CODES.contains(code)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "不支持的语言");
}
}
}
private static Map<String, String> parse(String json) {
if (json == null || json.isBlank()) {
return Map.of();
}
try {
Map<String, String> parsed = MAPPER.readValue(json, MAP_TYPE);
return parsed == null ? Map.of() : parsed;
} catch (Exception ex) {
return Map.of();
}
}
private static void put(Map<String, String> target, String code, String value) {
String text = trim(value);
if (!text.isEmpty()) {
target.put(code, text);
}
}
private static String first(String preferred, String fallback) {
return trim(preferred).isEmpty() ? fallback : preferred;
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private static String label(String code) {
return switch (code) {
case EN -> "英文";
case ID -> "印尼语";
default -> "中文";
};
}
private static ServiceException tooLong(String code, String field, int limit) {
return new ServiceException(ErrorCode.BAD_REQUEST, label(code) + field + "最多 " + limit + " 个字符");
}
}
@@ -0,0 +1,179 @@
// 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.content.dal.entity.ContentTag;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.mapper.ContentTagMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.vo.ContentTagRespVO;
import com.fruitshorts.content.vo.ContentTagSaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** 短剧标签。名称先选语言,中文必填,英文和印尼语按勾选填写。 */
@Service
@RequiredArgsConstructor
public class ContentTagService {
private final ContentTagMapper tagMapper;
private final DramaMapper dramaMapper;
public List<ContentTagRespVO> list() {
return tagMapper.selectList(Wrappers.<ContentTag>lambdaQuery()
.orderByAsc(ContentTag::getSort)
.orderByDesc(ContentTag::getId))
.stream()
.map(this::toResp)
.toList();
}
public List<ContentTagRespVO> listEnabled() {
return tagMapper.selectList(Wrappers.<ContentTag>lambdaQuery()
.eq(ContentTag::getStatus, 1)
.orderByAsc(ContentTag::getSort)
.orderByDesc(ContentTag::getId))
.stream()
.map(this::toResp)
.toList();
}
public Long create(ContentTagSaveReqVO req) {
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), null);
ContentTag entity = new ContentTag();
writeNames(entity, locales);
entity.setBgColor(normalizeColor(req.getBgColor()));
Long count = tagMapper.selectCount(null);
entity.setSort(req.getSort() == null
? (count == null ? 0 : count.intValue()) + 1
: req.getSort());
entity.setStatus(req.getStatus() == null ? 1 : req.getStatus());
tagMapper.insert(entity);
return entity.getId();
}
public void update(Long id, ContentTagSaveReqVO req) {
ContentTag entity = getExisting(id);
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), id);
writeNames(entity, locales);
if (req.getBgColor() != null) {
entity.setBgColor(normalizeColor(req.getBgColor()));
}
if (req.getSort() != null) {
entity.setSort(req.getSort());
}
if (req.getStatus() != null) {
entity.setStatus(req.getStatus());
}
tagMapper.updateById(entity);
}
public void delete(Long id) {
getExisting(id);
dramaMapper.update(null, Wrappers.<Drama>lambdaUpdate()
.eq(Drama::getTagId, id)
.set(Drama::getTagId, null));
tagMapper.deleteById(id);
}
/** 短剧保存时校验标签存在且启用。空 id 表示不选。 */
public void requireEnabled(Long id) {
if (id == null || id <= 0L) {
return;
}
ContentTag tag = tagMapper.selectById(id);
if (tag == null || tag.getStatus() == null || tag.getStatus() != 1) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "标签不存在或已停用");
}
}
private Map<String, String> namesOf(ContentTagSaveReqVO req) {
return ContentLocaleTexts.names(req.getLocales(), req.getName(), req.getNameEn(), req.getNameId());
}
private void writeNames(ContentTag entity, Map<String, String> locales) {
entity.setName(locales.get(ContentLocaleTexts.ZH));
entity.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
entity.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
entity.setNameLocales(ContentLocaleTexts.json(locales));
}
private String normalizeColor(String color) {
if (color == null || color.isBlank()) {
return "#E5484D";
}
return color.trim().toUpperCase();
}
private ContentTag getExisting(Long id) {
ContentTag entity = id == null ? null : tagMapper.selectById(id);
if (entity == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "标签不存在");
}
return entity;
}
private void ensureNameAvailable(String name, Long excludeId) {
Long count = tagMapper.selectCount(Wrappers.<ContentTag>lambdaQuery()
.eq(ContentTag::getName, name.trim())
.ne(excludeId != null, ContentTag::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "标签名称已存在");
}
}
private ContentTagRespVO toResp(ContentTag tag) {
ContentTagRespVO vo = new ContentTagRespVO();
vo.setId(tag.getId());
Map<String, String> locales = ContentLocaleTexts.read(
tag.getNameLocales(), tag.getName(), tag.getNameEn(), tag.getNameId());
vo.setName(locales.get(ContentLocaleTexts.ZH));
vo.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
vo.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
vo.setLocales(locales);
vo.setBgColor(tag.getBgColor() == null || tag.getBgColor().isBlank() ? "#E5484D" : tag.getBgColor());
vo.setSort(tag.getSort());
vo.setStatus(tag.getStatus());
return vo;
}
/** 按 id 批量取出标签,供短剧列表带名称和背景色。 */
public Map<Long, ContentTag> findByIds(Collection<Long> ids) {
if (ids == null || ids.isEmpty()) {
return Map.of();
}
Map<Long, ContentTag> result = new HashMap<>();
for (ContentTag tag : tagMapper.selectBatchIds(ids)) {
if (tag != null && tag.getId() != null) {
result.put(tag.getId(), tag);
}
}
return result;
}
/** 按 id 批量取出标签语言表,供短剧列表拼卡片。 */
public Map<Long, Map<String, String>> localeMap(Collection<Long> ids) {
if (ids == null || ids.isEmpty()) {
return Map.of();
}
Map<Long, Map<String, String>> result = new HashMap<>();
for (ContentTag tag : tagMapper.selectBatchIds(ids)) {
if (tag == null || tag.getId() == null) {
continue;
}
result.put(tag.getId(), ContentLocaleTexts.read(
tag.getNameLocales(), tag.getName(), tag.getNameEn(), tag.getNameId()));
}
return result;
}
}
@@ -14,6 +14,7 @@ import com.fruitshorts.content.vo.DramaCategorySaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -30,21 +31,15 @@ public class DramaCategoryService {
Map<Long, Integer> counts = countDramasByCategory();
return dramaCategoryMapper.selectList(
Wrappers.<DramaCategory>lambdaQuery().orderByAsc(DramaCategory::getSort))
.stream().map(category -> {
DramaCategoryRespVO vo = new DramaCategoryRespVO();
vo.setId(category.getId());
vo.setName(category.getName());
vo.setSort(category.getSort());
vo.setStatus(category.getStatus());
vo.setDramaCount(counts.getOrDefault(category.getId(), 0));
return vo;
}).toList();
.stream().map(category -> toResp(category, counts.getOrDefault(category.getId(), 0)))
.toList();
}
public Long create(DramaCategorySaveReqVO req) {
ensureNameAvailable(req.getName(), null);
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), null);
DramaCategory category = new DramaCategory();
category.setName(req.getName().trim());
writeNames(category, locales);
Long count = dramaCategoryMapper.selectCount(null);
category.setSort(req.getSort() == null
? (count == null ? 0 : count.intValue()) + 1
@@ -61,20 +56,14 @@ public class DramaCategoryService {
public List<DramaCategoryRespVO> listBasic() {
return dramaCategoryMapper.selectList(
Wrappers.<DramaCategory>lambdaQuery().orderByAsc(DramaCategory::getSort))
.stream().map(category -> {
DramaCategoryRespVO vo = new DramaCategoryRespVO();
vo.setId(category.getId());
vo.setName(category.getName());
vo.setSort(category.getSort());
vo.setStatus(category.getStatus());
return vo;
}).toList();
.stream().map(category -> toResp(category, null)).toList();
}
public void update(Long id, DramaCategorySaveReqVO req) {
DramaCategory category = getExisting(id);
ensureNameAvailable(req.getName(), id);
category.setName(req.getName().trim());
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), id);
writeNames(category, locales);
if (req.getSort() != null) {
category.setSort(req.getSort());
}
@@ -126,4 +115,55 @@ public class DramaCategoryService {
return dramaCategoryMapper.selectList(null).stream()
.collect(Collectors.toMap(DramaCategory::getId, DramaCategory::getName, (a, b) -> a));
}
/** 类型 id 到已选语言名称。C 端卡片按当前语言取其中一项。 */
public Map<Long, Map<String, String>> localeMap() {
Map<Long, Map<String, String>> result = new HashMap<>();
for (DramaCategory category : dramaCategoryMapper.selectList(null)) {
result.put(category.getId(), readLocales(category));
}
return result;
}
/** 一部短剧选中的类型,按保存顺序返回语言表。 */
public List<Map<String, String>> localesForDrama(Drama drama, Map<Long, Map<String, String>> localesById) {
List<Map<String, String>> result = new ArrayList<>();
for (Long id : DramaMultiTagSupport.parseCategoryIds(drama)) {
Map<String, String> locales = localesById.get(id);
if (locales != null && !locales.isEmpty()) {
result.add(locales);
}
}
return result;
}
private DramaCategoryRespVO toResp(DramaCategory category, Integer dramaCount) {
Map<String, String> locales = readLocales(category);
DramaCategoryRespVO vo = new DramaCategoryRespVO();
vo.setId(category.getId());
vo.setName(ContentLocaleTexts.value(locales, ContentLocaleTexts.ZH));
vo.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
vo.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
vo.setLocales(locales);
vo.setSort(category.getSort());
vo.setStatus(category.getStatus());
vo.setDramaCount(dramaCount);
return vo;
}
private Map<String, String> namesOf(DramaCategorySaveReqVO req) {
return ContentLocaleTexts.names(req.getLocales(), req.getName(), req.getNameEn(), req.getNameId());
}
private void writeNames(DramaCategory category, Map<String, String> locales) {
category.setName(locales.get(ContentLocaleTexts.ZH));
category.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
category.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
category.setNameLocales(ContentLocaleTexts.json(locales));
}
private Map<String, String> readLocales(DramaCategory category) {
return ContentLocaleTexts.read(
category.getNameLocales(), category.getName(), category.getNameEn(), category.getNameId());
}
}
@@ -27,6 +27,7 @@ import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 运营分类服务。分类是独立于「短剧类型(drama_category)」的一套概念,
@@ -48,7 +49,12 @@ public class DramaClassificationService {
.stream().map(item -> {
DramaClassificationRespVO vo = new DramaClassificationRespVO();
vo.setId(item.getId());
vo.setName(item.getName());
Map<String, String> locales = ContentLocaleTexts.read(
item.getNameLocales(), item.getName(), item.getNameEn(), item.getNameId());
vo.setName(locales.get(ContentLocaleTexts.ZH));
vo.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
vo.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
vo.setLocales(locales);
vo.setSort(item.getSort());
vo.setStatus(item.getStatus());
vo.setUpdatedAt(item.getUpdatedAt());
@@ -58,9 +64,10 @@ public class DramaClassificationService {
}
public Long create(DramaClassificationSaveReqVO req) {
ensureNameAvailable(req.getName(), null);
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), null);
DramaClassification entity = new DramaClassification();
entity.setName(req.getName().trim());
writeNames(entity, locales);
Long count = classificationMapper.selectCount(null);
entity.setSort(req.getSort() == null
? (count == null ? 0 : count.intValue()) + 1
@@ -72,8 +79,9 @@ public class DramaClassificationService {
public void update(Long id, DramaClassificationSaveReqVO req) {
DramaClassification entity = getExisting(id);
ensureNameAvailable(req.getName(), id);
entity.setName(req.getName().trim());
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), id);
writeNames(entity, locales);
if (req.getSort() != null) {
entity.setSort(req.getSort());
}
@@ -181,6 +189,17 @@ public class DramaClassificationService {
return entity;
}
private Map<String, String> namesOf(DramaClassificationSaveReqVO req) {
return ContentLocaleTexts.names(req.getLocales(), req.getName(), req.getNameEn(), req.getNameId());
}
private void writeNames(DramaClassification entity, Map<String, String> locales) {
entity.setName(locales.get(ContentLocaleTexts.ZH));
entity.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
entity.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
entity.setNameLocales(ContentLocaleTexts.json(locales));
}
private void ensureNameAvailable(String name, Long excludeId) {
Long count = classificationMapper.selectCount(Wrappers.<DramaClassification>lambdaQuery()
.eq(DramaClassification::getName, name.trim())
@@ -190,6 +209,37 @@ public class DramaClassificationService {
}
}
// ========== C 端只读视图 ==========
/** C 端:只返回已启用(status=1)的分类,按 sort 升序。 */
public List<DramaClassification> listEnabled() {
return classificationMapper.selectList(Wrappers.<DramaClassification>lambdaQuery()
.eq(DramaClassification::getStatus, 1)
.orderByAsc(DramaClassification::getSort)
.orderByAsc(DramaClassification::getId));
}
/** C 端:校验分类存在且启用。 */
public DramaClassification getEnabledById(Long id) {
DramaClassification entity = getExisting(id);
if (entity.getStatus() != 1) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "分类不存在或已下架");
}
return entity;
}
/** C 端:分类下关联的全部 dramaId(按关联创建时间 desc)。 */
public List<Long> listDramaIdsByClassification(Long classificationId) {
return classificationDramaMapper.selectList(
Wrappers.<DramaClassificationDrama>lambdaQuery()
.eq(DramaClassificationDrama::getClassificationId, classificationId)
.orderByDesc(DramaClassificationDrama::getCreatedAt)
.orderByDesc(DramaClassificationDrama::getId))
.stream()
.map(DramaClassificationDrama::getDramaId)
.collect(Collectors.toList());
}
private DramaBriefVO toBrief(Drama drama) {
DramaBriefVO vo = new DramaBriefVO();
vo.setId(drama.getId());
@@ -52,6 +52,11 @@ final class DramaMultiTagSupport {
drama.setCategoryIds(String.join(",", values.stream().map(String::valueOf).toList()));
return;
}
if (req.getCategoryIds() != null) {
drama.setCategoryId(0L);
drama.setCategoryIds("");
return;
}
if (drama.getCategoryId() == null) {
drama.setCategoryId(0L);
}
@@ -27,6 +27,7 @@ import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 剧单分类服务:分类直接关联短剧,没有「剧单」这一层。
@@ -48,8 +49,19 @@ public class DramaPlaylistCategoryService {
.stream().map(item -> {
DramaPlaylistCategoryRespVO vo = new DramaPlaylistCategoryRespVO();
vo.setId(item.getId());
vo.setName(item.getName());
vo.setDescription(item.getDescription());
Map<String, String> locales = ContentLocaleTexts.read(
item.getNameLocales(), item.getName(), item.getNameEn(), item.getNameId());
Map<String, String> descriptions = ContentLocaleTexts.read(
item.getDescriptionLocales(), item.getDescription(),
item.getDescriptionEn(), item.getDescriptionId());
vo.setName(locales.get(ContentLocaleTexts.ZH));
vo.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
vo.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
vo.setLocales(locales);
vo.setDescription(ContentLocaleTexts.value(descriptions, ContentLocaleTexts.ZH));
vo.setDescriptionEn(ContentLocaleTexts.value(descriptions, ContentLocaleTexts.EN));
vo.setDescriptionId(ContentLocaleTexts.value(descriptions, ContentLocaleTexts.ID));
vo.setDescriptionLocales(descriptions);
vo.setSort(item.getSort());
vo.setStatus(item.getStatus());
vo.setDramaCount(counts.getOrDefault(item.getId(), 0));
@@ -58,10 +70,10 @@ public class DramaPlaylistCategoryService {
}
public Long create(DramaPlaylistCategorySaveReqVO req) {
ensureNameAvailable(req.getName(), null);
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), null);
DramaPlaylistCategory entity = new DramaPlaylistCategory();
entity.setName(req.getName().trim());
entity.setDescription(req.getDescription() == null ? "" : req.getDescription().trim());
writeText(entity, req, locales);
Long count = categoryMapper.selectCount(null);
entity.setSort(req.getSort() == null
? (count == null ? 0 : count.intValue()) + 1
@@ -73,11 +85,9 @@ public class DramaPlaylistCategoryService {
public void update(Long id, DramaPlaylistCategorySaveReqVO req) {
DramaPlaylistCategory entity = getExisting(id);
ensureNameAvailable(req.getName(), id);
entity.setName(req.getName().trim());
if (req.getDescription() != null) {
entity.setDescription(req.getDescription().trim());
}
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), id);
writeText(entity, req, locales);
if (req.getSort() != null) {
entity.setSort(req.getSort());
}
@@ -177,6 +187,34 @@ public class DramaPlaylistCategoryService {
return counts;
}
/** C 端用:已启用剧单,按排序升序、再按 id 倒序。 */
public List<DramaPlaylistCategory> listEnabled() {
return categoryMapper.selectList(Wrappers.<DramaPlaylistCategory>lambdaQuery()
.eq(DramaPlaylistCategory::getStatus, 1)
.orderByAsc(DramaPlaylistCategory::getSort)
.orderByDesc(DramaPlaylistCategory::getId));
}
/** C 端用:只返回已启用(status=1)的剧单。 */
public DramaPlaylistCategory getEnabledById(Long id) {
DramaPlaylistCategory entity = getExisting(id);
if (entity.getStatus() == null || entity.getStatus() != 1) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "剧单不存在或已下架");
}
return entity;
}
/** C 端用:剧单下关联的全部 dramaId(按 created_at desc)。 */
public List<Long> listDramaIdsByCategory(Long categoryId) {
return categoryDramaMapper.selectList(Wrappers.<DramaPlaylistCategoryDrama>lambdaQuery()
.eq(DramaPlaylistCategoryDrama::getCategoryId, categoryId)
.orderByDesc(DramaPlaylistCategoryDrama::getCreatedAt)
.orderByDesc(DramaPlaylistCategoryDrama::getId))
.stream()
.map(DramaPlaylistCategoryDrama::getDramaId)
.collect(Collectors.toList());
}
private DramaPlaylistCategory getExisting(Long id) {
DramaPlaylistCategory entity = id == null ? null : categoryMapper.selectById(id);
if (entity == null) {
@@ -185,6 +223,25 @@ public class DramaPlaylistCategoryService {
return entity;
}
private Map<String, String> namesOf(DramaPlaylistCategorySaveReqVO req) {
return ContentLocaleTexts.names(req.getLocales(), req.getName(), req.getNameEn(), req.getNameId());
}
private void writeText(DramaPlaylistCategory entity, DramaPlaylistCategorySaveReqVO req,
Map<String, String> locales) {
Map<String, String> descriptions = ContentLocaleTexts.descriptions(
req.getDescriptionLocales(), locales,
req.getDescription(), req.getDescriptionEn(), req.getDescriptionId());
entity.setName(locales.get(ContentLocaleTexts.ZH));
entity.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
entity.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
entity.setNameLocales(ContentLocaleTexts.json(locales));
entity.setDescription(ContentLocaleTexts.value(descriptions, ContentLocaleTexts.ZH));
entity.setDescriptionEn(ContentLocaleTexts.value(descriptions, ContentLocaleTexts.EN));
entity.setDescriptionId(ContentLocaleTexts.value(descriptions, ContentLocaleTexts.ID));
entity.setDescriptionLocales(ContentLocaleTexts.json(descriptions));
}
private void ensureNameAvailable(String name, Long excludeId) {
Long count = categoryMapper.selectCount(Wrappers.<DramaPlaylistCategory>lambdaQuery()
.eq(DramaPlaylistCategory::getName, name.trim())
@@ -10,9 +10,11 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.ContentTag;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.entity.DramaReviewRecord;
import com.fruitshorts.content.dal.mapper.ContentTagMapper;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
@@ -28,20 +30,27 @@ import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class DramaService {
private static final DateTimeFormatter CODE_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
/** 全部短剧单页上限,与分类频道分页一致。 */
private static final int PUBLISHED_PAGE_SIZE_MAX = 50;
private final DramaMapper dramaMapper;
private final DramaEpisodeMapper dramaEpisodeMapper;
private final DramaReviewRecordMapper dramaReviewRecordMapper;
private final DramaCategoryService dramaCategoryService;
private final ContentTagMapper contentTagMapper;
private final ContentTagService contentTagService;
private final EpisodePriceSync episodePriceSync;
public PageResult<DramaRespVO> page(DramaPageReqVO req) {
@@ -122,6 +131,11 @@ public class DramaService {
}
dramaMapper.updateById(drama);
episodePriceSync.syncByDramaRule(drama);
if (req.getTagId() != null && req.getTagId() <= 0L) {
dramaMapper.update(null, Wrappers.<Drama>lambdaUpdate()
.eq(Drama::getId, drama.getId())
.set(Drama::getTagId, null));
}
}
@Transactional(rollbackFor = Exception.class)
@@ -226,6 +240,75 @@ public class DramaService {
return dramaMapper.selectPage(page, wrapper).getRecords();
}
/** C 端全部短剧:类型、分类、标签多选。 */
public PageResult<Drama> pagePublishedLibrary(List<Long> categoryIds, List<Long> classificationIds,
List<Long> tagIds, int pageIndex, int pageSize) {
int safeIndex = Math.max(1, pageIndex);
int safeSize = Math.max(1, Math.min(pageSize, PUBLISHED_PAGE_SIZE_MAX));
Page<Drama> page = new Page<>(safeIndex, safeSize);
LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>lambdaQuery()
.eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode());
applyLibraryFilters(wrapper, categoryIds, classificationIds, tagIds);
wrapper.orderByDesc(Drama::getSort).orderByDesc(Drama::getId);
IPage<Drama> result = dramaMapper.selectPage(page, wrapper);
return new PageResult<>(result.getTotal(), result.getRecords());
}
private void applyLibraryFilters(LambdaQueryWrapper<Drama> wrapper, List<Long> categoryIds,
List<Long> classificationIds, List<Long> tagIds) {
List<Long> types = positiveIds(categoryIds);
if (!types.isEmpty()) {
DramaMultiTagSupport.applyCategoryIdTokens(wrapper, types);
}
List<Long> tags = positiveIds(tagIds);
if (!tags.isEmpty()) {
wrapper.in(Drama::getTagId, tags);
}
List<Long> classes = positiveIds(classificationIds);
if (!classes.isEmpty()) {
wrapper.apply(classificationInClause(classes.size()), classes.toArray());
}
}
/**
* 分类关联用占位符,id 不拼进 SQL。
*/
static String classificationInClause(int size) {
StringBuilder sql = new StringBuilder(
"id IN (SELECT drama_id FROM drama_classification_drama WHERE classification_id IN (");
for (int index = 0; index < size; index++) {
if (index > 0) {
sql.append(',');
}
sql.append('{').append(index).append('}');
}
sql.append("))");
return sql.toString();
}
private List<Long> positiveIds(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return List.of();
}
return ids.stream().filter(id -> id != null && id > 0L).distinct().toList();
}
/** C 端分类频道:按分类分页查已上线短剧,含 total。 */
public PageResult<Drama> pagePublishedByCategory(Long categoryId, int pageIndex, int pageSize) {
int safeIndex = Math.max(1, pageIndex);
int safeSize = Math.max(1, Math.min(pageSize, PUBLISHED_PAGE_SIZE_MAX));
Page<Drama> page = new Page<>(safeIndex, safeSize);
LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>lambdaQuery()
.eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode());
if (categoryId != null && categoryId > 0) {
DramaMultiTagSupport.applyCategoryIdTokens(wrapper, List.of(categoryId));
}
wrapper.orderByDesc(Drama::getSort).orderByDesc(Drama::getViewCount)
.orderByDesc(Drama::getId);
IPage<Drama> result = dramaMapper.selectPage(page, wrapper);
return new PageResult<>(result.getTotal(), result.getRecords());
}
/** 已上线短剧,按上线时间倒序 */
public List<Drama> listPublishedLatest(long limit) {
Page<Drama> page = new Page<>(1, Math.max(1, limit));
@@ -353,7 +436,13 @@ public class DramaService {
* 避免编辑表单没带 sort / language / trailerUrl 时把已有数据清掉。
*/
private void apply(Drama drama, DramaSaveReqVO req, boolean isNew) {
drama.setTitle(req.getTitle());
if (req.getTitleLocales() != null) {
Map<String, String> titles = ContentLocaleTexts.titles(req.getTitleLocales(), req.getTitle());
drama.setTitle(titles.get(ContentLocaleTexts.ZH));
drama.setTitleLocales(ContentLocaleTexts.json(titles));
} else if (isNew || req.getTitle() != null) {
drama.setTitle(req.getTitle());
}
if (isNew || req.getSubtitle() != null) {
drama.setSubtitle(req.getSubtitle() == null ? "" : req.getSubtitle());
}
@@ -363,7 +452,13 @@ public class DramaService {
if (isNew || req.getGenre() != null) {
drama.setGenre(req.getGenre() == null ? "" : req.getGenre());
}
if (isNew || req.getDescription() != null) {
if (req.getSynopsisLocales() != null) {
Map<String, String> titles = ContentLocaleTexts.titles(req.getTitleLocales(), req.getTitle());
Map<String, String> synopses = ContentLocaleTexts.synopses(
req.getSynopsisLocales(), titles, req.getDescription());
drama.setSynopsis(ContentLocaleTexts.value(synopses, ContentLocaleTexts.ZH));
drama.setSynopsisLocales(ContentLocaleTexts.json(synopses));
} else if (isNew || req.getDescription() != null) {
drama.setSynopsis(req.getDescription() == null ? "" : req.getDescription());
}
if (isNew || req.getCover() != null) {
@@ -379,6 +474,10 @@ public class DramaService {
if (isNew || req.getDramaTypes() != null || req.getDramaType() != null) {
DramaMultiTagSupport.applyDramaTypes(drama, req);
}
if (isNew || req.getTagId() != null) {
contentTagService.requireEnabled(req.getTagId());
drama.setTagId(req.getTagId() != null && req.getTagId() > 0L ? req.getTagId() : null);
}
if (isNew || req.getFreeEpisodes() != null) {
drama.setFreeEpisodes(req.getFreeEpisodes() == null ? 3 : req.getFreeEpisodes());
}
@@ -404,11 +503,10 @@ public class DramaService {
return List.of();
}
Map<Long, String> categories = dramaCategoryService.nameMap();
Map<Long, ContentTag> tags = loadTags(dramas);
return dramas.stream().map(drama -> {
List<Long> categoryIds = DramaMultiTagSupport.parseCategoryIds(drama);
Long primaryCategoryId = categoryIds.isEmpty()
? (drama.getCategoryId() == null ? 0L : drama.getCategoryId())
: categoryIds.get(0);
Long primaryCategoryId = categoryIds.isEmpty() ? null : categoryIds.get(0);
List<String> categoryNames =
DramaMultiTagSupport.categoryNames(drama, categoryIds, categories);
List<String> dramaTypes = DramaMultiTagSupport.parseDramaTypes(drama);
@@ -416,19 +514,28 @@ public class DramaService {
vo.setId(drama.getId());
vo.setCode(drama.getCode());
vo.setTitle(drama.getTitle());
vo.setSubtitle(drama.getSubtitle());
Map<String, String> titles = ContentLocaleTexts.read(
drama.getTitleLocales(), drama.getTitle(), null, null);
Map<String, String> synopses = ContentLocaleTexts.read(
drama.getSynopsisLocales(), drama.getSynopsis(), null, null);
vo.setTitle(ContentLocaleTexts.value(titles, ContentLocaleTexts.ZH));
vo.setTitleLocales(titles);
vo.setCategoryId(primaryCategoryId);
vo.setCategoryIds(categoryIds);
vo.setCategoryNames(categoryNames);
vo.setCategory(categories.getOrDefault(primaryCategoryId, ""));
vo.setCategory(primaryCategoryId == null ? "" : categories.getOrDefault(primaryCategoryId, ""));
vo.setGenre(drama.getGenre());
vo.setDescription(drama.getSynopsis());
vo.setDescription(ContentLocaleTexts.value(synopses, ContentLocaleTexts.ZH));
vo.setSynopsisLocales(synopses);
vo.setSubtitle(drama.getSubtitle());
vo.setCover(drama.getCoverUrl());
vo.setTrailerUrl(drama.getTrailerUrl());
vo.setLanguage(drama.getLanguage());
vo.setDramaType(dramaTypes.get(0));
vo.setDramaTypes(dramaTypes);
vo.setEpisodes(drama.getTotalEpisodes());
fillTag(vo, drama.getTagId() == null ? null : tags.get(drama.getTagId()));
vo.setFreeEpisodes(drama.getFreeEpisodes());
vo.setUnlockCoin(drama.getUnlockCoin());
vo.setIsHot(drama.getIsHot() != null && drama.getIsHot() == 1);
@@ -464,6 +571,29 @@ public class DramaService {
}
}
private Map<Long, ContentTag> loadTags(List<Drama> dramas) {
List<Long> ids = dramas.stream()
.map(Drama::getTagId)
.filter(id -> id != null && id > 0L)
.distinct()
.toList();
if (ids.isEmpty()) {
return new HashMap<>();
}
return contentTagMapper.selectBatchIds(ids).stream()
.collect(Collectors.toMap(ContentTag::getId, Function.identity(), (left, right) -> left));
}
private void fillTag(DramaRespVO vo, ContentTag tag) {
if (tag == null) {
return;
}
vo.setTagId(tag.getId());
vo.setTagName(tag.getName());
vo.setTagNameEn(tag.getNameEn());
vo.setTagNameId(tag.getNameId());
}
private Long nonNegativeOrZero(Long value, String fieldName) {
return value == null ? 0L : nonNegative(value, fieldName);
}
@@ -0,0 +1,19 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import lombok.Data;
/** 标签列表项。 */
@Data
public class ContentTagRespVO {
private Long id;
private String name;
private String nameEn;
private String nameId;
private java.util.Map<String, String> locales;
private String bgColor;
private Integer sort;
private Integer status;
}
@@ -0,0 +1,26 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
/** 标签新增或编辑。中文必填,其余语言按 locales 勾选。 */
@Data
public class ContentTagSaveReqVO {
@Size(max = 64, message = "中文名称最多 64 个字符")
private String name;
private String nameEn;
private String nameId;
/** 勾选的语言名称,key 为 zh / en / id。 */
private java.util.Map<String, String> locales;
@Pattern(regexp = "^$|^#[0-9A-Fa-f]{6}$", message = "背景色格式应为 #RRGGBB")
private String bgColor;
private Integer sort;
private Integer status;
}
@@ -4,11 +4,16 @@ package com.fruitshorts.content.vo;
import lombok.Data;
import java.util.Map;
@Data
public class DramaCategoryRespVO {
private Long id;
private String name;
private String nameEn;
private String nameId;
private Map<String, String> locales;
private Integer sort;
private Integer status;
/** 该类型下的短剧数量 */
@@ -2,15 +2,22 @@
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.Map;
@Data
public class DramaCategorySaveReqVO {
@NotBlank(message = "请填写类型名称")
@Size(max = 64, message = "中文名称最多 64 个字符")
private String name;
private String nameEn;
private String nameId;
/** 勾选的语言名称,key 为 zh / en / id。 */
private Map<String, String> locales;
private Integer sort;
private Integer status;
}
@@ -12,6 +12,9 @@ public class DramaClassificationRespVO {
private Long id;
private String name;
private String nameEn;
private String nameId;
private java.util.Map<String, String> locales;
private Integer sort;
private Integer status;
private Integer dramaCount;
@@ -2,16 +2,21 @@
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/** 分类新增/编辑请求体。 */
@Data
public class DramaClassificationSaveReqVO {
@NotBlank(message = "请填写分类名称")
@Size(max = 64, message = "中文名称最多 64 个字符")
private String name;
private String nameEn;
private String nameId;
/** 勾选的语言名称,key 为 zh / en / id。 */
private java.util.Map<String, String> locales;
private Integer sort;
private Integer status;
}
@@ -10,7 +10,13 @@ public class DramaPlaylistCategoryRespVO {
private Long id;
private String name;
private String nameEn;
private String nameId;
private String description;
private String descriptionEn;
private String descriptionId;
private java.util.Map<String, String> locales;
private java.util.Map<String, String> descriptionLocales;
private Integer sort;
private Integer status;
/** 该分类下已关联的短剧数量 */
@@ -2,7 +2,6 @@
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@@ -10,11 +9,19 @@ import lombok.Data;
@Data
public class DramaPlaylistCategorySaveReqVO {
@NotBlank(message = "请填写剧单分类名称")
@Size(max = 64, message = "中文名称最多 64 个字符")
private String name;
@Size(max = 255, message = "描述最多 255 个字符")
private String nameEn;
private String nameId;
/** 勾选的语言名称,key 为 zh / en / id。 */
private java.util.Map<String, String> locales;
private String description;
private String descriptionEn;
private String descriptionId;
/** 勾选语言对应的描述。 */
private java.util.Map<String, String> descriptionLocales;
private Integer sort;
private Integer status;
@@ -17,6 +17,7 @@ public class DramaRespVO {
private Long id;
private String code;
private String title;
private java.util.Map<String, String> titleLocales;
private String subtitle;
private Long categoryId;
private List<Long> categoryIds;
@@ -24,12 +25,17 @@ public class DramaRespVO {
private String category;
private String genre;
private String description;
private java.util.Map<String, String> synopsisLocales;
private String cover;
/** 整剧预告片地址 */
private String trailerUrl;
private String language;
private String dramaType;
private List<String> dramaTypes;
private Long tagId;
private String tagName;
private String tagNameEn;
private String tagNameId;
private Integer episodes;
private Integer freeEpisodes;
private Integer unlockCoin;
@@ -14,6 +14,8 @@ public class DramaSaveReqVO {
@NotBlank(message = "请填写短剧名称")
private String title;
/** 勾选语言的标题,key 为 zh / en / id。 */
private java.util.Map<String, String> titleLocales;
private String subtitle;
private Long categoryId;
@@ -21,6 +23,8 @@ public class DramaSaveReqVO {
private List<Long> categoryIds;
private String genre;
private String description;
/** 勾选语言的简介。 */
private java.util.Map<String, String> synopsisLocales;
private String cover;
/** 整剧预告片地址(复用已有的 drama.trailer_url 列) */
private String trailerUrl;
@@ -29,6 +33,8 @@ public class DramaSaveReqVO {
private String dramaType;
/** 后台多选优先,创作者中心可继续只传 dramaType */
private List<String> dramaTypes;
/** 单选标签。不传则保持原值。 */
private Long tagId;
private Integer freeEpisodes;
private Integer unlockCoin;
@PositiveOrZero(message = "收藏数不能小于 0")
@@ -0,0 +1,20 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/** 分类筛选 SQL 只保留占位符,不把 id 写进语句。 */
class DramaServiceClassificationClauseTest {
@Test
void should_use_placeholders_when_classification_ids_are_bound() {
String sql = DramaService.classificationInClause(2);
assertThat(sql).isEqualTo(
"id IN (SELECT drama_id FROM drama_classification_drama WHERE classification_id IN ({0},{1}))");
assertThat(sql).doesNotContain("211");
}
}
@@ -60,7 +60,10 @@ class DramaServiceTest {
DramaCategoryService categoryService = mock(DramaCategoryService.class);
EpisodePriceSync episodePriceSync = new EpisodePriceSync(dramaEpisodeMapper);
dramaService = new DramaService(
dramaMapper, dramaEpisodeMapper, reviewRecordMapper, categoryService, episodePriceSync);
dramaMapper, dramaEpisodeMapper, reviewRecordMapper, categoryService,
mock(com.fruitshorts.content.dal.mapper.ContentTagMapper.class),
mock(com.fruitshorts.content.service.ContentTagService.class),
episodePriceSync);
episodes = new ArrayList<>();
given(dramaEpisodeMapper.selectList(any())).willAnswer(invocation -> List.copyOf(episodes));
given(dramaEpisodeMapper.updateById(any(DramaEpisode.class))).willAnswer(invocation -> {
@@ -28,6 +28,15 @@ public class TranscodeProperties {
/** 队列空时的轮询间隔(毫秒)。 */
private long pollDelayMs = 5000L;
/** 是否自动巡检缺失码率产物/缩略图的分集并补入队(仅 worker 进程生效)。 */
private boolean backfillEnabled = true;
/** 自动补队的巡检间隔(毫秒)。 */
private long backfillDelayMs = 300000L;
/** 单轮巡检最多补入队的分集数。 */
private int backfillBatchSize = 20;
/** ffmpeg 可执行文件(生产建议绝对路径或容器内固定路径)。 */
private String ffmpegPath = "ffmpeg";
@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.ContentLocaleTexts;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.playback.dal.entity.WatchHistory;
@@ -218,7 +219,10 @@ public class WatchProgressService {
ProgressRespVO vo = toResp(progress);
Drama drama = dramaById.get(progress.getDramaId());
if (drama != null) {
vo.setDramaTitle(drama.getTitle());
Map<String, String> titles = ContentLocaleTexts.read(
drama.getTitleLocales(), drama.getTitle(), null, null);
vo.setTitleLocales(titles);
vo.setDramaTitle(ContentLocaleTexts.value(titles, ContentLocaleTexts.ZH));
vo.setDramaCover(drama.getCoverUrl());
vo.setTotalEpisodes(drama.getTotalEpisodes());
vo.setViewCount(drama.getViewCount());
@@ -5,6 +5,7 @@ package com.fruitshorts.playback.vo;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Map;
/** 续看进度。 */
@Data
@@ -12,8 +13,10 @@ public class ProgressRespVO {
private Long dramaId;
private Long episodeId;
/** 短剧标题,仅列表类接口批量装配。 */
/** 短剧标题,仅列表类接口批量装配,取中文,兼容旧客户端。 */
private String dramaTitle;
/** 短剧标题语言表,key 为 zh / en / id。 */
private Map<String, String> titleLocales;
/** 短剧封面,仅列表类接口批量装配。 */
private String dramaCover;
/** 当前续看集号。 */
@@ -0,0 +1,57 @@
// Author: fruit.com.cn
package com.fruitshorts.system.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* App 版本(app_version)。
*
* <p>iOS / Android 共用一张表,由 {@code platform} 区分;同一平台下 {@code version_code}
* 作为整数比较用(C 端用它判断是否需要升级),{@code version_name} 是展示用字符串。
*/
@Data
@TableName("app_version")
public class AppVersion {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** ANDROID / IOS。 */
private String platform;
/** 整数版本号(比较用)。 */
private Integer versionCode;
/** 展示版本号,如 1.2.0。 */
private String versionName;
/** 下载链接(Android apk 或 iOS App Store)。 */
private String downloadUrl;
/** 更新说明。 */
private String updateContent;
/** 1=强制升级,0=可选升级。 */
private Integer forceUpdate;
/** 低于此版本强制升级(兜底)。 */
private Integer minSupportedCode;
/** 1=已发布,0=已下架。 */
private Integer status;
private LocalDateTime releasedAt;
private Long createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,10 @@
// Author: fruit.com.cn
package com.fruitshorts.system.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.system.dal.entity.AppVersion;
/** AppVersion Mapper。 */
public interface AppVersionMapper extends BaseMapper<AppVersion> {
}
@@ -0,0 +1,178 @@
// Author: fruit.com.cn
package com.fruitshorts.system.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.system.dal.entity.AppVersion;
import com.fruitshorts.system.dal.mapper.AppVersionMapper;
import com.fruitshorts.system.vo.AppVersionQueryReqVO;
import com.fruitshorts.system.vo.AppVersionRespVO;
import com.fruitshorts.system.vo.AppVersionSaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
/**
* App 版本管理:后台 CRUD + 按平台查询最新发布版。
*
* <p>同一平台下 {@code version_code} 唯一(按 (platform, version_code) 校验重复)。
* 发布(status=1)即对 C 端可见,下架后 C 端 latest 查询不再返回。
*/
@Service
@RequiredArgsConstructor
public class AppVersionService {
private static final List<String> PLATFORMS = List.of("ANDROID", "IOS");
private final AppVersionMapper versionMapper;
public PageResult<AppVersionRespVO> page(AppVersionQueryReqVO req) {
Page<AppVersion> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
String platform = normalizedPlatform(req.getPlatform());
IPage<AppVersion> result = versionMapper.selectPage(page, Wrappers.<AppVersion>lambdaQuery()
.eq(req.getStatus() != null, AppVersion::getStatus, req.getStatus())
.eq(platform != null, AppVersion::getPlatform, platform)
.like(StringUtils.hasText(req.getKeyword()), AppVersion::getVersionName, req.getKeyword())
.orderByDesc(AppVersion::getVersionCode)
.orderByDesc(AppVersion::getId));
return new PageResult<>(result.getTotal(),
result.getRecords().stream().map(AppVersionRespVO::of).toList());
}
public AppVersionRespVO detail(Long id) {
return AppVersionRespVO.of(getExisting(id));
}
@Transactional(rollbackFor = Exception.class)
public Long create(AppVersionSaveReqVO req) {
String platform = normalizePlatform(req.getPlatform());
checkDuplicate(platform, req.getVersionCode(), null);
AppVersion v = new AppVersion();
applyFields(v, req, platform);
v.setStatus(1);
v.setReleasedAt(LocalDateTime.now());
versionMapper.insert(v);
return v.getId();
}
/** 批量新建:一次事务全插或全回滚。 */
@Transactional(rollbackFor = Exception.class)
public List<Long> batchCreate(List<AppVersionSaveReqVO> reqs) {
if (reqs == null || reqs.isEmpty()) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请至少传一条");
}
if (reqs.size() > 16) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "单次不超过 16 条");
}
LocalDateTime now = LocalDateTime.now();
List<Long> ids = new java.util.ArrayList<>();
for (AppVersionSaveReqVO req : reqs) {
String platform = normalizePlatform(req.getPlatform());
checkDuplicate(platform, req.getVersionCode(), null);
AppVersion v = new AppVersion();
applyFields(v, req, platform);
v.setStatus(1);
v.setReleasedAt(now);
versionMapper.insert(v);
ids.add(v.getId());
}
return ids;
}
@Transactional(rollbackFor = Exception.class)
public void update(Long id, AppVersionSaveReqVO req) {
String platform = normalizePlatform(req.getPlatform());
AppVersion existing = getExisting(id);
checkDuplicate(platform, req.getVersionCode(), id);
applyFields(existing, req, platform);
versionMapper.updateById(existing);
}
/** 发布(1)/ 下架(0)。 */
@Transactional(rollbackFor = Exception.class)
public void updateStatus(Long id, Integer status) {
if (status == null || (status != 0 && status != 1)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "状态只能是 0(下架)或 1(发布)");
}
AppVersion v = getExisting(id);
v.setStatus(status);
if (status == 1 && v.getReleasedAt() == null) {
v.setReleasedAt(LocalDateTime.now());
}
versionMapper.updateById(v);
}
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
getExisting(id);
versionMapper.deleteById(id);
}
/** C 端用:某平台最新已发布版本(版本码最大者)。 */
public AppVersion latestFor(String platform) {
String normalized = normalizePlatform(platform);
return versionMapper.selectOne(Wrappers.<AppVersion>lambdaQuery()
.eq(AppVersion::getPlatform, normalized)
.eq(AppVersion::getStatus, 1)
.orderByDesc(AppVersion::getVersionCode)
.last("LIMIT 1"));
}
// ─────────── 内部 ───────────
private void applyFields(AppVersion v, AppVersionSaveReqVO req, String platform) {
v.setPlatform(platform);
v.setVersionCode(req.getVersionCode());
v.setVersionName(req.getVersionName().trim());
v.setDownloadUrl(req.getDownloadUrl().trim());
v.setUpdateContent(StringUtils.hasText(req.getUpdateContent()) ? req.getUpdateContent().trim() : "");
v.setForceUpdate(req.getForceUpdate() == null ? 0 : req.getForceUpdate());
v.setMinSupportedCode(req.getMinSupportedCode() == null ? 0 : req.getMinSupportedCode());
}
private String normalizePlatform(String platform) {
if (!StringUtils.hasText(platform)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请选择平台");
}
String normalized = platform.trim().toUpperCase();
if (!PLATFORMS.contains(normalized)) {
throw new ServiceException(ErrorCode.BAD_REQUEST,
"平台取值不合法:" + platform + ",可选 ANDROID / IOS");
}
return normalized;
}
private String normalizedPlatform(String platform) {
if (!StringUtils.hasText(platform)) return null;
return normalizePlatform(platform);
}
/** 同平台下 version_code 不可重复(update 时排除自身 id)。 */
private void checkDuplicate(String platform, Integer versionCode, Long excludeId) {
Long count = versionMapper.selectCount(Wrappers.<AppVersion>lambdaQuery()
.eq(AppVersion::getPlatform, platform)
.eq(AppVersion::getVersionCode, versionCode)
.ne(excludeId != null, AppVersion::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.BAD_REQUEST,
"该平台下 versionCode=" + versionCode + " 已存在");
}
}
private AppVersion getExisting(Long id) {
AppVersion v = id == null ? null : versionMapper.selectById(id);
if (v == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "版本不存在");
}
return v;
}
}
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
package com.fruitshorts.system.vo;
import com.fruitshorts.common.pojo.PageParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/** App 版本后台分页查询。 */
@Data
@EqualsAndHashCode(callSuper = true)
public class AppVersionQueryReqVO extends PageParam {
/** ANDROID / IOS,空=全部。 */
private String platform;
/** 1=已发布 / 0=已下架,空=全部。 */
private Integer status;
/** 关键字(匹配 version_name)。 */
private String keyword;
}
@@ -0,0 +1,53 @@
// Author: fruit.com.cn
package com.fruitshorts.system.vo;
import com.fruitshorts.system.dal.entity.AppVersion;
import lombok.Data;
import java.time.LocalDateTime;
/** App 版本后台响应。 */
@Data
public class AppVersionRespVO {
private Long id;
private String platform;
private String platformText;
private Integer versionCode;
private String versionName;
private String downloadUrl;
private String updateContent;
private Integer forceUpdate;
private Integer minSupportedCode;
private Integer status;
private String statusText;
private LocalDateTime releasedAt;
private LocalDateTime createdAt;
public static AppVersionRespVO of(AppVersion v) {
AppVersionRespVO vo = new AppVersionRespVO();
vo.setId(v.getId());
vo.setPlatform(v.getPlatform());
vo.setPlatformText(platformText(v.getPlatform()));
vo.setVersionCode(v.getVersionCode());
vo.setVersionName(v.getVersionName());
vo.setDownloadUrl(v.getDownloadUrl());
vo.setUpdateContent(v.getUpdateContent());
vo.setForceUpdate(v.getForceUpdate());
vo.setMinSupportedCode(v.getMinSupportedCode());
vo.setStatus(v.getStatus());
vo.setStatusText(v.getStatus() != null && v.getStatus() == 1 ? "已发布" : "已下架");
vo.setReleasedAt(v.getReleasedAt());
vo.setCreatedAt(v.getCreatedAt());
return vo;
}
private static String platformText(String p) {
return switch (p == null ? "" : p.toUpperCase()) {
case "ANDROID" -> "Android";
case "IOS" -> "iOS";
default -> p == null ? "-" : p;
};
}
}
@@ -0,0 +1,41 @@
// Author: fruit.com.cn
package com.fruitshorts.system.vo;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/** App 版本新增/编辑。 */
@Data
public class AppVersionSaveReqVO {
/** ANDROID / IOS。 */
@NotBlank(message = "请选择平台")
private String platform;
@NotNull(message = "请填写整数版本号")
@Min(value = 1, message = "versionCode 必须大于 0")
@Max(value = 999999, message = "versionCode 过大")
private Integer versionCode;
@NotBlank(message = "请填写展示版本号")
@Size(max = 32, message = "versionName 过长")
private String versionName;
@NotBlank(message = "请填写下载地址")
@Size(max = 512, message = "下载地址过长")
private String downloadUrl;
@Size(max = 2000, message = "更新说明过长")
private String updateContent;
/** 0=可选(默认),1=强制升级。 */
private Integer forceUpdate;
/** 低于此版本强制升级(兜底,默认 0 不开启)。 */
private Integer minSupportedCode;
}
@@ -0,0 +1,151 @@
// Author: fruit.com.cn
package com.fruitshorts.worker;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.media.config.MediaTranscodeProfile;
import com.fruitshorts.media.config.TranscodeProperties;
import com.fruitshorts.media.dal.entity.MediaAsset;
import com.fruitshorts.media.dal.entity.TranscodeTask;
import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
import com.fruitshorts.media.service.MediaTranscodeOutputService;
import com.fruitshorts.media.service.MediaTranscodeProfileService;
import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.media.vo.TranscodeTriggerRespVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* 转码补齐巡检:定时找出"有片源但缺少码率产物或缩略图轴"的分集,自动补入转码队列。
*
* <p>与 worker 同进程、同开关(`worker-enabled`):在线入口不跑巡检,避免与播放链路抢资源。
* 已排队/执行中的分集不重复补队;最近一次任务已 FAILED 的分集不自动重试,
* 交给运营在后台处理,避免坏片源反复占用转码资源。
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "fruitshorts.media.transcode.worker-enabled", havingValue = "true")
public class TranscodeBackfillScanner {
private static final int MIN_BATCH_SIZE = 1;
private static final int MAX_BATCH_SIZE = 100;
private static final long CURSOR_START = 0L;
private final DramaEpisodeMapper episodeMapper;
private final TranscodeService transcodeService;
private final MediaAssetMapper mediaAssetMapper;
private final MediaTranscodeOutputService outputService;
private final MediaTranscodeProfileService profileService;
private final TranscodeProperties properties;
/** 游标:记录上一批处理到的分集 ID,处理完一轮后回到起点。 */
private final AtomicLong cursor = new AtomicLong(CURSOR_START);
/** 只对缺少媒资的分集告警一次,避免每轮巡检刷日志。 */
private final Set<Long> loggedMissingAssetEpisodeIds = ConcurrentHashMap.newKeySet();
@Scheduled(fixedDelayString = "${fruitshorts.media.transcode.backfill-delay-ms:300000}")
public void scan() {
if (!properties.isBackfillEnabled()) {
return;
}
try {
int triggered = scanOnce();
if (triggered > 0) {
log.info("转码补齐巡检:本轮补入 {} 个分集", triggered);
}
} catch (Exception ex) {
// 巡检异常不能让调度线程终止,否则后续分集再也补不上
log.error("转码补齐巡检异常:{}", ex.getMessage(), ex);
}
}
/**
* 扫描一批分集并补队。
*
* @return 本轮实际入队的分集数
*/
public int scanOnce() {
int limit = batchSize();
List<DramaEpisode> candidates = episodeMapper.selectTranscodeBackfillCandidates(
cursor.get(), limit, framesRequired());
if (candidates == null || candidates.isEmpty()) {
cursor.set(CURSOR_START);
return 0;
}
cursor.set(candidates.size() < limit
? CURSOR_START : candidates.get(candidates.size() - 1).getId());
int triggered = 0;
for (DramaEpisode episode : candidates) {
if (triggerIfNeeded(episode)) {
triggered++;
}
}
return triggered;
}
private boolean triggerIfNeeded(DramaEpisode episode) {
Long mediaAssetId = resolveMediaAssetId(episode);
if (mediaAssetId == null) {
warnMissingAsset(episode.getId());
return false;
}
TranscodeTask latest = transcodeService.findLatestTaskByMediaAssetId(mediaAssetId);
if (latest != null && isInProgress(latest.getStatus())) {
return false;
}
if (latest != null && TranscodeService.STATUS_FAILED.equals(latest.getStatus())) {
return false;
}
MediaAsset asset = mediaAssetMapper.selectById(mediaAssetId);
if (asset == null) {
warnMissingAsset(episode.getId());
return false;
}
TranscodeTriggerRespVO result = transcodeService.trigger(
mediaAssetId, asset.getSourceUrl(), episode.getDramaId(), episode.getId());
log.info("分集 {} 缺少码率产物或缩略图,已自动入队 task={}",
episode.getId(), result == null ? null : result.getTaskId());
return true;
}
/** 优先用分集已绑定的媒资,老数据没有绑定则按视频地址反查。 */
private Long resolveMediaAssetId(DramaEpisode episode) {
Long videoAssetId = episode.getVideoAssetId();
if (videoAssetId != null && videoAssetId > 0) {
return videoAssetId;
}
return outputService.resolveMediaAssetId(episode.getVideoUrl());
}
private boolean isInProgress(String status) {
return TranscodeService.STATUS_QUEUED.equals(status)
|| TranscodeService.STATUS_RUNNING.equals(status);
}
/** 关闭雪碧图时不会产出缩略图,此时只看码率产物,避免每轮误判重复转码。 */
private boolean framesRequired() {
MediaTranscodeProfile profile = profileService.currentProfile();
return profile != null && profile.getSprite() != null && profile.getSprite().isEnabled();
}
private int batchSize() {
return Math.max(MIN_BATCH_SIZE, Math.min(properties.getBackfillBatchSize(), MAX_BATCH_SIZE));
}
private void warnMissingAsset(Long episodeId) {
if (episodeId != null && loggedMissingAssetEpisodeIds.add(episodeId)) {
log.warn("分集 {} 找不到对应媒资,跳过自动转码", episodeId);
}
}
}
@@ -42,6 +42,10 @@ fruitshorts:
# 只有本进程打开转码(§8.5 隔离要求)
worker-enabled: true
poll-delay-ms: 3000
# 自动巡检缺失码率产物/缩略图的分集并补入队
backfill-enabled: true
backfill-delay-ms: 300000
backfill-batch-size: 20
ffmpeg-path: ${FFMPEG_PATH:ffmpeg}
ffprobe-path: ${FFPROBE_PATH:ffprobe}
segment-seconds: 6
@@ -0,0 +1,143 @@
// Author: fruit.com.cn
package com.fruitshorts.worker;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.media.config.MediaTranscodeProfile;
import com.fruitshorts.media.config.TranscodeProperties;
import com.fruitshorts.media.dal.entity.MediaAsset;
import com.fruitshorts.media.dal.entity.TranscodeTask;
import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
import com.fruitshorts.media.service.MediaTranscodeOutputService;
import com.fruitshorts.media.service.MediaTranscodeProfileService;
import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.media.vo.TranscodeTriggerRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
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 TranscodeBackfillScannerTest {
private static final long EPISODE_ID = 1L;
private static final long DRAMA_ID = 2L;
private static final long ASSET_ID = 9L;
private static final long RESOLVED_ASSET_ID = 77L;
private static final long TASK_ID = 55L;
private static final String SOURCE_URL = "/files/episode/video.mp4";
private static final String VIDEO_URL = "/files/episode/video.mp4";
private DramaEpisodeMapper episodeMapper;
private TranscodeService transcodeService;
private MediaAssetMapper mediaAssetMapper;
private MediaTranscodeOutputService outputService;
private MediaTranscodeProfileService profileService;
private TranscodeBackfillScanner scanner;
@BeforeEach
void setUp() {
episodeMapper = mock(DramaEpisodeMapper.class);
transcodeService = mock(TranscodeService.class);
mediaAssetMapper = mock(MediaAssetMapper.class);
outputService = mock(MediaTranscodeOutputService.class);
profileService = mock(MediaTranscodeProfileService.class);
TranscodeProperties properties = new TranscodeProperties();
given(profileService.currentProfile()).willReturn(new MediaTranscodeProfile());
scanner = new TranscodeBackfillScanner(episodeMapper, transcodeService,
mediaAssetMapper, outputService, profileService, properties);
}
@Test
void should_enqueue_task_when_episode_missing_rendition() {
givenCandidates(episode(ASSET_ID));
given(transcodeService.findLatestTaskByMediaAssetId(ASSET_ID)).willReturn(null);
given(mediaAssetMapper.selectById(ASSET_ID)).willReturn(asset(ASSET_ID));
given(transcodeService.trigger(ASSET_ID, SOURCE_URL, DRAMA_ID, EPISODE_ID))
.willReturn(new TranscodeTriggerRespVO(TASK_ID, TranscodeService.STATUS_QUEUED));
int triggered = scanner.scanOnce();
assertThat(triggered).isEqualTo(1);
verify(transcodeService).trigger(ASSET_ID, SOURCE_URL, DRAMA_ID, EPISODE_ID);
}
@Test
void should_resolve_asset_by_video_url_when_episode_has_no_asset_id() {
givenCandidates(episode(null));
given(outputService.resolveMediaAssetId(VIDEO_URL)).willReturn(RESOLVED_ASSET_ID);
given(transcodeService.findLatestTaskByMediaAssetId(RESOLVED_ASSET_ID)).willReturn(null);
given(mediaAssetMapper.selectById(RESOLVED_ASSET_ID)).willReturn(asset(RESOLVED_ASSET_ID));
given(transcodeService.trigger(RESOLVED_ASSET_ID, SOURCE_URL, DRAMA_ID, EPISODE_ID))
.willReturn(new TranscodeTriggerRespVO(TASK_ID, TranscodeService.STATUS_QUEUED));
int triggered = scanner.scanOnce();
assertThat(triggered).isEqualTo(1);
verify(transcodeService).trigger(RESOLVED_ASSET_ID, SOURCE_URL, DRAMA_ID, EPISODE_ID);
}
@Test
void should_skip_when_latest_task_failed() {
givenCandidates(episode(ASSET_ID));
given(transcodeService.findLatestTaskByMediaAssetId(ASSET_ID))
.willReturn(task(TranscodeService.STATUS_FAILED));
int triggered = scanner.scanOnce();
assertThat(triggered).isZero();
verify(transcodeService, never()).trigger(anyLong(), any(), anyLong(), anyLong());
}
@Test
void should_skip_when_task_still_running() {
givenCandidates(episode(ASSET_ID));
given(transcodeService.findLatestTaskByMediaAssetId(ASSET_ID))
.willReturn(task(TranscodeService.STATUS_RUNNING));
int triggered = scanner.scanOnce();
assertThat(triggered).isZero();
verify(transcodeService, never()).trigger(anyLong(), any(), anyLong(), anyLong());
}
private void givenCandidates(DramaEpisode episode) {
given(episodeMapper.selectTranscodeBackfillCandidates(anyLong(), anyInt(), anyBoolean()))
.willReturn(List.of(episode));
}
private DramaEpisode episode(Long videoAssetId) {
DramaEpisode episode = new DramaEpisode();
episode.setId(EPISODE_ID);
episode.setDramaId(DRAMA_ID);
episode.setVideoAssetId(videoAssetId);
episode.setVideoUrl(VIDEO_URL);
return episode;
}
private MediaAsset asset(Long id) {
MediaAsset asset = new MediaAsset();
asset.setId(id);
asset.setSourceUrl(SOURCE_URL);
return asset;
}
private TranscodeTask task(String status) {
TranscodeTask task = new TranscodeTask();
task.setStatus(status);
return task;
}
}
+32
View File
@@ -0,0 +1,32 @@
-- Author: fruit.com.cn
-- 修正 App 版本管理的菜单与权限:
-- 1) 删除误加的 BUTTON 类型菜单 40-44(项目其他模块都只在 permission_code 里挂权限点,
-- 不在 sys_menu 里建按钮行,否则前端菜单渲染会多出无用条目)
-- 2) 在 sys_permission 里加 app-version 完整权限点
-- 3) 给超级管理员(role_id=1)授权这些权限点
-- 1) 删除多余的 BUTTON 菜单
DELETE FROM sys_role_menu WHERE menu_id IN (40, 41, 42, 43, 44);
DELETE FROM sys_menu WHERE id IN (40, 41, 42, 43, 44);
-- 2) 权限点(code 唯一约束兜底)
INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES
(50000101, 'app-version:list', '版本管理-查看', 'App 版本', 'BUTTON', 1),
(50000102, 'app-version:create', '版本管理-新增', 'App 版本', 'BUTTON', 2),
(50000103, 'app-version:update', '版本管理-编辑', 'App 版本', 'BUTTON', 3),
(50000104, 'app-version:delete', '版本管理-删除', 'App 版本', 'BUTTON', 4)
ON CONFLICT (code) DO NOTHING;
-- 3) 超管授权(id 按现有 MAX+1 顺延)
INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT 50001, 1, p.id FROM sys_permission p WHERE p.code = 'app-version:list'
ON CONFLICT DO NOTHING;
INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT 50002, 1, p.id FROM sys_permission p WHERE p.code = 'app-version:create'
ON CONFLICT DO NOTHING;
INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT 50003, 1, p.id FROM sys_permission p WHERE p.code = 'app-version:update'
ON CONFLICT DO NOTHING;
INSERT INTO sys_role_permission (id, role_id, permission_id)
SELECT 50004, 1, p.id FROM sys_permission p WHERE p.code = 'app-version:delete'
ON CONFLICT DO NOTHING;
+50
View File
@@ -0,0 +1,50 @@
-- Author: fruit.com.cn
-- App 版本管理:表 + 后台菜单 + 角色菜单授权
CREATE TABLE IF NOT EXISTS app_version (
id BIGINT PRIMARY KEY,
platform VARCHAR(16) NOT NULL, -- ANDROID / IOS
version_code INTEGER NOT NULL, -- 整数版本号(比较用)
version_name VARCHAR(32) NOT NULL, -- 展示版本号,如 1.2.0
download_url VARCHAR(512) NOT NULL,
update_content TEXT, -- 更新说明
force_update SMALLINT NOT NULL DEFAULT 0, -- 1=强更 0=可选
min_supported_code INTEGER NOT NULL DEFAULT 0, -- 低于此版本强制升级
status SMALLINT NOT NULL DEFAULT 1, -- 1=已发布 0=已下架
released_at TIMESTAMP(3),
created_by BIGINT,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted SMALLINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_app_version_platform ON app_version(platform, version_code DESC);
CREATE INDEX IF NOT EXISTS idx_app_version_status ON app_version(status, platform);
DROP TRIGGER IF EXISTS trg_app_version_updated_at ON app_version;
CREATE TRIGGER trg_app_version_updated_at
BEFORE UPDATE ON app_version
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- 菜单:后台管理下新增「版本管理」(id=39)
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon, permission_code, type, always_show, affix, sort, visible, status, created_at, updated_at, super_admin_visible)
VALUES (39, 6, '版本管理', 'app-versions', 'app-versions', 'views/systemManagement/AppVersionView', '', 'ep:download', 'app-version:list', 'MENU', 0, 0, 9, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
ON CONFLICT (id) DO NOTHING;
-- 权限按钮
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, permission_code, type, sort, visible, status, created_at, updated_at, super_admin_visible) VALUES
(40, 39, '查看', '', '', '', 'app-version:list', 'BUTTON', 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1),
(41, 39, '新增', '', '', '', 'app-version:create', 'BUTTON', 2, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1),
(42, 39, '编辑', '', '', '', 'app-version:update', 'BUTTON', 3, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1),
(43, 39, '删除', '', '', '', 'app-version:delete', 'BUTTON', 4, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1),
(44, 39, '上下架', '', '', '', 'app-version:update', 'BUTTON', 5, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
ON CONFLICT (id) DO NOTHING;
-- 超管角色授权(sys_role_menu 需要手动 id,按现有 MAX(id) 顺延)
INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT 5000031, 1, 39 UNION ALL
SELECT 5000032, 1, 40 UNION ALL
SELECT 5000033, 1, 41 UNION ALL
SELECT 5000034, 1, 42 UNION ALL
SELECT 5000035, 1, 43 UNION ALL
SELECT 5000036, 1, 44
ON CONFLICT DO NOTHING;
+35
View File
@@ -0,0 +1,35 @@
-- Author: fruit.com.cn
-- 短剧类型名称改为勾选语言。中文仍在 name,英文和印尼语写入原列与 locales。
ALTER TABLE drama_category ADD COLUMN IF NOT EXISTS name_en VARCHAR(64) NOT NULL DEFAULT '';
ALTER TABLE drama_category ADD COLUMN IF NOT EXISTS name_id VARCHAR(64) NOT NULL DEFAULT '';
ALTER TABLE drama_category ADD COLUMN IF NOT EXISTS name_locales TEXT NOT NULL DEFAULT '{}';
UPDATE drama_category
SET name_locales = (
SELECT COALESCE(json_object_agg(k, v), '{}'::json)::text
FROM (
SELECT 'zh' AS k, name AS v WHERE name <> ''
UNION ALL SELECT 'en', name_en WHERE name_en <> ''
UNION ALL SELECT 'id', name_id WHERE name_id <> ''
) s
)
WHERE name_locales = '{}';
UPDATE drama_category AS c
SET name_en = v.name_en,
name_id = v.name_id,
name_locales = json_build_object('zh', c.name, 'en', v.name_en, 'id', v.name_id)::text
FROM (VALUES
(1::bigint, 'Urban Romance', 'Romansa Urban'),
(2, 'Rebirth Comeback', 'Kelahiran Kembali'),
(3, 'Sweet Romance', 'Romansa Manis'),
(4, 'Suspense Revenge', 'Balas Dendam'),
(2112443900437336065, 'Campus Action', 'Aksi Kampus'),
(2112443936592236545, 'Horror Mystery', 'Horor Misteri'),
(2112444003856289794, 'Infinite Travel', 'Perjalanan Tanpa Batas'),
(2112444050467590146, 'Thriller Horror', 'Thriller Horor'),
(2112444103433261057, 'Indonesia Exclusive', 'Eksklusif Indonesia')
) AS v(id, name_en, name_id)
WHERE c.id = v.id
AND (c.name_en = '' OR c.name_id = '');
@@ -0,0 +1,10 @@
-- Author: fruit.com.cn
-- 首页运营分类名称补英文、印尼语。原 name 继续表示中文。
ALTER TABLE drama_classification
ADD COLUMN IF NOT EXISTS name_en VARCHAR(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS name_id VARCHAR(64) NOT NULL DEFAULT '';
COMMENT ON COLUMN drama_classification.name IS '中文名称';
COMMENT ON COLUMN drama_classification.name_en IS '英文名称';
COMMENT ON COLUMN drama_classification.name_id IS '印尼语名称';
+54
View File
@@ -0,0 +1,54 @@
-- Author: fruit.com.cn
-- 标签管理:三种语言名称。短剧单选一个标签。
CREATE TABLE IF NOT EXISTS content_tag (
id BIGINT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
name_en VARCHAR(64) NOT NULL DEFAULT '',
name_id VARCHAR(64) NOT NULL DEFAULT '',
sort INTEGER NOT NULL DEFAULT 0,
status SMALLINT NOT NULL DEFAULT 1,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_content_tag_name ON content_tag (name);
DROP TRIGGER IF EXISTS trg_content_tag_updated_at ON content_tag;
CREATE TRIGGER trg_content_tag_updated_at
BEFORE UPDATE ON content_tag
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
ALTER TABLE drama ADD COLUMN IF NOT EXISTS tag_id BIGINT;
CREATE INDEX IF NOT EXISTS idx_drama_tag ON drama (tag_id);
INSERT INTO content_tag (id, name, name_en, name_id, sort, status)
VALUES
(2113500000000000001, '漫剧', 'Anime', 'Anime', 1, 1),
(2113500000000000002, '真人剧', 'Live Action', 'Live Action', 2, 1)
ON CONFLICT (id) DO NOTHING;
UPDATE drama SET tag_id = 2113500000000000001
WHERE drama_type = 'ANIME' AND tag_id IS NULL;
UPDATE drama SET tag_id = 2113500000000000002
WHERE (drama_type IS NULL OR drama_type = 'LIVE') AND tag_id IS NULL;
INSERT INTO sys_menu (
id, parent_id, title, route_path, route_name, component, icon,
permission_code, type, sort, visible, status, super_admin_visible
) VALUES (
45, 3, '标签管理', 'content-tags', 'content-tags',
'views/contentManagement/TagManagementView', 'ep:price-tag',
'drama:list', 'MENU', 7, 1, 1, 1
) ON CONFLICT (id) DO NOTHING;
INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT v.id, v.role_id, 45
FROM (VALUES
(2113600000000000001, 1),
(2113600000000000002, 2),
(2113600000000000003, 3),
(2113600000000000004, 4)
) AS v(id, role_id)
WHERE NOT EXISTS (
SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = v.role_id AND rm.menu_id = 45
);
@@ -0,0 +1,13 @@
-- Author: fruit.com.cn
-- 短剧标题和简介按勾选语言保存。中文仍写 title / synopsis。
ALTER TABLE drama ADD COLUMN IF NOT EXISTS title_locales TEXT NOT NULL DEFAULT '{}';
ALTER TABLE drama ADD COLUMN IF NOT EXISTS synopsis_locales TEXT NOT NULL DEFAULT '{}';
UPDATE drama
SET title_locales = json_build_object('zh', title)::text
WHERE title_locales = '{}' AND title <> '';
UPDATE drama
SET synopsis_locales = json_build_object('zh', synopsis)::text
WHERE synopsis_locales = '{}' AND synopsis IS NOT NULL AND synopsis <> '';
+51
View File
@@ -0,0 +1,51 @@
-- Author: fruit.com.cn
-- 名称语言改为勾选保存。中文、英文、印尼语仍写原列,勾选结果记在 locales 文本里。
ALTER TABLE content_tag ADD COLUMN IF NOT EXISTS name_locales TEXT NOT NULL DEFAULT '{}';
ALTER TABLE drama_classification ADD COLUMN IF NOT EXISTS name_locales TEXT NOT NULL DEFAULT '{}';
ALTER TABLE drama_playlist_category ADD COLUMN IF NOT EXISTS name_locales TEXT NOT NULL DEFAULT '{}';
ALTER TABLE drama_playlist_category ADD COLUMN IF NOT EXISTS description_locales TEXT NOT NULL DEFAULT '{}';
UPDATE content_tag
SET name_locales = (
SELECT COALESCE(json_object_agg(k, v), '{}'::json)::text
FROM (
SELECT 'zh' AS k, name AS v WHERE name <> ''
UNION ALL SELECT 'en', name_en WHERE name_en <> ''
UNION ALL SELECT 'id', name_id WHERE name_id <> ''
) s
)
WHERE name_locales = '{}';
UPDATE drama_classification
SET name_locales = (
SELECT COALESCE(json_object_agg(k, v), '{}'::json)::text
FROM (
SELECT 'zh' AS k, name AS v WHERE name <> ''
UNION ALL SELECT 'en', name_en WHERE name_en <> ''
UNION ALL SELECT 'id', name_id WHERE name_id <> ''
) s
)
WHERE name_locales = '{}';
UPDATE drama_playlist_category
SET name_locales = (
SELECT COALESCE(json_object_agg(k, v), '{}'::json)::text
FROM (
SELECT 'zh' AS k, name AS v WHERE name <> ''
UNION ALL SELECT 'en', name_en WHERE name_en <> ''
UNION ALL SELECT 'id', name_id WHERE name_id <> ''
) s
)
WHERE name_locales = '{}';
UPDATE drama_playlist_category
SET description_locales = (
SELECT COALESCE(json_object_agg(k, v), '{}'::json)::text
FROM (
SELECT 'zh' AS k, description AS v WHERE description <> ''
UNION ALL SELECT 'en', description_en WHERE description_en <> ''
UNION ALL SELECT 'id', description_id WHERE description_id <> ''
) s
)
WHERE description_locales = '{}';
+15
View File
@@ -0,0 +1,15 @@
-- Author: fruit.com.cn
-- 剧单分类名称和描述补英文、印尼语。原 name/description 继续表示中文。
ALTER TABLE drama_playlist_category
ADD COLUMN IF NOT EXISTS name_en VARCHAR(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS name_id VARCHAR(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS description_en VARCHAR(255) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS description_id VARCHAR(255) NOT NULL DEFAULT '';
COMMENT ON COLUMN drama_playlist_category.name IS '中文名称';
COMMENT ON COLUMN drama_playlist_category.name_en IS '英文名称';
COMMENT ON COLUMN drama_playlist_category.name_id IS '印尼语名称';
COMMENT ON COLUMN drama_playlist_category.description IS '中文描述';
COMMENT ON COLUMN drama_playlist_category.description_en IS '英文描述';
COMMENT ON COLUMN drama_playlist_category.description_id IS '印尼语描述';
+7
View File
@@ -0,0 +1,7 @@
-- Author: fruit.com.cn
-- 标签角标背景色。客户端按 bg_color 绘制封面右上角标签。
ALTER TABLE content_tag ADD COLUMN IF NOT EXISTS bg_color VARCHAR(7) NOT NULL DEFAULT '#E5484D';
UPDATE content_tag SET bg_color = '#E5484D' WHERE name = '热剧' AND (bg_color = '' OR bg_color = '#E5484D');
UPDATE content_tag SET bg_color = '#3B82F6' WHERE name = '新剧';