chore(merge): 合并内容多语言与转码通知

This commit is contained in:
Zhou Weidong committed 2026-10-24 16:57:31 +08:00
commit 15b540cdd6
58 files changed
+1775 -119

No files matched your search

+53 -2
View File
@@ -168,10 +168,30 @@ app.register.max-per-device-per-day
| GET | `/app/home/feed` | 首页:轮播 + 分区,卡片带 `preview` 可播预览集与 `language` 语种 |
| GET | `/app/discover/feed` | 发现页分区 |
| GET | `/app/feed?types=ANIME,LIVE&categoryId=7&cursor=&size=20` | 推荐流;支持内容类型与分类叠加过滤,游标翻页 |
| GET | `/app/categories` | 分类列表 |
| GET | `/app/categories` | 已启用首页分类。`name` 为中文,`locales` 为 `{zh,en,id}` |
| GET | `/app/categories/{id}/dramas?pageIndex=&pageSize=` | 分类下已上线短剧,分页。`pageIndex` 默认 1,`pageSize` 默认 20、最大 50 |
| GET | `/app/drama-categories` | 已启用类型。`locales` 为 `{zh,en,id}` |
| GET | `/app/tags` | 已启用标签。`locales` 为 `{zh,en,id}` |
| POST | `/app/dramas/page` | 全部短剧分页。请求体 `pageIndex`(默认 1)、`pageSize`(默认 20、最大 50)、`categoryIds`、`classificationIds`、`tagIds`。id 用 JSON 数组,不放查询串。同一组多选为或,组与组之间为且 |
| GET | `/app/playlists?pageIndex=&pageSize=` | 已启用剧单列表,分页。`pageIndex` 默认 1,`pageSize` 默认 10、最大 20 |
| GET | `/app/playlists/{id}?pageIndex=&pageSize=` | 剧单详情。短剧按页返回。`pageIndex` 默认 1,`pageSize` 默认 20、最大 50。下架或不存在返回 `200002` |
| GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) |
| GET | `/app/search?keyword=&pageIndex=&pageSize=` | **匿名**。搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 |
`POST /app/dramas/page` 请求体:
```json
{
"pageIndex": 1,
"pageSize": 20,
"categoryIds": ["2112443900437336065"],
"classificationIds": ["2112807182243336193"],
"tagIds": ["2113500000000000002"]
}
```
省略的 id 数组表示这一组不筛选。`pageIndex` 小于 1 按 1,`pageSize` 小于 1 按 1,大于 50 按 50。响应仍是 `{total, list}`,卡片结构与首页一致。
> 卡片(`AppDramaCardVO`)统一新增 `language` 字段(取值如 `zh-CN` / `id`),
> 首页、发现页、分类、收藏、搜索等所有下发卡片的场景都带该字段,客户端可据此做语言筛选。
>
@@ -180,9 +200,21 @@ app.register.max-per-device-per-day
> `drama_episode.like_count` 与 `content_comment.episode_id` 统计(排除逻辑删除与 `status = 'deleted'`);
> `viewCount` 热度仍取整部剧。首页、发现、分类、收藏、搜索等列表卡片结构一致。
>
> 卡片文案只放语言表:`titleLocales`、`synopsisLocales`、`types`、`tagLocales`。
> `tagColor` 是标签背景色。中文取各表的 `zh`。
>
> 卡片新增 `dramaType`:`ANIME` 表示漫剧,`LIVE` 表示真人剧。
> 客户端字段仍返回主内容类型;后台多选值保存在 `drama.drama_types`,
> App 类型筛选按逗号列整词匹配,因此 `ANIME,LIVE` 多标签剧可被任一类型命中。
>
> `GET /app/playlists` 与 `GET /app/playlists/{id}` 匿名可访问。列表返回 `{total, list}`。
> 列表项与详情的名称在 `locales`,描述在 `descriptionLocales`,key 为 `zh` / `en` / `id`。
> `name` 与 `description` 等于中文。客户端按当前语言取对应 key,没有则用 `zh`。
> 其余字段:`id`、`dramaCount`(已上线短剧条数),
> 列表里每条剧单的 `list` 只是短剧第 1 页、每页 10 条。短剧翻页走详情:
> `pageIndex`、`pageSize` 会原样回在详情里,`dramaCount` 是短剧总数,`list` 是当前页卡片。
> 只返回 `status = 1` 的剧单,排序与后台剧单排序一致,卡片顺序与后台关联顺序一致。
> 剧单列表越界页返回空 `list`,`total` 仍是全部剧单数。详情越界页同样返回空 `list`。
#### 推荐类型流(`GET /app/feed`)
@@ -356,6 +388,11 @@ app.register.max-per-device-per-day
"dramaId": "12",
"episodeId": "88",
"dramaTitle": "替嫁新娘:总裁的掌心宠",
"titleLocales": {
"zh": "替嫁新娘:总裁的掌心宠",
"en": "The Stand-in Bride: The CEO's Beloved",
"id": "Pengantin Pengganti: Kesayangan Presiden Direktur"
},
"dramaCover": "/files/drama_cover/12.jpg",
"episodeNumber": 6,
"totalEpisodes": 68,
@@ -377,7 +414,8 @@ app.register.max-per-device-per-day
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `dramaTitle` | string / null | 短剧标题 |
| `dramaTitle` | string / null | 短剧中文标题,旧客户端回退用 |
| `titleLocales` | object / null | 短剧标题语言表,key 为 `zh` / `en` / `id`。客户端按当前语言取,没有对应语言时回退 `zh` |
| `dramaCover` | string / null | 短剧封面 |
| `episodeNumber` | int / null | 当前续看集号 `ep_no` |
| `totalEpisodes` | int / null | 短剧总集数 |
@@ -656,6 +694,16 @@ GET /app/notices?pageIndex=1&pageSize=20
- 列表响应返回 `dramaTypes[]`、`categoryIds[]` 与按选中顺序映射的 `categoryNames[]`;
分类名缺失时回退 `genre`。
- 后台与 App 过滤均使用 `(',' || column || ',') LIKE '%,value,%'` 整词匹配。
- 短剧另有单选标签 `tagId`。不传保持原值,传 `0` 清空。列表返回 `tagId`、`tagName`、`tagNameEn`、`tagNameId`。后台编辑不再提交内容类型。
**标签**
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/content-tags?enabledOnly=` | `drama:list` | 标签列表。`enabledOnly=true` 只返回启用项 |
| POST | `/admin/content-tags` | `drama:create` | 新建。`locales` 勾选语言,中文必填,英文和印尼语可选 |
| PUT | `/admin/content-tags/{id}` | `drama:update` | 编辑 |
| DELETE | `/admin/content-tags/{id}` | `drama:delete` | 删除,并清空已选该标签的短剧 |
**剧集**
@@ -827,6 +875,7 @@ GET /app/notices?pageIndex=1&pageSize=20
| --- | --- | --- | --- |
| GET | `/admin/dashboard/overview` | `dashboard:view` | 总览:用户 / 内容 / 播放 / 收入 / 成本 |
| GET | `/admin/dashboard/transcode` | `dashboard:view` | 转码队列积压与失败任务明细(含 `errorMsg` / `retryCount`) |
| GET | `/admin/notifications/transcode` | `drama:list` | 转码通知:`running` / `success` / `failed` 角标,以及各状态最近 20 条(含 `dramaTitle`、`episodeNo`、`episodeTitle`) |
**口径(与代码注释一致,前端 tooltip 可直接复用)**
@@ -834,6 +883,8 @@ GET /app/notices?pageIndex=1&pageSize=20
| --- | --- | --- |
| 用户 | `total` / `newToday` / `new7d` | `app_user` 未删除总数;按 `created_at` 落在当天 / 近 7 天 |
| 用户 | `active7d` | `last_login_at` 在近 7 天内。**不是 DAU**,日活需要逐日登录快照,当前无产出 |
| 用户 | `androidActive7d` / `iosActive7d` | `app_user_device` 中 `status=active` 且 `last_seen_at` 在近 7 天的去重设备,平台分别为 `ANDROID` / `IOS` |
| 用户 | `retention1d` / `retention7d` | 次日留存:昨日注册且今天仍登录的比例。7 日留存:8 至 14 天前注册、近 7 天仍登录的比例。队列为空返回 `null`,取值 0 到 1 |
| 用户 | `banned` / `limited` / `deactivated` | 按 `status` 计数 |
| 内容 | `drama*` | 短剧按 `status` 计数(`reviewing` 为待审、`published` 为已上线) |
| 内容 | `episodeTotal` / `Published` / `WithMedia` | 剧集总数 / 已上线数 / `video_url` 非空数(均排除逻辑删除) |
+19
View File
@@ -0,0 +1,19 @@
# 标签管理(替换短剧内容类型)
## 决定
不做「语言开关」总控。分类和剧单已经用 `name` / `nameEn` / `nameId` 三列。标签沿用同一套,避免两套国际化。语言总控要改短剧标题、简介、剧集标题,单独开一轮。
## 本轮
- 短剧编辑去掉「内容类型」。库里的 `drama_type` 先留着,推荐流的 `ANIME` / `LIVE` 筛选不受影响,后台不再改它。
- 内容管理下新增「标签管理」。
- 标签字段:中文名、英文名、印尼语名、排序、启用状态。三个名称都必填。
- 短剧 `tag_id` 单选,可空。编辑页用下拉,只列出启用中的标签。
- 已有短剧按原内容类型回填:`ANIME` → 漫剧,`LIVE` → 真人剧。
## 接口
- `GET/POST /admin/content-tags`,`PUT/DELETE /admin/content-tags/{id}`,权限沿用 `drama:list` / `create` / `update` / `delete`。
- 短剧保存增加 `tagId`。不传则保持原值;传 `0` 表示清空。
- 短剧列表返回 `tagId`、`tagName`、`tagNameEn`、`tagNameId`。
@@ -0,0 +1,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();
}
}
@@ -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());
}
}
@@ -58,7 +58,11 @@ import org.springframework.jdbc.core.JdbcTemplate;
* <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` = 短剧表上累计计数列的求和(累计口径,不限定时间范围);
@@ -76,6 +80,9 @@ import org.springframework.jdbc.core.JdbcTemplate;
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;
@@ -187,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));
@@ -284,6 +329,8 @@ public class DashboardService {
List<String> notes = new ArrayList<>();
notes.add("DAU 暂无数据源:需要按天留存登录快照(ops_daily_stat 已建表但当前无产出),"
+ "现以 active7d(近 7 天登录过)近似");
notes.add("androidActive7d / iosActive7d 是近 7 天 last_seen_at 仍活跃的去重设备,不是安装量");
notes.add("retention1d 是昨日注册且今天仍登录的比例;retention7d 是 8 至 14 天前注册、近 7 天仍登录的比例;队列为空时为 null");
notes.add("CDN 流量与带宽成本暂无数据源:需接入 CDN 账单或访问日志,cdnTrafficBytes 恒为 null");
notes.add("收入金额在商店未接入前恒为 0:订单表无真实商店订单,金币解锁收入请参考 coinConsumed");
notes.add("viewTotal/playTotal 是短剧表上的累计计数,与 playEvents7d 的流水口径不可相加");
@@ -0,0 +1,84 @@
// Author: fruit.com.cn
// 转码通知:按状态计数,并带出短剧与集数。
package com.fruitshorts.admin.service;
import com.fruitshorts.admin.vo.TranscodeNoticeRespVO;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 转码通知。
* 转码中、成功、失败分别计数,列表带出短剧名和集数。
*/
@Service
public class TranscodeNoticeService {
private static final int NOTICE_LIMIT = 20;
private static final String STATUS_RUNNING = "RUNNING";
private static final String STATUS_SUCCESS = "SUCCESS";
private static final String STATUS_FAILED = "FAILED";
private static final String UNKNOWN_DRAMA = "未关联短剧";
private final JdbcTemplate jdbcTemplate;
public TranscodeNoticeService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
/** 角标数量和最近 20 条转码中、成功、失败任务。 */
public TranscodeNoticeRespVO notices() {
TranscodeNoticeRespVO data = new TranscodeNoticeRespVO();
fillCounts(data);
data.setRunningList(latest(STATUS_RUNNING));
data.setSuccessList(latest(STATUS_SUCCESS));
data.setFailedList(latest(STATUS_FAILED));
return data;
}
private void fillCounts(TranscodeNoticeRespVO data) {
jdbcTemplate.query("""
SELECT status, COUNT(*)
FROM transcode_task
WHERE deleted = 0 AND status IN (?, ?, ?)
GROUP BY status
""", rs -> {
long count = rs.getLong(2);
switch (rs.getString(1)) {
case STATUS_RUNNING -> data.setRunning(count);
case STATUS_SUCCESS -> data.setSuccess(count);
case STATUS_FAILED -> data.setFailed(count);
default -> {
}
}
}, STATUS_RUNNING, STATUS_SUCCESS, STATUS_FAILED);
}
private List<TranscodeNoticeRespVO.Item> latest(String status) {
return jdbcTemplate.query("""
SELECT t.id, t.status, t.progress, t.error_msg, t.updated_at,
COALESCE(NULLIF(d.title, ''), NULLIF(d2.title, ''), ?) AS drama_title,
e.ep_no, e.title
FROM transcode_task t
LEFT JOIN drama_episode e ON e.id = t.episode_id AND e.deleted = 0
LEFT JOIN drama d ON d.id = t.drama_id AND d.deleted = 0
LEFT JOIN drama d2 ON d2.id = e.drama_id AND d2.deleted = 0
WHERE t.deleted = 0 AND t.status = ?
ORDER BY t.updated_at DESC
LIMIT ?
""", (rs, row) -> {
TranscodeNoticeRespVO.Item item = new TranscodeNoticeRespVO.Item();
item.setId(rs.getLong("id"));
item.setStatus(rs.getString("status"));
item.setProgress(rs.getInt("progress"));
item.setErrorMsg(rs.getString("error_msg"));
item.setUpdatedAt(rs.getTimestamp("updated_at").toLocalDateTime());
item.setDramaTitle(rs.getString("drama_title"));
item.setEpisodeNo((Integer) rs.getObject("ep_no"));
item.setEpisodeTitle(rs.getString("title"));
return item;
}, UNKNOWN_DRAMA, status, NOTICE_LIMIT);
}
}
@@ -0,0 +1,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;
}
}
@@ -0,0 +1,53 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.TranscodeNoticeService;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.admin.vo.TranscodeNoticeRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class TranscodeNoticeContractTest {
private TranscodeNoticeService transcodeNoticeService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
transcodeNoticeService = mock(TranscodeNoticeService.class);
mockMvc = ControllerContractSupport.mockMvcOf(new TranscodeNoticeController(transcodeNoticeService));
}
@Test
void should_return_drama_and_episode_when_transcode_notice_is_requested() throws Exception {
TranscodeNoticeRespVO data = new TranscodeNoticeRespVO();
data.setRunning(1L);
data.setSuccess(2L);
data.setFailed(3L);
TranscodeNoticeRespVO.Item item = new TranscodeNoticeRespVO.Item();
item.setDramaTitle("重生");
item.setEpisodeNo(4);
item.setEpisodeTitle("第4集");
data.setFailedList(List.of(item));
given(transcodeNoticeService.notices()).willReturn(data);
mockMvc.perform(get("/admin/notifications/transcode"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.running").value(1))
.andExpect(jsonPath("$.data.success").value(2))
.andExpect(jsonPath("$.data.failed").value(3))
.andExpect(jsonPath("$.data.failedList[0].dramaTitle").value("重生"))
.andExpect(jsonPath("$.data.failedList[0].episodeNo").value(4))
.andExpect(jsonPath("$.data.failedList[0].episodeTitle").value("第4集"));
}
}
@@ -0,0 +1,67 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.service;
import com.fruitshorts.admin.vo.TranscodeNoticeRespVO;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowCallbackHandler;
import org.springframework.jdbc.core.RowMapper;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class TranscodeNoticeServiceTest {
@Test
void should_attach_drama_and_episode_when_failed_tasks_are_loaded() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
doAnswer(invocation -> {
assertTrue(invocation.getArgument(0, String.class).contains("?"));
return null;
}).when(jdbc).query(anyString(), any(RowCallbackHandler.class), eq("RUNNING"), eq("SUCCESS"), eq("FAILED"));
when(jdbc.query(anyString(), any(RowMapper.class), any(), anyString(), any()))
.thenAnswer(invocation -> {
String sql = invocation.getArgument(0, String.class);
String status = invocation.getArgument(3, String.class);
if (!"FAILED".equals(status)) {
return java.util.List.of();
}
assertTrue(sql.contains("drama_title"));
assertTrue(sql.contains("?"));
RowMapper<?> mapper = invocation.getArgument(1);
ResultSet rs = failedRow();
return java.util.List.of(mapper.mapRow(rs, 0));
});
TranscodeNoticeRespVO data = new TranscodeNoticeService(jdbc).notices();
assertEquals(1, data.getFailedList().size());
assertEquals("重生", data.getFailedList().get(0).getDramaTitle());
assertEquals(4, data.getFailedList().get(0).getEpisodeNo());
assertEquals("第4集", data.getFailedList().get(0).getEpisodeTitle());
}
private ResultSet failedRow() throws Exception {
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(9L);
when(rs.getString("status")).thenReturn("FAILED");
when(rs.getInt("progress")).thenReturn(0);
when(rs.getString("error_msg")).thenReturn("boom");
when(rs.getTimestamp("updated_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2026, 10, 24, 12, 0)));
when(rs.getString("drama_title")).thenReturn("重生");
when(rs.getObject("ep_no")).thenReturn(4);
when(rs.getString("title")).thenReturn("第4集");
return rs;
}
}
@@ -4,6 +4,7 @@ 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;
@@ -16,6 +17,8 @@ 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;
@@ -30,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 = "首页聚合:轮播 + 分区,一次请求渲染整页")
@@ -69,9 +75,46 @@ public class AppCatalogController {
return Result.ok(appCatalogService.categoryDramas(id, pageIndex, pageSize));
}
@Operation(summary = "剧单详情:剧单基本信息 + 该剧单下已上线短剧卡片")
@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) {
return Result.ok(appCatalogService.playlistDetail(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));
}
}
@@ -13,10 +13,13 @@ 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;
@@ -31,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;
@@ -48,11 +52,17 @@ 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;
@@ -60,28 +70,65 @@ public class AppCatalogService {
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 dramaClassificationService.listEnabled().stream()
.map(c -> new AppCategoryVO(c.getId(), c.getName()))
.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) {
@@ -108,7 +155,7 @@ public class AppCatalogService {
}
int toIndex = Math.min(fromIndex + safeSize, ordered.size());
List<Drama> pageRows = ordered.subList(fromIndex, toIndex);
return new PageResult<>(total, cards(pageRows, categoryNameMap()));
return new PageResult<>(total, cards(pageRows, categoryLocaleMap()));
}
public CursorResult<AppDramaCardVO> feed(
@@ -128,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);
}
/**
@@ -139,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) {
@@ -149,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());
@@ -172,8 +222,27 @@ public class AppCatalogService {
return detail;
}
/** C 端剧单详情:剧单基本信息 + 该剧单下所有已上线短剧卡片。 */
public AppPlaylistDetailVO playlistDetail(Long playlistId) {
/** 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);
@@ -190,12 +259,27 @@ public class AppCatalogService {
})
.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.setName(category.getName());
vo.setDescription(category.getDescription());
vo.setLocales(locales);
vo.setDescriptionLocales(descriptions);
vo.setDramaCount(ordered.size());
vo.setList(cards(ordered, categoryNameMap()));
vo.setPageIndex(safeIndex);
vo.setPageSize(safeSize);
vo.setList(cards(pageRows, categoryLocaleMap()));
return vo;
}
@@ -203,7 +287,7 @@ public class AppCatalogService {
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) {
@@ -213,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)));
}
}
@@ -237,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());
@@ -307,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;
}
@@ -5,15 +5,20 @@ 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 String name;
private String description;
private Map<String, String> locales;
private Map<String, String> descriptionLocales;
/** 该剧单下已上线短剧的实际条数(被下架/下线的不计入) */
private Integer dramaCount;
/** 当前短剧页码,从 1 开始 */
private Integer pageIndex;
/** 当前短剧页大小 */
private Integer pageSize;
private List<AppDramaCardVO> list;
}
@@ -2,18 +2,28 @@
package com.fruitshorts.app.vo;
import lombok.AllArgsConstructor;
import com.fruitshorts.content.service.ContentLocaleTexts;
import lombok.Data;
import java.util.List;
import java.util.Map;
/** 首页/发现页的一个横滑分区 */
@Data
@AllArgsConstructor
public class AppSectionVO {
/** 分区标识:hot / new / category-{id},客户端可据此做埋点与样式 */
private String code;
private String title;
private Map<String, String> titleLocales;
private List<AppDramaCardVO> dramas;
public AppSectionVO(String code, String title, List<AppDramaCardVO> dramas) {
this(code, Map.of(ContentLocaleTexts.ZH, title == null ? "" : title), dramas);
}
public AppSectionVO(String code, Map<String, String> titleLocales, List<AppDramaCardVO> dramas) {
this.code = code;
this.titleLocales = titleLocales;
this.dramas = dramas;
}
}
@@ -17,15 +17,18 @@ import com.fruitshorts.playback.service.PlaybackService;
import com.fruitshorts.interaction.service.CommentService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.hamcrest.Matchers.nullValue;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -58,7 +61,7 @@ class AppCatalogControllerContractTest {
banner.setLinkValue("12");
AppDramaCardVO card = new AppDramaCardVO();
card.setId(12L);
card.setTitle("命运的相遇");
card.setTitleLocales(Map.of("zh", "命运的相遇"));
card.setViewCount(12400L);
card.setLikeCount(3200L);
card.setFavoriteCount(1800L);
@@ -94,7 +97,7 @@ class AppCatalogControllerContractTest {
void should_return_typed_cursor_feed() throws Exception {
AppDramaCardVO card = new AppDramaCardVO();
card.setId(12L);
card.setTitle("漫剧卡片");
card.setTitleLocales(Map.of("zh", "漫剧卡片"));
card.setDramaType("ANIME");
given(catalogService.feed("ANIME", 7L, null, 20))
.willReturn(new CursorResult<>(List.of(card), 12L, true));
@@ -122,14 +125,14 @@ class AppCatalogControllerContractTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data[0].id").value(3))
.andExpect(jsonPath("$.data[0].name").value("都市"));
.andExpect(jsonPath("$.data[0].locales.zh").value("都市"));
}
@Test
void should_bind_path_variable_when_drama_detail_is_requested() throws Exception {
AppDramaDetailVO detail = new AppDramaDetailVO();
detail.setId(42L);
detail.setTitle("命运的相遇");
detail.setTitleLocales(Map.of("zh", "命运的相遇"));
detail.setViewCount(12400L);
detail.setLikeCount(3200L);
detail.setFavoriteCount(1800L);
@@ -142,7 +145,7 @@ class AppCatalogControllerContractTest {
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").value("42"))
.andExpect(jsonPath("$.data.id").isString())
.andExpect(jsonPath("$.data.title").value("命运的相遇"))
.andExpect(jsonPath("$.data.titleLocales.zh").value("命运的相遇"))
.andExpect(jsonPath("$.data.viewCount").value("12400"))
.andExpect(jsonPath("$.data.likeCount").value("3200"))
.andExpect(jsonPath("$.data.favoriteCount").value("1800"))
@@ -161,4 +164,23 @@ class AppCatalogControllerContractTest {
.andExpect(jsonPath("$.code").value(ErrorCode.DATA_NOT_FOUND.getCode()))
.andExpect(jsonPath("$.data").value(nullValue()));
}
@Test
void should_bind_filter_ids_from_body_when_drama_page_is_posted() throws Exception {
AppDramaCardVO card = new AppDramaCardVO();
card.setId(9L);
card.setTitleLocales(Map.of("zh", "重生"));
given(catalogService.libraryDramas(List.of(2L), List.of(3L), List.of(4L), 1, 20))
.willReturn(new com.fruitshorts.common.pojo.PageResult<>(1L, List.of(card)));
catalogMvc.perform(post("/app/dramas/page")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"pageIndex\":1,\"pageSize\":20,"
+ "\"categoryIds\":[\"2\"],\"classificationIds\":[\"3\"],\"tagIds\":[\"4\"]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value("1"))
.andExpect(jsonPath("$.data.list[0].titleLocales.zh").value("重生"));
verify(catalogService).libraryDramas(List.of(2L), List.of(3L), List.of(4L), 1, 20);
}
}
@@ -7,6 +7,7 @@ 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;
@@ -54,7 +55,7 @@ class AppCatalogServiceTest {
given(thumbnailUrlResolver.resolveAll(any())).willReturn(Map.of());
commentService = mock(CommentService.class);
catalogService = new AppCatalogService(dramaService, episodeService, bannerService,
dramaCategoryService, dramaClassificationService, dramaPlaylistCategoryService,
dramaCategoryService, mock(ContentTagService.class), dramaClassificationService, dramaPlaylistCategoryService,
playbackSigner, commentService, thumbnailUrlResolver);
}
@@ -43,6 +43,8 @@ class DramaServiceFavoriteCountTest {
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
mock(DramaCategoryService.class),
mock(com.fruitshorts.content.dal.mapper.ContentTagMapper.class),
mock(com.fruitshorts.content.service.ContentTagService.class),
mock(EpisodePriceSync.class));
given(dramaMapper.update(isNull(), any())).willReturn(1);
ArgumentCaptor<Wrapper<Drama>> captor = ArgumentCaptor.forClass(Wrapper.class);
@@ -47,6 +47,8 @@ class DramaServiceFeedTest {
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
mock(DramaCategoryService.class),
mock(com.fruitshorts.content.dal.mapper.ContentTagMapper.class),
mock(com.fruitshorts.content.service.ContentTagService.class),
mock(EpisodePriceSync.class));
}
@@ -48,6 +48,8 @@ class DramaServiceK88Test {
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
mock(DramaCategoryService.class),
mock(com.fruitshorts.content.dal.mapper.ContentTagMapper.class),
mock(com.fruitshorts.content.service.ContentTagService.class),
mock(EpisodePriceSync.class));
}
@@ -56,6 +56,8 @@ class DramaServiceMultiTagTest {
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
categoryService,
mock(com.fruitshorts.content.dal.mapper.ContentTagMapper.class),
mock(com.fruitshorts.content.service.ContentTagService.class),
mock(EpisodePriceSync.class));
}
@@ -38,6 +38,8 @@ public class SecurityConfig {
"/app/discover/**",
"/app/feed",
"/app/categories/**",
"/app/drama-categories",
"/app/tags",
"/app/playlists/**",
"/app/banners/**",
"/app/dramas/**",
@@ -0,0 +1,31 @@
// Author: fruit.com.cn
package com.fruitshorts.content.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 短剧标签。名称分中文、英文、印尼语。 */
@Data
@TableName("content_tag")
public class ContentTag {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 中文名称。 */
private String name;
private String nameEn;
private String nameId;
/** 已选语言的名称。 */
private String nameLocales;
/** 角标背景色,#RRGGBB。 */
private String bgColor;
private Integer sort;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -26,6 +26,10 @@ public class Drama {
private String categoryIds;
private String genre;
private String synopsis;
/** 标题语言表,JSON。 */
private String titleLocales;
/** 简介语言表,JSON。 */
private String synopsisLocales;
private String coverUrl;
private String horizontalCoverUrl;
private String language;
@@ -33,6 +37,8 @@ public class Drama {
private String dramaType;
/** 内容类型多选,逗号分隔(ANIME/LIVE),drama_type 保留第一个 */
private String dramaTypes;
/** 单选标签,对应 content_tag.id。 */
private Long tagId;
private Integer totalEpisodes;
private Integer freeEpisodes;
private Integer unlockCoin;
@@ -14,6 +14,10 @@ public class DramaCategory {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String name;
private String nameEn;
private String nameId;
/** 已选语言的名称。 */
private String nameLocales;
private Integer sort;
private Integer status;
}
@@ -16,7 +16,12 @@ public class DramaClassification {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 中文名称。 */
private String name;
private String nameEn;
private String nameId;
/** 已选语言的名称。 */
private String nameLocales;
private Integer sort;
private Integer status;
private LocalDateTime createdAt;
@@ -16,9 +16,18 @@ public class DramaPlaylistCategory {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 中文名称,列表和唯一性都用这一列。 */
private String name;
/** 分类描述,便于运营说明该分类的用途 */
private String nameEn;
private String nameId;
/** 中文描述。 */
private String description;
private String descriptionEn;
private String descriptionId;
/** 已选语言的名称。 */
private String nameLocales;
/** 已选语言的描述。 */
private String descriptionLocales;
private Integer sort;
private Integer status;
private LocalDateTime createdAt;
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
package com.fruitshorts.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.content.dal.entity.ContentTag;
public interface ContentTagMapper extends BaseMapper<ContentTag> {
}
@@ -0,0 +1,173 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** 内容名称的可选语言。中文必填,英文和印尼语按勾选保存。 */
public final class ContentLocaleTexts {
public static final String ZH = "zh";
public static final String EN = "en";
public static final String ID = "id";
private static final int NAME_LIMIT = 64;
private static final int TITLE_LIMIT = 80;
private static final int DESCRIPTION_LIMIT = 255;
private static final int SYNOPSIS_LIMIT = 1000;
private static final List<String> CODES = List.of(ZH, EN, ID);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final TypeReference<Map<String, String>> MAP_TYPE = new TypeReference<>() {
};
private ContentLocaleTexts() {
}
public static Map<String, String> names(Map<String, String> locales, String name,
String nameEn, String nameId) {
Map<String, String> source = locales == null ? legacy(name, nameEn, nameId) : locales;
return normalize(source, NAME_LIMIT, "名称");
}
public static Map<String, String> titles(Map<String, String> locales, String title) {
Map<String, String> source = locales == null ? legacy(title, null, null) : locales;
return normalize(source, TITLE_LIMIT, "名称");
}
public static Map<String, String> descriptions(Map<String, String> locales, Map<String, String> names,
String description, String descriptionEn,
String descriptionId) {
return optional(locales, names, description, descriptionEn, descriptionId, DESCRIPTION_LIMIT, "描述");
}
public static Map<String, String> synopses(Map<String, String> locales, Map<String, String> titles,
String synopsis) {
return optional(locales, titles, synopsis, null, null, SYNOPSIS_LIMIT, "简介");
}
private static Map<String, String> optional(Map<String, String> locales, Map<String, String> required,
String zh, String en, String id, int limit, String field) {
Map<String, String> source = locales == null ? legacy(zh, en, id) : locales;
Map<String, String> result = new LinkedHashMap<>();
for (String code : required.keySet()) {
String value = trim(source.get(code));
if (value.length() > limit) {
throw tooLong(code, field, limit);
}
if (!value.isEmpty()) {
result.put(code, value);
}
}
return result;
}
public static Map<String, String> read(String json, String zh, String en, String id) {
Map<String, String> parsed = parse(json);
Map<String, String> result = new LinkedHashMap<>();
put(result, ZH, first(parsed.get(ZH), zh));
put(result, EN, first(parsed.get(EN), en));
put(result, ID, first(parsed.get(ID), id));
return result;
}
public static String json(Map<String, String> locales) {
try {
return MAPPER.writeValueAsString(locales == null ? Map.of() : locales);
} catch (Exception ex) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "语言内容无法保存");
}
}
public static String value(Map<String, String> locales, String code) {
String text = locales == null ? null : locales.get(code);
return text == null ? "" : text;
}
private static Map<String, String> normalize(Map<String, String> source, int limit, String field) {
Map<String, String> result = new LinkedHashMap<>();
for (String code : CODES) {
if (!source.containsKey(code)) {
continue;
}
String value = trim(source.get(code));
if (value.isEmpty()) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请填写" + label(code) + field);
}
if (value.length() > limit) {
throw tooLong(code, field, limit);
}
result.put(code, value);
}
rejectUnknown(source);
if (!result.containsKey(ZH)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请选择中文并填写中文" + field);
}
return result;
}
private static Map<String, String> legacy(String zh, String en, String id) {
Map<String, String> source = new LinkedHashMap<>();
source.put(ZH, zh);
if (!trim(en).isEmpty()) {
source.put(EN, en);
}
if (!trim(id).isEmpty()) {
source.put(ID, id);
}
return source;
}
private static void rejectUnknown(Map<String, String> source) {
for (String code : source.keySet()) {
if (!CODES.contains(code)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "不支持的语言");
}
}
}
private static Map<String, String> parse(String json) {
if (json == null || json.isBlank()) {
return Map.of();
}
try {
Map<String, String> parsed = MAPPER.readValue(json, MAP_TYPE);
return parsed == null ? Map.of() : parsed;
} catch (Exception ex) {
return Map.of();
}
}
private static void put(Map<String, String> target, String code, String value) {
String text = trim(value);
if (!text.isEmpty()) {
target.put(code, text);
}
}
private static String first(String preferred, String fallback) {
return trim(preferred).isEmpty() ? fallback : preferred;
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private static String label(String code) {
return switch (code) {
case EN -> "英文";
case ID -> "印尼语";
default -> "中文";
};
}
private static ServiceException tooLong(String code, String field, int limit) {
return new ServiceException(ErrorCode.BAD_REQUEST, label(code) + field + "最多 " + limit + " 个字符");
}
}
@@ -0,0 +1,179 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.ContentTag;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.mapper.ContentTagMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.vo.ContentTagRespVO;
import com.fruitshorts.content.vo.ContentTagSaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** 短剧标签。名称先选语言,中文必填,英文和印尼语按勾选填写。 */
@Service
@RequiredArgsConstructor
public class ContentTagService {
private final ContentTagMapper tagMapper;
private final DramaMapper dramaMapper;
public List<ContentTagRespVO> list() {
return tagMapper.selectList(Wrappers.<ContentTag>lambdaQuery()
.orderByAsc(ContentTag::getSort)
.orderByDesc(ContentTag::getId))
.stream()
.map(this::toResp)
.toList();
}
public List<ContentTagRespVO> listEnabled() {
return tagMapper.selectList(Wrappers.<ContentTag>lambdaQuery()
.eq(ContentTag::getStatus, 1)
.orderByAsc(ContentTag::getSort)
.orderByDesc(ContentTag::getId))
.stream()
.map(this::toResp)
.toList();
}
public Long create(ContentTagSaveReqVO req) {
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), null);
ContentTag entity = new ContentTag();
writeNames(entity, locales);
entity.setBgColor(normalizeColor(req.getBgColor()));
Long count = tagMapper.selectCount(null);
entity.setSort(req.getSort() == null
? (count == null ? 0 : count.intValue()) + 1
: req.getSort());
entity.setStatus(req.getStatus() == null ? 1 : req.getStatus());
tagMapper.insert(entity);
return entity.getId();
}
public void update(Long id, ContentTagSaveReqVO req) {
ContentTag entity = getExisting(id);
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), id);
writeNames(entity, locales);
if (req.getBgColor() != null) {
entity.setBgColor(normalizeColor(req.getBgColor()));
}
if (req.getSort() != null) {
entity.setSort(req.getSort());
}
if (req.getStatus() != null) {
entity.setStatus(req.getStatus());
}
tagMapper.updateById(entity);
}
public void delete(Long id) {
getExisting(id);
dramaMapper.update(null, Wrappers.<Drama>lambdaUpdate()
.eq(Drama::getTagId, id)
.set(Drama::getTagId, null));
tagMapper.deleteById(id);
}
/** 短剧保存时校验标签存在且启用。空 id 表示不选。 */
public void requireEnabled(Long id) {
if (id == null || id <= 0L) {
return;
}
ContentTag tag = tagMapper.selectById(id);
if (tag == null || tag.getStatus() == null || tag.getStatus() != 1) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "标签不存在或已停用");
}
}
private Map<String, String> namesOf(ContentTagSaveReqVO req) {
return ContentLocaleTexts.names(req.getLocales(), req.getName(), req.getNameEn(), req.getNameId());
}
private void writeNames(ContentTag entity, Map<String, String> locales) {
entity.setName(locales.get(ContentLocaleTexts.ZH));
entity.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
entity.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
entity.setNameLocales(ContentLocaleTexts.json(locales));
}
private String normalizeColor(String color) {
if (color == null || color.isBlank()) {
return "#E5484D";
}
return color.trim().toUpperCase();
}
private ContentTag getExisting(Long id) {
ContentTag entity = id == null ? null : tagMapper.selectById(id);
if (entity == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "标签不存在");
}
return entity;
}
private void ensureNameAvailable(String name, Long excludeId) {
Long count = tagMapper.selectCount(Wrappers.<ContentTag>lambdaQuery()
.eq(ContentTag::getName, name.trim())
.ne(excludeId != null, ContentTag::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "标签名称已存在");
}
}
private ContentTagRespVO toResp(ContentTag tag) {
ContentTagRespVO vo = new ContentTagRespVO();
vo.setId(tag.getId());
Map<String, String> locales = ContentLocaleTexts.read(
tag.getNameLocales(), tag.getName(), tag.getNameEn(), tag.getNameId());
vo.setName(locales.get(ContentLocaleTexts.ZH));
vo.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
vo.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
vo.setLocales(locales);
vo.setBgColor(tag.getBgColor() == null || tag.getBgColor().isBlank() ? "#E5484D" : tag.getBgColor());
vo.setSort(tag.getSort());
vo.setStatus(tag.getStatus());
return vo;
}
/** 按 id 批量取出标签,供短剧列表带名称和背景色。 */
public Map<Long, ContentTag> findByIds(Collection<Long> ids) {
if (ids == null || ids.isEmpty()) {
return Map.of();
}
Map<Long, ContentTag> result = new HashMap<>();
for (ContentTag tag : tagMapper.selectBatchIds(ids)) {
if (tag != null && tag.getId() != null) {
result.put(tag.getId(), tag);
}
}
return result;
}
/** 按 id 批量取出标签语言表,供短剧列表拼卡片。 */
public Map<Long, Map<String, String>> localeMap(Collection<Long> ids) {
if (ids == null || ids.isEmpty()) {
return Map.of();
}
Map<Long, Map<String, String>> result = new HashMap<>();
for (ContentTag tag : tagMapper.selectBatchIds(ids)) {
if (tag == null || tag.getId() == null) {
continue;
}
result.put(tag.getId(), ContentLocaleTexts.read(
tag.getNameLocales(), tag.getName(), tag.getNameEn(), tag.getNameId()));
}
return result;
}
}
@@ -14,6 +14,7 @@ import com.fruitshorts.content.vo.DramaCategorySaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -30,21 +31,15 @@ public class DramaCategoryService {
Map<Long, Integer> counts = countDramasByCategory();
return dramaCategoryMapper.selectList(
Wrappers.<DramaCategory>lambdaQuery().orderByAsc(DramaCategory::getSort))
.stream().map(category -> {
DramaCategoryRespVO vo = new DramaCategoryRespVO();
vo.setId(category.getId());
vo.setName(category.getName());
vo.setSort(category.getSort());
vo.setStatus(category.getStatus());
vo.setDramaCount(counts.getOrDefault(category.getId(), 0));
return vo;
}).toList();
.stream().map(category -> toResp(category, counts.getOrDefault(category.getId(), 0)))
.toList();
}
public Long create(DramaCategorySaveReqVO req) {
ensureNameAvailable(req.getName(), null);
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), null);
DramaCategory category = new DramaCategory();
category.setName(req.getName().trim());
writeNames(category, locales);
Long count = dramaCategoryMapper.selectCount(null);
category.setSort(req.getSort() == null
? (count == null ? 0 : count.intValue()) + 1
@@ -61,20 +56,14 @@ public class DramaCategoryService {
public List<DramaCategoryRespVO> listBasic() {
return dramaCategoryMapper.selectList(
Wrappers.<DramaCategory>lambdaQuery().orderByAsc(DramaCategory::getSort))
.stream().map(category -> {
DramaCategoryRespVO vo = new DramaCategoryRespVO();
vo.setId(category.getId());
vo.setName(category.getName());
vo.setSort(category.getSort());
vo.setStatus(category.getStatus());
return vo;
}).toList();
.stream().map(category -> toResp(category, null)).toList();
}
public void update(Long id, DramaCategorySaveReqVO req) {
DramaCategory category = getExisting(id);
ensureNameAvailable(req.getName(), id);
category.setName(req.getName().trim());
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), id);
writeNames(category, locales);
if (req.getSort() != null) {
category.setSort(req.getSort());
}
@@ -126,4 +115,55 @@ public class DramaCategoryService {
return dramaCategoryMapper.selectList(null).stream()
.collect(Collectors.toMap(DramaCategory::getId, DramaCategory::getName, (a, b) -> a));
}
/** 类型 id 到已选语言名称。C 端卡片按当前语言取其中一项。 */
public Map<Long, Map<String, String>> localeMap() {
Map<Long, Map<String, String>> result = new HashMap<>();
for (DramaCategory category : dramaCategoryMapper.selectList(null)) {
result.put(category.getId(), readLocales(category));
}
return result;
}
/** 一部短剧选中的类型,按保存顺序返回语言表。 */
public List<Map<String, String>> localesForDrama(Drama drama, Map<Long, Map<String, String>> localesById) {
List<Map<String, String>> result = new ArrayList<>();
for (Long id : DramaMultiTagSupport.parseCategoryIds(drama)) {
Map<String, String> locales = localesById.get(id);
if (locales != null && !locales.isEmpty()) {
result.add(locales);
}
}
return result;
}
private DramaCategoryRespVO toResp(DramaCategory category, Integer dramaCount) {
Map<String, String> locales = readLocales(category);
DramaCategoryRespVO vo = new DramaCategoryRespVO();
vo.setId(category.getId());
vo.setName(ContentLocaleTexts.value(locales, ContentLocaleTexts.ZH));
vo.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
vo.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
vo.setLocales(locales);
vo.setSort(category.getSort());
vo.setStatus(category.getStatus());
vo.setDramaCount(dramaCount);
return vo;
}
private Map<String, String> namesOf(DramaCategorySaveReqVO req) {
return ContentLocaleTexts.names(req.getLocales(), req.getName(), req.getNameEn(), req.getNameId());
}
private void writeNames(DramaCategory category, Map<String, String> locales) {
category.setName(locales.get(ContentLocaleTexts.ZH));
category.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
category.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
category.setNameLocales(ContentLocaleTexts.json(locales));
}
private Map<String, String> readLocales(DramaCategory category) {
return ContentLocaleTexts.read(
category.getNameLocales(), category.getName(), category.getNameEn(), category.getNameId());
}
}
@@ -49,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());
@@ -59,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
@@ -73,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());
}
@@ -182,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())
@@ -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);
}
@@ -49,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));
@@ -59,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
@@ -74,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());
}
@@ -178,6 +187,14 @@ 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);
@@ -206,6 +223,25 @@ public class DramaPlaylistCategoryService {
return entity;
}
private Map<String, String> namesOf(DramaPlaylistCategorySaveReqVO req) {
return ContentLocaleTexts.names(req.getLocales(), req.getName(), req.getNameEn(), req.getNameId());
}
private void writeText(DramaPlaylistCategory entity, DramaPlaylistCategorySaveReqVO req,
Map<String, String> locales) {
Map<String, String> descriptions = ContentLocaleTexts.descriptions(
req.getDescriptionLocales(), locales,
req.getDescription(), req.getDescriptionEn(), req.getDescriptionId());
entity.setName(locales.get(ContentLocaleTexts.ZH));
entity.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
entity.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
entity.setNameLocales(ContentLocaleTexts.json(locales));
entity.setDescription(ContentLocaleTexts.value(descriptions, ContentLocaleTexts.ZH));
entity.setDescriptionEn(ContentLocaleTexts.value(descriptions, ContentLocaleTexts.EN));
entity.setDescriptionId(ContentLocaleTexts.value(descriptions, ContentLocaleTexts.ID));
entity.setDescriptionLocales(ContentLocaleTexts.json(descriptions));
}
private void ensureNameAvailable(String name, Long excludeId) {
Long count = categoryMapper.selectCount(Wrappers.<DramaPlaylistCategory>lambdaQuery()
.eq(DramaPlaylistCategory::getName, name.trim())
@@ -10,9 +10,11 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.ContentTag;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.entity.DramaReviewRecord;
import com.fruitshorts.content.dal.mapper.ContentTagMapper;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
@@ -28,20 +30,27 @@ import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class DramaService {
private static final DateTimeFormatter CODE_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
/** 全部短剧单页上限,与分类频道分页一致。 */
private static final int PUBLISHED_PAGE_SIZE_MAX = 50;
private final DramaMapper dramaMapper;
private final DramaEpisodeMapper dramaEpisodeMapper;
private final DramaReviewRecordMapper dramaReviewRecordMapper;
private final DramaCategoryService dramaCategoryService;
private final ContentTagMapper contentTagMapper;
private final ContentTagService contentTagService;
private final EpisodePriceSync episodePriceSync;
public PageResult<DramaRespVO> page(DramaPageReqVO req) {
@@ -122,6 +131,11 @@ public class DramaService {
}
dramaMapper.updateById(drama);
episodePriceSync.syncByDramaRule(drama);
if (req.getTagId() != null && req.getTagId() <= 0L) {
dramaMapper.update(null, Wrappers.<Drama>lambdaUpdate()
.eq(Drama::getId, drama.getId())
.set(Drama::getTagId, null));
}
}
@Transactional(rollbackFor = Exception.class)
@@ -226,10 +240,63 @@ 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, 50));
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());
@@ -369,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());
}
@@ -379,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) {
@@ -395,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());
}
@@ -420,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);
@@ -432,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);
@@ -480,6 +571,29 @@ public class DramaService {
}
}
private Map<Long, ContentTag> loadTags(List<Drama> dramas) {
List<Long> ids = dramas.stream()
.map(Drama::getTagId)
.filter(id -> id != null && id > 0L)
.distinct()
.toList();
if (ids.isEmpty()) {
return new HashMap<>();
}
return contentTagMapper.selectBatchIds(ids).stream()
.collect(Collectors.toMap(ContentTag::getId, Function.identity(), (left, right) -> left));
}
private void fillTag(DramaRespVO vo, ContentTag tag) {
if (tag == null) {
return;
}
vo.setTagId(tag.getId());
vo.setTagName(tag.getName());
vo.setTagNameEn(tag.getNameEn());
vo.setTagNameId(tag.getNameId());
}
private Long nonNegativeOrZero(Long value, String fieldName) {
return value == null ? 0L : nonNegative(value, fieldName);
}
@@ -0,0 +1,19 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import lombok.Data;
/** 标签列表项。 */
@Data
public class ContentTagRespVO {
private Long id;
private String name;
private String nameEn;
private String nameId;
private java.util.Map<String, String> locales;
private String bgColor;
private Integer sort;
private Integer status;
}
@@ -0,0 +1,26 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
/** 标签新增或编辑。中文必填,其余语言按 locales 勾选。 */
@Data
public class ContentTagSaveReqVO {
@Size(max = 64, message = "中文名称最多 64 个字符")
private String name;
private String nameEn;
private String nameId;
/** 勾选的语言名称,key 为 zh / en / id。 */
private java.util.Map<String, String> locales;
@Pattern(regexp = "^$|^#[0-9A-Fa-f]{6}$", message = "背景色格式应为 #RRGGBB")
private String bgColor;
private Integer sort;
private Integer status;
}
@@ -4,11 +4,16 @@ package com.fruitshorts.content.vo;
import lombok.Data;
import java.util.Map;
@Data
public class DramaCategoryRespVO {
private Long id;
private String name;
private String nameEn;
private String nameId;
private Map<String, String> locales;
private Integer sort;
private Integer status;
/** 该类型下的短剧数量 */
@@ -2,15 +2,22 @@
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.Map;
@Data
public class DramaCategorySaveReqVO {
@NotBlank(message = "请填写类型名称")
@Size(max = 64, message = "中文名称最多 64 个字符")
private String name;
private String nameEn;
private String nameId;
/** 勾选的语言名称,key 为 zh / en / id。 */
private Map<String, String> locales;
private Integer sort;
private Integer status;
}
@@ -12,6 +12,9 @@ public class DramaClassificationRespVO {
private Long id;
private String name;
private String nameEn;
private String nameId;
private java.util.Map<String, String> locales;
private Integer sort;
private Integer status;
private Integer dramaCount;
@@ -2,16 +2,21 @@
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/** 分类新增/编辑请求体。 */
@Data
public class DramaClassificationSaveReqVO {
@NotBlank(message = "请填写分类名称")
@Size(max = 64, message = "中文名称最多 64 个字符")
private String name;
private String nameEn;
private String nameId;
/** 勾选的语言名称,key 为 zh / en / id。 */
private java.util.Map<String, String> locales;
private Integer sort;
private Integer status;
}
@@ -10,7 +10,13 @@ public class DramaPlaylistCategoryRespVO {
private Long id;
private String name;
private String nameEn;
private String nameId;
private String description;
private String descriptionEn;
private String descriptionId;
private java.util.Map<String, String> locales;
private java.util.Map<String, String> descriptionLocales;
private Integer sort;
private Integer status;
/** 该分类下已关联的短剧数量 */
@@ -2,7 +2,6 @@
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@@ -10,11 +9,19 @@ import lombok.Data;
@Data
public class DramaPlaylistCategorySaveReqVO {
@NotBlank(message = "请填写剧单分类名称")
@Size(max = 64, message = "中文名称最多 64 个字符")
private String name;
@Size(max = 255, message = "描述最多 255 个字符")
private String nameEn;
private String nameId;
/** 勾选的语言名称,key 为 zh / en / id。 */
private java.util.Map<String, String> locales;
private String description;
private String descriptionEn;
private String descriptionId;
/** 勾选语言对应的描述。 */
private java.util.Map<String, String> descriptionLocales;
private Integer sort;
private Integer status;
@@ -17,6 +17,7 @@ public class DramaRespVO {
private Long id;
private String code;
private String title;
private java.util.Map<String, String> titleLocales;
private String subtitle;
private Long categoryId;
private List<Long> categoryIds;
@@ -24,12 +25,17 @@ public class DramaRespVO {
private String category;
private String genre;
private String description;
private java.util.Map<String, String> synopsisLocales;
private String cover;
/** 整剧预告片地址 */
private String trailerUrl;
private String language;
private String dramaType;
private List<String> dramaTypes;
private Long tagId;
private String tagName;
private String tagNameEn;
private String tagNameId;
private Integer episodes;
private Integer freeEpisodes;
private Integer unlockCoin;
@@ -14,6 +14,8 @@ public class DramaSaveReqVO {
@NotBlank(message = "请填写短剧名称")
private String title;
/** 勾选语言的标题,key 为 zh / en / id。 */
private java.util.Map<String, String> titleLocales;
private String subtitle;
private Long categoryId;
@@ -21,6 +23,8 @@ public class DramaSaveReqVO {
private List<Long> categoryIds;
private String genre;
private String description;
/** 勾选语言的简介。 */
private java.util.Map<String, String> synopsisLocales;
private String cover;
/** 整剧预告片地址(复用已有的 drama.trailer_url 列) */
private String trailerUrl;
@@ -29,6 +33,8 @@ public class DramaSaveReqVO {
private String dramaType;
/** 后台多选优先,创作者中心可继续只传 dramaType */
private List<String> dramaTypes;
/** 单选标签。不传则保持原值。 */
private Long tagId;
private Integer freeEpisodes;
private Integer unlockCoin;
@PositiveOrZero(message = "收藏数不能小于 0")
@@ -0,0 +1,20 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/** 分类筛选 SQL 只保留占位符,不把 id 写进语句。 */
class DramaServiceClassificationClauseTest {
@Test
void should_use_placeholders_when_classification_ids_are_bound() {
String sql = DramaService.classificationInClause(2);
assertThat(sql).isEqualTo(
"id IN (SELECT drama_id FROM drama_classification_drama WHERE classification_id IN ({0},{1}))");
assertThat(sql).doesNotContain("211");
}
}
@@ -60,7 +60,10 @@ class DramaServiceTest {
DramaCategoryService categoryService = mock(DramaCategoryService.class);
EpisodePriceSync episodePriceSync = new EpisodePriceSync(dramaEpisodeMapper);
dramaService = new DramaService(
dramaMapper, dramaEpisodeMapper, reviewRecordMapper, categoryService, episodePriceSync);
dramaMapper, dramaEpisodeMapper, reviewRecordMapper, categoryService,
mock(com.fruitshorts.content.dal.mapper.ContentTagMapper.class),
mock(com.fruitshorts.content.service.ContentTagService.class),
episodePriceSync);
episodes = new ArrayList<>();
given(dramaEpisodeMapper.selectList(any())).willAnswer(invocation -> List.copyOf(episodes));
given(dramaEpisodeMapper.updateById(any(DramaEpisode.class))).willAnswer(invocation -> {
@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.ContentLocaleTexts;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.playback.dal.entity.WatchHistory;
@@ -218,7 +219,10 @@ public class WatchProgressService {
ProgressRespVO vo = toResp(progress);
Drama drama = dramaById.get(progress.getDramaId());
if (drama != null) {
vo.setDramaTitle(drama.getTitle());
Map<String, String> titles = ContentLocaleTexts.read(
drama.getTitleLocales(), drama.getTitle(), null, null);
vo.setTitleLocales(titles);
vo.setDramaTitle(ContentLocaleTexts.value(titles, ContentLocaleTexts.ZH));
vo.setDramaCover(drama.getCoverUrl());
vo.setTotalEpisodes(drama.getTotalEpisodes());
vo.setViewCount(drama.getViewCount());
@@ -5,6 +5,7 @@ package com.fruitshorts.playback.vo;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Map;
/** 续看进度。 */
@Data
@@ -12,8 +13,10 @@ public class ProgressRespVO {
private Long dramaId;
private Long episodeId;
/** 短剧标题,仅列表类接口批量装配。 */
/** 短剧标题,仅列表类接口批量装配,取中文,兼容旧客户端。 */
private String dramaTitle;
/** 短剧标题语言表,key 为 zh / en / id。 */
private Map<String, String> titleLocales;
/** 短剧封面,仅列表类接口批量装配。 */
private String dramaCover;
/** 当前续看集号。 */
+35
View File
@@ -0,0 +1,35 @@
-- Author: fruit.com.cn
-- 短剧类型名称改为勾选语言。中文仍在 name,英文和印尼语写入原列与 locales。
ALTER TABLE drama_category ADD COLUMN IF NOT EXISTS name_en VARCHAR(64) NOT NULL DEFAULT '';
ALTER TABLE drama_category ADD COLUMN IF NOT EXISTS name_id VARCHAR(64) NOT NULL DEFAULT '';
ALTER TABLE drama_category ADD COLUMN IF NOT EXISTS name_locales TEXT NOT NULL DEFAULT '{}';
UPDATE drama_category
SET name_locales = (
SELECT COALESCE(json_object_agg(k, v), '{}'::json)::text
FROM (
SELECT 'zh' AS k, name AS v WHERE name <> ''
UNION ALL SELECT 'en', name_en WHERE name_en <> ''
UNION ALL SELECT 'id', name_id WHERE name_id <> ''
) s
)
WHERE name_locales = '{}';
UPDATE drama_category AS c
SET name_en = v.name_en,
name_id = v.name_id,
name_locales = json_build_object('zh', c.name, 'en', v.name_en, 'id', v.name_id)::text
FROM (VALUES
(1::bigint, 'Urban Romance', 'Romansa Urban'),
(2, 'Rebirth Comeback', 'Kelahiran Kembali'),
(3, 'Sweet Romance', 'Romansa Manis'),
(4, 'Suspense Revenge', 'Balas Dendam'),
(2112443900437336065, 'Campus Action', 'Aksi Kampus'),
(2112443936592236545, 'Horror Mystery', 'Horor Misteri'),
(2112444003856289794, 'Infinite Travel', 'Perjalanan Tanpa Batas'),
(2112444050467590146, 'Thriller Horror', 'Thriller Horor'),
(2112444103433261057, 'Indonesia Exclusive', 'Eksklusif Indonesia')
) AS v(id, name_en, name_id)
WHERE c.id = v.id
AND (c.name_en = '' OR c.name_id = '');
@@ -0,0 +1,10 @@
-- Author: fruit.com.cn
-- 首页运营分类名称补英文、印尼语。原 name 继续表示中文。
ALTER TABLE drama_classification
ADD COLUMN IF NOT EXISTS name_en VARCHAR(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS name_id VARCHAR(64) NOT NULL DEFAULT '';
COMMENT ON COLUMN drama_classification.name IS '中文名称';
COMMENT ON COLUMN drama_classification.name_en IS '英文名称';
COMMENT ON COLUMN drama_classification.name_id IS '印尼语名称';
+54
View File
@@ -0,0 +1,54 @@
-- Author: fruit.com.cn
-- 标签管理:三种语言名称。短剧单选一个标签。
CREATE TABLE IF NOT EXISTS content_tag (
id BIGINT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
name_en VARCHAR(64) NOT NULL DEFAULT '',
name_id VARCHAR(64) NOT NULL DEFAULT '',
sort INTEGER NOT NULL DEFAULT 0,
status SMALLINT NOT NULL DEFAULT 1,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_content_tag_name ON content_tag (name);
DROP TRIGGER IF EXISTS trg_content_tag_updated_at ON content_tag;
CREATE TRIGGER trg_content_tag_updated_at
BEFORE UPDATE ON content_tag
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
ALTER TABLE drama ADD COLUMN IF NOT EXISTS tag_id BIGINT;
CREATE INDEX IF NOT EXISTS idx_drama_tag ON drama (tag_id);
INSERT INTO content_tag (id, name, name_en, name_id, sort, status)
VALUES
(2113500000000000001, '漫剧', 'Anime', 'Anime', 1, 1),
(2113500000000000002, '真人剧', 'Live Action', 'Live Action', 2, 1)
ON CONFLICT (id) DO NOTHING;
UPDATE drama SET tag_id = 2113500000000000001
WHERE drama_type = 'ANIME' AND tag_id IS NULL;
UPDATE drama SET tag_id = 2113500000000000002
WHERE (drama_type IS NULL OR drama_type = 'LIVE') AND tag_id IS NULL;
INSERT INTO sys_menu (
id, parent_id, title, route_path, route_name, component, icon,
permission_code, type, sort, visible, status, super_admin_visible
) VALUES (
45, 3, '标签管理', 'content-tags', 'content-tags',
'views/contentManagement/TagManagementView', 'ep:price-tag',
'drama:list', 'MENU', 7, 1, 1, 1
) ON CONFLICT (id) DO NOTHING;
INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT v.id, v.role_id, 45
FROM (VALUES
(2113600000000000001, 1),
(2113600000000000002, 2),
(2113600000000000003, 3),
(2113600000000000004, 4)
) AS v(id, role_id)
WHERE NOT EXISTS (
SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = v.role_id AND rm.menu_id = 45
);
@@ -0,0 +1,13 @@
-- Author: fruit.com.cn
-- 短剧标题和简介按勾选语言保存。中文仍写 title / synopsis。
ALTER TABLE drama ADD COLUMN IF NOT EXISTS title_locales TEXT NOT NULL DEFAULT '{}';
ALTER TABLE drama ADD COLUMN IF NOT EXISTS synopsis_locales TEXT NOT NULL DEFAULT '{}';
UPDATE drama
SET title_locales = json_build_object('zh', title)::text
WHERE title_locales = '{}' AND title <> '';
UPDATE drama
SET synopsis_locales = json_build_object('zh', synopsis)::text
WHERE synopsis_locales = '{}' AND synopsis IS NOT NULL AND synopsis <> '';
+51
View File
@@ -0,0 +1,51 @@
-- Author: fruit.com.cn
-- 名称语言改为勾选保存。中文、英文、印尼语仍写原列,勾选结果记在 locales 文本里。
ALTER TABLE content_tag ADD COLUMN IF NOT EXISTS name_locales TEXT NOT NULL DEFAULT '{}';
ALTER TABLE drama_classification ADD COLUMN IF NOT EXISTS name_locales TEXT NOT NULL DEFAULT '{}';
ALTER TABLE drama_playlist_category ADD COLUMN IF NOT EXISTS name_locales TEXT NOT NULL DEFAULT '{}';
ALTER TABLE drama_playlist_category ADD COLUMN IF NOT EXISTS description_locales TEXT NOT NULL DEFAULT '{}';
UPDATE content_tag
SET name_locales = (
SELECT COALESCE(json_object_agg(k, v), '{}'::json)::text
FROM (
SELECT 'zh' AS k, name AS v WHERE name <> ''
UNION ALL SELECT 'en', name_en WHERE name_en <> ''
UNION ALL SELECT 'id', name_id WHERE name_id <> ''
) s
)
WHERE name_locales = '{}';
UPDATE drama_classification
SET name_locales = (
SELECT COALESCE(json_object_agg(k, v), '{}'::json)::text
FROM (
SELECT 'zh' AS k, name AS v WHERE name <> ''
UNION ALL SELECT 'en', name_en WHERE name_en <> ''
UNION ALL SELECT 'id', name_id WHERE name_id <> ''
) s
)
WHERE name_locales = '{}';
UPDATE drama_playlist_category
SET name_locales = (
SELECT COALESCE(json_object_agg(k, v), '{}'::json)::text
FROM (
SELECT 'zh' AS k, name AS v WHERE name <> ''
UNION ALL SELECT 'en', name_en WHERE name_en <> ''
UNION ALL SELECT 'id', name_id WHERE name_id <> ''
) s
)
WHERE name_locales = '{}';
UPDATE drama_playlist_category
SET description_locales = (
SELECT COALESCE(json_object_agg(k, v), '{}'::json)::text
FROM (
SELECT 'zh' AS k, description AS v WHERE description <> ''
UNION ALL SELECT 'en', description_en WHERE description_en <> ''
UNION ALL SELECT 'id', description_id WHERE description_id <> ''
) s
)
WHERE description_locales = '{}';
+15
View File
@@ -0,0 +1,15 @@
-- Author: fruit.com.cn
-- 剧单分类名称和描述补英文、印尼语。原 name/description 继续表示中文。
ALTER TABLE drama_playlist_category
ADD COLUMN IF NOT EXISTS name_en VARCHAR(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS name_id VARCHAR(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS description_en VARCHAR(255) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS description_id VARCHAR(255) NOT NULL DEFAULT '';
COMMENT ON COLUMN drama_playlist_category.name IS '中文名称';
COMMENT ON COLUMN drama_playlist_category.name_en IS '英文名称';
COMMENT ON COLUMN drama_playlist_category.name_id IS '印尼语名称';
COMMENT ON COLUMN drama_playlist_category.description IS '中文描述';
COMMENT ON COLUMN drama_playlist_category.description_en IS '英文描述';
COMMENT ON COLUMN drama_playlist_category.description_id IS '印尼语描述';
+7
View File
@@ -0,0 +1,7 @@
-- Author: fruit.com.cn
-- 标签角标背景色。客户端按 bg_color 绘制封面右上角标签。
ALTER TABLE content_tag ADD COLUMN IF NOT EXISTS bg_color VARCHAR(7) NOT NULL DEFAULT '#E5484D';
UPDATE content_tag SET bg_color = '#E5484D' WHERE name = '热剧' AND (bg_color = '' OR bg_color = '#E5484D');
UPDATE content_tag SET bg_color = '#3B82F6' WHERE name = '新剧';