From e60c47123ce55b7de016b25b58b89d14202f5af2 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Sat, 24 Oct 2026 16:53:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(content):=20=E7=9F=AD=E5=89=A7=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E5=92=8C=E6=A0=87=E7=AD=BE=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-10-24-content-tag.md | 19 ++ .../controller/ContentTagController.java | 72 +++++++ .../content/dal/entity/ContentTag.java | 31 +++ .../fruitshorts/content/dal/entity/Drama.java | 6 + .../content/dal/entity/DramaCategory.java | 4 + .../dal/entity/DramaClassification.java | 5 + .../dal/entity/DramaPlaylistCategory.java | 11 +- .../content/dal/mapper/ContentTagMapper.java | 9 + .../content/service/ContentLocaleTexts.java | 173 +++++++++++++++++ .../content/service/ContentTagService.java | 179 ++++++++++++++++++ .../content/service/DramaCategoryService.java | 82 ++++++-- .../service/DramaClassificationService.java | 28 ++- .../content/service/DramaMultiTagSupport.java | 5 + .../service/DramaPlaylistCategoryService.java | 56 +++++- .../content/service/DramaService.java | 130 ++++++++++++- .../content/vo/ContentTagRespVO.java | 19 ++ .../content/vo/ContentTagSaveReqVO.java | 26 +++ .../content/vo/DramaCategoryRespVO.java | 5 + .../content/vo/DramaCategorySaveReqVO.java | 11 +- .../content/vo/DramaClassificationRespVO.java | 3 + .../vo/DramaClassificationSaveReqVO.java | 9 +- .../vo/DramaPlaylistCategoryRespVO.java | 6 + .../vo/DramaPlaylistCategorySaveReqVO.java | 13 +- .../fruitshorts/content/vo/DramaRespVO.java | 6 + .../content/vo/DramaSaveReqVO.java | 6 + .../DramaServiceClassificationClauseTest.java | 20 ++ .../content/service/DramaServiceTest.java | 5 +- sql/patch-2026-10-24-category-locale.sql | 35 ++++ sql/patch-2026-10-24-classification-i18n.sql | 10 + sql/patch-2026-10-24-content-tag.sql | 54 ++++++ sql/patch-2026-10-24-drama-text-locale.sql | 13 ++ sql/patch-2026-10-24-locale-select.sql | 51 +++++ sql/patch-2026-10-24-playlist-i18n.sql | 15 ++ sql/patch-2026-10-24-tag-color.sql | 7 + 34 files changed, 1071 insertions(+), 53 deletions(-) create mode 100644 docs/plans/2026-10-24-content-tag.md create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ContentTagController.java create mode 100644 fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/ContentTag.java create mode 100644 fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/ContentTagMapper.java create mode 100644 fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/ContentLocaleTexts.java create mode 100644 fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/ContentTagService.java create mode 100644 fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/ContentTagRespVO.java create mode 100644 fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/ContentTagSaveReqVO.java create mode 100644 fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/DramaServiceClassificationClauseTest.java create mode 100644 sql/patch-2026-10-24-category-locale.sql create mode 100644 sql/patch-2026-10-24-classification-i18n.sql create mode 100644 sql/patch-2026-10-24-content-tag.sql create mode 100644 sql/patch-2026-10-24-drama-text-locale.sql create mode 100644 sql/patch-2026-10-24-locale-select.sql create mode 100644 sql/patch-2026-10-24-playlist-i18n.sql create mode 100644 sql/patch-2026-10-24-tag-color.sql 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-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/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 = '新剧';