diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 735e6e6..8d68c0d 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -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` 非空数(均排除逻辑删除) | diff --git a/docs/plans/2026-10-24-content-tag.md b/docs/plans/2026-10-24-content-tag.md new file mode 100644 index 0000000..b452638 --- /dev/null +++ b/docs/plans/2026-10-24-content-tag.md @@ -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`。 diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ContentTagController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ContentTagController.java new file mode 100644 index 0000000..5ea8bd9 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ContentTagController.java @@ -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( + @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 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 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 delete(@PathVariable Long id) { + contentTagService.delete(id); + return Result.ok(); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/TranscodeNoticeController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/TranscodeNoticeController.java new file mode 100644 index 0000000..2f2e143 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/TranscodeNoticeController.java @@ -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 transcode() { + return Result.ok(transcodeNoticeService.notices()); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/DashboardService.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/DashboardService.java index 277d645..14df550 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/DashboardService.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/DashboardService.java @@ -58,7 +58,11 @@ import org.springframework.jdbc.core.JdbcTemplate; *
    *
  • 用户:`total` = app_user 未删除总数;`newToday`/`new7d` = 按 created_at 落在当天 / 近 7 天; * `active7d` = last_login_at 在近 7 天内(**不是 DAU**:真正的日活需要按天留存登录快照, - * 当前没有逐日快照数据源,见 notes);`banned`/`limited` = 按 status 计数。
  • + * 当前没有逐日快照数据源,见 notes);`androidActive7d`/`iosActive7d` = + * `app_user_device` 中状态为 active、近 7 天有心跳的去重设备数; + * `retention1d` = 昨日注册用户中今天仍登录的比例,分母为 0 时返回 null; + * `retention7d` = 8 至 14 天前注册的用户中,近 7 天仍登录的比例,分母为 0 时返回 null; + * `banned`/`limited` = 按 status 计数。 *
  • 内容:短剧按 status 计数(`reviewing` 视为待审、`published` 视为已上线); * 剧集总数与已上线数;`episodeWithMedia` = video_url 非空的剧集数。
  • *
  • 播放:`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.lambdaQuery().eq(AppUser::getStatus, "limited"))); user.put("deactivated", countUser(Wrappers.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 user, LocalDateTime since) { + Map 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 contentMetrics() { Map content = new LinkedHashMap<>(); content.put("dramaTotal", dramaMapper.selectCount(null)); @@ -284,6 +329,8 @@ public class DashboardService { List 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 的流水口径不可相加"); diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/TranscodeNoticeService.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/TranscodeNoticeService.java new file mode 100644 index 0000000..5c96670 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/TranscodeNoticeService.java @@ -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 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); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/TranscodeNoticeRespVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/TranscodeNoticeRespVO.java new file mode 100644 index 0000000..afdf892 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/TranscodeNoticeRespVO.java @@ -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 runningList = new ArrayList<>(); + private List successList = new ArrayList<>(); + private List 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; + } +} diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/TranscodeNoticeContractTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/TranscodeNoticeContractTest.java new file mode 100644 index 0000000..e5e8e29 --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/TranscodeNoticeContractTest.java @@ -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集")); + } +} diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/TranscodeNoticeServiceTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/TranscodeNoticeServiceTest.java new file mode 100644 index 0000000..012f1c9 --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/TranscodeNoticeServiceTest.java @@ -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; + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java index 11f9b6a..ce23d55 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java @@ -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> dramaCategories() { + return Result.ok(appCatalogService.dramaCategories()); + } + + @Operation(summary = "已启用标签") + @GetMapping("/tags") + public Result> tags() { + return Result.ok(appCatalogService.tags()); + } + + /** + * 全部短剧分页。类型、分类、标签放在请求体,避免长 id 进入查询串。 + * 同一组多选为或,不同组同时传入为且。 + */ + @Operation(summary = "全部短剧:类型、分类、标签多选,分页") + @PostMapping("/dramas/page") + public Result> 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> 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 playlistDetail(@PathVariable Long id) { - return Result.ok(appCatalogService.playlistDetail(id)); + public Result playlistDetail( + @PathVariable Long id, + @RequestParam(defaultValue = "1") int pageIndex, + @RequestParam(defaultValue = "20") int pageSize) { + return Result.ok(appCatalogService.playlistDetail(id, pageIndex, pageSize)); } } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java index 580747e..9249237 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java @@ -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 categoryNames = categoryNameMap(); + Map> categoryLocales = categoryLocaleMap(); List 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 categories() { return dramaClassificationService.listEnabled().stream() - .map(c -> new AppCategoryVO(c.getId(), c.getName())) + .map(c -> { + Map 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 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 tags() { + return contentTagService.listEnabled().stream() + .map(item -> option(item.getId(), item.getName(), item.getLocales())) + .toList(); + } + + /** 全部短剧分页。同一行多选是或,不同类型之间是且。 */ + public PageResult libraryDramas(List categoryIds, List classificationIds, + List tagIds, int pageIndex, int pageSize) { + PageResult page = dramaService.pagePublishedLibrary( + categoryIds, classificationIds, tagIds, pageIndex, pageSize); + return new PageResult<>(page.getTotal(), toCards(page.getList())); + } + + private AppCategoryVO option(Long id, String name, Map locales) { + AppCategoryVO vo = new AppCategoryVO(id, name); + if (locales != null && !locales.isEmpty()) { + vo.setLocales(locales); + } + return vo; + } + /** 分类频道:按分类分页查已上线短剧卡片(通过分类关联表过滤)。 */ public PageResult categoryDramas(Long classificationId, int pageIndex, int pageSize) { @@ -108,7 +155,7 @@ public class AppCatalogService { } int toIndex = Math.min(fromIndex + safeSize, ordered.size()); List pageRows = ordered.subList(fromIndex, toIndex); - return new PageResult<>(total, cards(pageRows, categoryNameMap())); + return new PageResult<>(total, cards(pageRows, categoryLocaleMap())); } public CursorResult feed( @@ -128,7 +175,7 @@ public class AppCatalogService { boolean hasMore = rows.size() > pageSize; List 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 thumbnailUrls = thumbnailUrlResolver.resolveAll( episodes.stream().map(DramaEpisode::getId).sorted().toList()); - String categoryName = categoryNameMap().getOrDefault(drama.getCategoryId(), ""); + List> types = dramaCategoryService.localesForDrama(drama, categoryLocaleMap()); AppDramaDetailVO detail = new AppDramaDetailVO(); detail.setId(drama.getId()); - detail.setTitle(drama.getTitle()); - detail.setSynopsis(drama.getSynopsis()); + Map titles = ContentLocaleTexts.read(drama.getTitleLocales(), drama.getTitle(), null, null); + Map 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 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 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 allIds = dramaPlaylistCategoryService.listDramaIdsByCategory(playlistId); List 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 pageRows = from >= ordered.size() + ? List.of() + : ordered.subList(from, Math.min(from + safeSize, ordered.size())); + Map locales = ContentLocaleTexts.read( + category.getNameLocales(), category.getName(), category.getNameEn(), category.getNameId()); + Map 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 categorySections(int size, Map categoryNames) { + private List categorySections(int size, Map> categoryLocales) { List 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 sections, String code, String title, - List dramas, Map categoryNames) { + List dramas, Map> 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 cards(List dramas, Map categoryNames) { + private List cards(List dramas, Map> categoryLocales) { List ids = dramas.stream().map(Drama::getId).toList(); Map previews = episodeService.firstPlayableByDramas(ids); Map commentCounts = commentService.countByEpisodes( previews.values().stream().map(DramaEpisode::getId).sorted().toList()); Map thumbnailUrls = thumbnailUrlResolver.resolveAll( previews.values().stream().map(DramaEpisode::getId).sorted().toList()); + Map 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 categoryNames, + private AppDramaCardVO toCard(Drama drama, Map> categoryLocales, + Map tags, DramaEpisode preview, Map commentCounts, Map thumbnailUrls) { + List> types = dramaCategoryService.localesForDrama(drama, categoryLocales); AppDramaCardVO card = new AppDramaCardVO(); card.setId(drama.getId()); - card.setTitle(drama.getTitle()); + Map titles = ContentLocaleTexts.read(drama.getTitleLocales(), drama.getTitle(), null, null); + Map 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 categoryNameMap() { - Map names = new HashMap<>(); - dramaCategoryService.listBasic().forEach(category -> names.put(category.getId(), category.getName())); - return names; + private Map loadTags(List dramas) { + List ids = dramas.stream() + .map(Drama::getTagId) + .filter(id -> id != null && id > 0L) + .distinct() + .toList(); + Map tags = contentTagService.findByIds(ids); + return tags == null ? Map.of() : tags; + } + + private void fillTag(AppDramaCardVO card, Drama drama, Map tags) { + ContentTag tag = tagOf(drama, tags); + Map 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 locales = tagLocales(tag); + detail.setTagLocales(locales); + detail.setTagColor(tag == null ? "" : tag.getBgColor()); + } + + private ContentTag tagOf(Drama drama, Map tags) { + if (drama.getTagId() == null || drama.getTagId() <= 0L) { + return null; + } + return tags.get(drama.getTagId()); + } + + private Map tagLocales(ContentTag tag) { + if (tag == null) { + return Map.of(); + } + return ContentLocaleTexts.read(tag.getNameLocales(), tag.getName(), tag.getNameEn(), tag.getNameId()); + } + + private Map> categoryLocaleMap() { + return dramaCategoryService.localeMap(); } /** 卡片只带一句简介,避免列表响应体过大 */ + private Map shortLocales(Map locales) { + Map 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 ""; diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppCategoryVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppCategoryVO.java index 62c1c50..d27d99c 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppCategoryVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppCategoryVO.java @@ -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 locales; + + public AppCategoryVO(Long id, String name) { + this.id = id; + this.locales = name == null || name.isBlank() ? Map.of() : Map.of("zh", name); + } } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java index 4a9d080..819e888 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java @@ -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 titleLocales; private String cover; - private String synopsis; - private String genre; - private String category; + private Map synopsisLocales; + /** 短剧选中的全部类型,每项是一份语言表。 */ + private List> types; + /** 标签语言表,key 为 zh / en / id。 */ + private Map 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; diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaDetailVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaDetailVO.java index 9992c04..f4503e5 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaDetailVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaDetailVO.java @@ -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 titleLocales; + private Map synopsisLocales; private String cover; - private String genre; - private String category; + private List> types; + private Map tagLocales; + private String tagColor; private Integer totalEpisodes; private Integer freeEpisodes; private Integer unlockCoin; diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaPageReqVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaPageReqVO.java new file mode 100644 index 0000000..f9be8d5 --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaPageReqVO.java @@ -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 categoryIds; + /** 分类 id,多选为或。 */ + private List classificationIds; + /** 标签 id,多选为或。 */ + private List tagIds; +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppPlaylistDetailVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppPlaylistDetailVO.java index acc89b6..ef3f6e3 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppPlaylistDetailVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppPlaylistDetailVO.java @@ -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 locales; + private Map descriptionLocales; /** 该剧单下已上线短剧的实际条数(被下架/下线的不计入) */ private Integer dramaCount; + /** 当前短剧页码,从 1 开始 */ + private Integer pageIndex; + /** 当前短剧页大小 */ + private Integer pageSize; private List list; } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppSectionVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppSectionVO.java index 86bc787..ca1653b 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppSectionVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppSectionVO.java @@ -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 titleLocales; private List dramas; + + public AppSectionVO(String code, String title, List dramas) { + this(code, Map.of(ContentLocaleTexts.ZH, title == null ? "" : title), dramas); + } + + public AppSectionVO(String code, Map titleLocales, List dramas) { + this.code = code; + this.titleLocales = titleLocales; + this.dramas = dramas; + } } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java index 4d8d844..b1793ee 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java @@ -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); + } } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java index 745bc2d..a734b20 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java @@ -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); } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFavoriteCountTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFavoriteCountTest.java index d1c1fb0..681227e 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFavoriteCountTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFavoriteCountTest.java @@ -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> captor = ArgumentCaptor.forClass(Wrapper.class); diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFeedTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFeedTest.java index 5e4111b..f2f975a 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFeedTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFeedTest.java @@ -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)); } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceK88Test.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceK88Test.java index 6287c06..1829714 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceK88Test.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceK88Test.java @@ -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)); } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceMultiTagTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceMultiTagTest.java index 7d6c712..4ed2ef0 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceMultiTagTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceMultiTagTest.java @@ -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)); } diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java index 6422ebe..3c8f04f 100644 --- a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java @@ -38,6 +38,8 @@ public class SecurityConfig { "/app/discover/**", "/app/feed", "/app/categories/**", + "/app/drama-categories", + "/app/tags", "/app/playlists/**", "/app/banners/**", "/app/dramas/**", diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/ContentTag.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/ContentTag.java new file mode 100644 index 0000000..c70c3eb --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/ContentTag.java @@ -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; +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/Drama.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/Drama.java index 5cceb5b..68deb7b 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/Drama.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/Drama.java @@ -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; diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaCategory.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaCategory.java index c2004fd..8a9c0eb 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaCategory.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaCategory.java @@ -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; } diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaClassification.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaClassification.java index 5911e20..fd4fb2a 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaClassification.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaClassification.java @@ -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; diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaPlaylistCategory.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaPlaylistCategory.java index 61ef7d2..5661c37 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaPlaylistCategory.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaPlaylistCategory.java @@ -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; diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/ContentTagMapper.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/ContentTagMapper.java new file mode 100644 index 0000000..d63d8fe --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/ContentTagMapper.java @@ -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 { +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/ContentLocaleTexts.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/ContentLocaleTexts.java new file mode 100644 index 0000000..aaace51 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/ContentLocaleTexts.java @@ -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 CODES = List.of(ZH, EN, ID); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final TypeReference> MAP_TYPE = new TypeReference<>() { + }; + + private ContentLocaleTexts() { + } + + public static Map names(Map locales, String name, + String nameEn, String nameId) { + Map source = locales == null ? legacy(name, nameEn, nameId) : locales; + return normalize(source, NAME_LIMIT, "名称"); + } + + public static Map titles(Map locales, String title) { + Map source = locales == null ? legacy(title, null, null) : locales; + return normalize(source, TITLE_LIMIT, "名称"); + } + + public static Map descriptions(Map locales, Map names, + String description, String descriptionEn, + String descriptionId) { + return optional(locales, names, description, descriptionEn, descriptionId, DESCRIPTION_LIMIT, "描述"); + } + + public static Map synopses(Map locales, Map titles, + String synopsis) { + return optional(locales, titles, synopsis, null, null, SYNOPSIS_LIMIT, "简介"); + } + + private static Map optional(Map locales, Map required, + String zh, String en, String id, int limit, String field) { + Map source = locales == null ? legacy(zh, en, id) : locales; + Map 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 read(String json, String zh, String en, String id) { + Map parsed = parse(json); + Map 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 locales) { + try { + return MAPPER.writeValueAsString(locales == null ? Map.of() : locales); + } catch (Exception ex) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "语言内容无法保存"); + } + } + + public static String value(Map locales, String code) { + String text = locales == null ? null : locales.get(code); + return text == null ? "" : text; + } + + private static Map normalize(Map source, int limit, String field) { + Map 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 legacy(String zh, String en, String id) { + Map 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 source) { + for (String code : source.keySet()) { + if (!CODES.contains(code)) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "不支持的语言"); + } + } + } + + private static Map parse(String json) { + if (json == null || json.isBlank()) { + return Map.of(); + } + try { + Map parsed = MAPPER.readValue(json, MAP_TYPE); + return parsed == null ? Map.of() : parsed; + } catch (Exception ex) { + return Map.of(); + } + } + + private static void put(Map 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 + " 个字符"); + } +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/ContentTagService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/ContentTagService.java new file mode 100644 index 0000000..a2294e8 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/ContentTagService.java @@ -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 list() { + return tagMapper.selectList(Wrappers.lambdaQuery() + .orderByAsc(ContentTag::getSort) + .orderByDesc(ContentTag::getId)) + .stream() + .map(this::toResp) + .toList(); + } + + public List listEnabled() { + return tagMapper.selectList(Wrappers.lambdaQuery() + .eq(ContentTag::getStatus, 1) + .orderByAsc(ContentTag::getSort) + .orderByDesc(ContentTag::getId)) + .stream() + .map(this::toResp) + .toList(); + } + + public Long create(ContentTagSaveReqVO req) { + Map 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 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.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 namesOf(ContentTagSaveReqVO req) { + return ContentLocaleTexts.names(req.getLocales(), req.getName(), req.getNameEn(), req.getNameId()); + } + + private void writeNames(ContentTag entity, Map 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.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 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 findByIds(Collection ids) { + if (ids == null || ids.isEmpty()) { + return Map.of(); + } + Map 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> localeMap(Collection ids) { + if (ids == null || ids.isEmpty()) { + return Map.of(); + } + Map> 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; + } +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaCategoryService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaCategoryService.java index ea0b4d4..89e9e4e 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaCategoryService.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaCategoryService.java @@ -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 counts = countDramasByCategory(); return dramaCategoryMapper.selectList( Wrappers.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 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 listBasic() { return dramaCategoryMapper.selectList( Wrappers.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 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> localeMap() { + Map> result = new HashMap<>(); + for (DramaCategory category : dramaCategoryMapper.selectList(null)) { + result.put(category.getId(), readLocales(category)); + } + return result; + } + + /** 一部短剧选中的类型,按保存顺序返回语言表。 */ + public List> localesForDrama(Drama drama, Map> localesById) { + List> result = new ArrayList<>(); + for (Long id : DramaMultiTagSupport.parseCategoryIds(drama)) { + Map locales = localesById.get(id); + if (locales != null && !locales.isEmpty()) { + result.add(locales); + } + } + return result; + } + + private DramaCategoryRespVO toResp(DramaCategory category, Integer dramaCount) { + Map 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 namesOf(DramaCategorySaveReqVO req) { + return ContentLocaleTexts.names(req.getLocales(), req.getName(), req.getNameEn(), req.getNameId()); + } + + private void writeNames(DramaCategory category, Map 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 readLocales(DramaCategory category) { + return ContentLocaleTexts.read( + category.getNameLocales(), category.getName(), category.getNameEn(), category.getNameId()); + } } diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaClassificationService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaClassificationService.java index 31a43ef..7708671 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaClassificationService.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaClassificationService.java @@ -49,7 +49,12 @@ public class DramaClassificationService { .stream().map(item -> { DramaClassificationRespVO vo = new DramaClassificationRespVO(); vo.setId(item.getId()); - vo.setName(item.getName()); + Map 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 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 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 namesOf(DramaClassificationSaveReqVO req) { + return ContentLocaleTexts.names(req.getLocales(), req.getName(), req.getNameEn(), req.getNameId()); + } + + private void writeNames(DramaClassification entity, Map 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.lambdaQuery() .eq(DramaClassification::getName, name.trim()) diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaMultiTagSupport.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaMultiTagSupport.java index 5876bb3..e1bd878 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaMultiTagSupport.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaMultiTagSupport.java @@ -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); } diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaPlaylistCategoryService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaPlaylistCategoryService.java index 9d2e676..eb0b2df 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaPlaylistCategoryService.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaPlaylistCategoryService.java @@ -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 locales = ContentLocaleTexts.read( + item.getNameLocales(), item.getName(), item.getNameEn(), item.getNameId()); + Map 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 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 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 listEnabled() { + return categoryMapper.selectList(Wrappers.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 namesOf(DramaPlaylistCategorySaveReqVO req) { + return ContentLocaleTexts.names(req.getLocales(), req.getName(), req.getNameEn(), req.getNameId()); + } + + private void writeText(DramaPlaylistCategory entity, DramaPlaylistCategorySaveReqVO req, + Map locales) { + Map 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.lambdaQuery() .eq(DramaPlaylistCategory::getName, name.trim()) diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java index 1c01677..e2aa3d1 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java @@ -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 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.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 pagePublishedLibrary(List categoryIds, List classificationIds, + List tagIds, int pageIndex, int pageSize) { + int safeIndex = Math.max(1, pageIndex); + int safeSize = Math.max(1, Math.min(pageSize, PUBLISHED_PAGE_SIZE_MAX)); + Page page = new Page<>(safeIndex, safeSize); + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() + .eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode()); + applyLibraryFilters(wrapper, categoryIds, classificationIds, tagIds); + wrapper.orderByDesc(Drama::getSort).orderByDesc(Drama::getId); + IPage result = dramaMapper.selectPage(page, wrapper); + return new PageResult<>(result.getTotal(), result.getRecords()); + } + + private void applyLibraryFilters(LambdaQueryWrapper wrapper, List categoryIds, + List classificationIds, List tagIds) { + List types = positiveIds(categoryIds); + if (!types.isEmpty()) { + DramaMultiTagSupport.applyCategoryIdTokens(wrapper, types); + } + List tags = positiveIds(tagIds); + if (!tags.isEmpty()) { + wrapper.in(Drama::getTagId, tags); + } + List 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 positiveIds(List ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + return ids.stream().filter(id -> id != null && id > 0L).distinct().toList(); + } + /** C 端分类频道:按分类分页查已上线短剧,含 total。 */ public PageResult 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 page = new Page<>(safeIndex, safeSize); LambdaQueryWrapper wrapper = Wrappers.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 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 titles = ContentLocaleTexts.titles(req.getTitleLocales(), req.getTitle()); + Map 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 categories = dramaCategoryService.nameMap(); + Map tags = loadTags(dramas); return dramas.stream().map(drama -> { List 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 categoryNames = DramaMultiTagSupport.categoryNames(drama, categoryIds, categories); List 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 titles = ContentLocaleTexts.read( + drama.getTitleLocales(), drama.getTitle(), null, null); + Map 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 loadTags(List dramas) { + List 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); } diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/ContentTagRespVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/ContentTagRespVO.java new file mode 100644 index 0000000..ad8d039 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/ContentTagRespVO.java @@ -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 locales; + private String bgColor; + private Integer sort; + private Integer status; +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/ContentTagSaveReqVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/ContentTagSaveReqVO.java new file mode 100644 index 0000000..08c4b1b --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/ContentTagSaveReqVO.java @@ -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 locales; + + @Pattern(regexp = "^$|^#[0-9A-Fa-f]{6}$", message = "背景色格式应为 #RRGGBB") + private String bgColor; + + private Integer sort; + private Integer status; +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaCategoryRespVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaCategoryRespVO.java index f5abb85..3672243 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaCategoryRespVO.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaCategoryRespVO.java @@ -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 locales; private Integer sort; private Integer status; /** 该类型下的短剧数量 */ diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaCategorySaveReqVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaCategorySaveReqVO.java index 56c6dd8..23c9f6c 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaCategorySaveReqVO.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaCategorySaveReqVO.java @@ -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 locales; + private Integer sort; private Integer status; } diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaClassificationRespVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaClassificationRespVO.java index 65cf039..b2fe065 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaClassificationRespVO.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaClassificationRespVO.java @@ -12,6 +12,9 @@ public class DramaClassificationRespVO { private Long id; private String name; + private String nameEn; + private String nameId; + private java.util.Map locales; private Integer sort; private Integer status; private Integer dramaCount; diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaClassificationSaveReqVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaClassificationSaveReqVO.java index 6c023f4..13e69ec 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaClassificationSaveReqVO.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaClassificationSaveReqVO.java @@ -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 locales; + private Integer sort; private Integer status; } diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaPlaylistCategoryRespVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaPlaylistCategoryRespVO.java index 1163548..ed9894b 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaPlaylistCategoryRespVO.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaPlaylistCategoryRespVO.java @@ -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 locales; + private java.util.Map descriptionLocales; private Integer sort; private Integer status; /** 该分类下已关联的短剧数量 */ diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaPlaylistCategorySaveReqVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaPlaylistCategorySaveReqVO.java index 5275cf4..e0f2fd6 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaPlaylistCategorySaveReqVO.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaPlaylistCategorySaveReqVO.java @@ -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 locales; + private String description; + private String descriptionEn; + private String descriptionId; + /** 勾选语言对应的描述。 */ + private java.util.Map descriptionLocales; private Integer sort; private Integer status; diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaRespVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaRespVO.java index 58e9456..a4a847a 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaRespVO.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaRespVO.java @@ -17,6 +17,7 @@ public class DramaRespVO { private Long id; private String code; private String title; + private java.util.Map titleLocales; private String subtitle; private Long categoryId; private List categoryIds; @@ -24,12 +25,17 @@ public class DramaRespVO { private String category; private String genre; private String description; + private java.util.Map synopsisLocales; private String cover; /** 整剧预告片地址 */ private String trailerUrl; private String language; private String dramaType; private List dramaTypes; + private Long tagId; + private String tagName; + private String tagNameEn; + private String tagNameId; private Integer episodes; private Integer freeEpisodes; private Integer unlockCoin; diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaSaveReqVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaSaveReqVO.java index 2d5cd63..6c79db7 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaSaveReqVO.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaSaveReqVO.java @@ -14,6 +14,8 @@ public class DramaSaveReqVO { @NotBlank(message = "请填写短剧名称") private String title; + /** 勾选语言的标题,key 为 zh / en / id。 */ + private java.util.Map titleLocales; private String subtitle; private Long categoryId; @@ -21,6 +23,8 @@ public class DramaSaveReqVO { private List categoryIds; private String genre; private String description; + /** 勾选语言的简介。 */ + private java.util.Map synopsisLocales; private String cover; /** 整剧预告片地址(复用已有的 drama.trailer_url 列) */ private String trailerUrl; @@ -29,6 +33,8 @@ public class DramaSaveReqVO { private String dramaType; /** 后台多选优先,创作者中心可继续只传 dramaType */ private List dramaTypes; + /** 单选标签。不传则保持原值。 */ + private Long tagId; private Integer freeEpisodes; private Integer unlockCoin; @PositiveOrZero(message = "收藏数不能小于 0") diff --git a/fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/DramaServiceClassificationClauseTest.java b/fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/DramaServiceClassificationClauseTest.java new file mode 100644 index 0000000..6ee63a7 --- /dev/null +++ b/fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/DramaServiceClassificationClauseTest.java @@ -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"); + } +} diff --git a/fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/DramaServiceTest.java b/fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/DramaServiceTest.java index 47095c2..5188df6 100644 --- a/fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/DramaServiceTest.java +++ b/fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/DramaServiceTest.java @@ -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 -> { diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/WatchProgressService.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/WatchProgressService.java index 29a98a1..1730692 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/WatchProgressService.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/WatchProgressService.java @@ -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 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()); diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressRespVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressRespVO.java index 4dd00ea..96cce81 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressRespVO.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressRespVO.java @@ -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 titleLocales; /** 短剧封面,仅列表类接口批量装配。 */ private String dramaCover; /** 当前续看集号。 */ diff --git a/sql/patch-2026-10-24-category-locale.sql b/sql/patch-2026-10-24-category-locale.sql new file mode 100644 index 0000000..a8e2116 --- /dev/null +++ b/sql/patch-2026-10-24-category-locale.sql @@ -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 = ''); diff --git a/sql/patch-2026-10-24-classification-i18n.sql b/sql/patch-2026-10-24-classification-i18n.sql new file mode 100644 index 0000000..8cd2a9e --- /dev/null +++ b/sql/patch-2026-10-24-classification-i18n.sql @@ -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 '印尼语名称'; diff --git a/sql/patch-2026-10-24-content-tag.sql b/sql/patch-2026-10-24-content-tag.sql new file mode 100644 index 0000000..b8740a2 --- /dev/null +++ b/sql/patch-2026-10-24-content-tag.sql @@ -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 +); diff --git a/sql/patch-2026-10-24-drama-text-locale.sql b/sql/patch-2026-10-24-drama-text-locale.sql new file mode 100644 index 0000000..c9cfa73 --- /dev/null +++ b/sql/patch-2026-10-24-drama-text-locale.sql @@ -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 <> ''; diff --git a/sql/patch-2026-10-24-locale-select.sql b/sql/patch-2026-10-24-locale-select.sql new file mode 100644 index 0000000..05ad739 --- /dev/null +++ b/sql/patch-2026-10-24-locale-select.sql @@ -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 = '{}'; diff --git a/sql/patch-2026-10-24-playlist-i18n.sql b/sql/patch-2026-10-24-playlist-i18n.sql new file mode 100644 index 0000000..e1f974c --- /dev/null +++ b/sql/patch-2026-10-24-playlist-i18n.sql @@ -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 '印尼语描述'; diff --git a/sql/patch-2026-10-24-tag-color.sql b/sql/patch-2026-10-24-tag-color.sql new file mode 100644 index 0000000..61ede74 --- /dev/null +++ b/sql/patch-2026-10-24-tag-color.sql @@ -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 = '新剧';