Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15b540cdd6 | ||
|
|
50b3dd4a8e | ||
|
|
1d34e47673 | ||
|
|
2367a5d12b | ||
|
|
ebc5f890fd | ||
|
|
e60c47123c | ||
|
|
5c20cf1ab9 | ||
|
|
45cf405285 | ||
|
|
4f80a691d1 | ||
|
|
47f19a98eb | ||
|
|
62792413df | ||
|
|
34c775620d | ||
|
|
b3774e0112 | ||
|
|
0e6f7531d4 | ||
|
|
73a8db8498 | ||
|
|
cf657707f9 | ||
|
|
40728e685b |
No files matched your search
@@ -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
@@ -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` 非空数(均排除逻辑删除) |
|
||||
|
||||
@@ -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`。
|
||||
+98
@@ -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();
|
||||
}
|
||||
}
|
||||
+72
@@ -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();
|
||||
}
|
||||
}
|
||||
+18
@@ -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());
|
||||
}
|
||||
}
|
||||
+13
@@ -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));
|
||||
}
|
||||
}
|
||||
+33
@@ -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());
|
||||
}
|
||||
}
|
||||
+105
-1
@@ -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 的流水口径不可相加");
|
||||
|
||||
+112
@@ -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);
|
||||
}
|
||||
}
|
||||
+84
@@ -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);
|
||||
}
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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("");
|
||||
|
||||
+3
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+53
@@ -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集"));
|
||||
}
|
||||
}
|
||||
+67
@@ -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;
|
||||
}
|
||||
}
|
||||
+61
@@ -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));
|
||||
}
|
||||
}
|
||||
+228
-31
@@ -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;
|
||||
}
|
||||
}
|
||||
+27
-5
@@ -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
-1
@@ -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
|
||||
|
||||
+2
@@ -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);
|
||||
|
||||
+2
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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",
|
||||
|
||||
+31
@@ -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;
|
||||
|
||||
+4
@@ -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;
|
||||
}
|
||||
+5
@@ -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;
|
||||
|
||||
+10
-1
@@ -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;
|
||||
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+40
@@ -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 > #{afterId}
|
||||
AND (e.video_asset_id > 0
|
||||
OR (e.video_url IS NOT NULL AND e.video_url <> ''))
|
||||
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);
|
||||
}
|
||||
+173
@@ -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 + " 个字符");
|
||||
}
|
||||
}
|
||||
+179
@@ -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;
|
||||
}
|
||||
}
|
||||
+61
-21
@@ -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());
|
||||
}
|
||||
}
|
||||
+55
-5
@@ -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());
|
||||
|
||||
+5
@@ -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);
|
||||
}
|
||||
|
||||
+67
-10
@@ -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())
|
||||
|
||||
+137
-7
@@ -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);
|
||||
}
|
||||
|
||||
+19
@@ -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;
|
||||
}
|
||||
+26
@@ -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;
|
||||
}
|
||||
+5
@@ -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;
|
||||
/** 该类型下的短剧数量 */
|
||||
|
||||
+9
-2
@@ -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;
|
||||
}
|
||||
+3
@@ -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;
|
||||
|
||||
+7
-2
@@ -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;
|
||||
}
|
||||
+6
@@ -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;
|
||||
/** 该分类下已关联的短剧数量 */
|
||||
|
||||
+10
-3
@@ -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;
|
||||
|
||||
+6
@@ -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")
|
||||
|
||||
+20
@@ -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");
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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 -> {
|
||||
|
||||
+9
@@ -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";
|
||||
|
||||
|
||||
+5
-1
@@ -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());
|
||||
|
||||
+4
-1
@@ -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;
|
||||
/** 当前续看集号。 */
|
||||
|
||||
+57
@@ -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;
|
||||
}
|
||||
+10
@@ -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> {
|
||||
}
|
||||
+178
@@ -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;
|
||||
}
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+53
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
+151
@@ -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
|
||||
|
||||
+143
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 '印尼语名称';
|
||||
@@ -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 <> '';
|
||||
@@ -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 = '{}';
|
||||
@@ -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 '印尼语描述';
|
||||
@@ -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 = '新剧';
|
||||
Reference in new issue
Block a user