feat(content): 短剧分类和标签支持多语言

This commit is contained in:
Zhou Weidong committed 2026-10-24 16:53:26 +08:00
1 parent 5c20cf1ab9
commit e60c47123c
34 files changed
+1071 -53

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
# 标签管理(替换短剧内容类型)
## 决定
不做「语言开关」总控。分类和剧单已经用 `name` / `nameEn` / `nameId` 三列。标签沿用同一套,避免两套国际化。语言总控要改短剧标题、简介、剧集标题,单独开一轮。
## 本轮
- 短剧编辑去掉「内容类型」。库里的 `drama_type` 先留着,推荐流的 `ANIME` / `LIVE` 筛选不受影响,后台不再改它。
- 内容管理下新增「标签管理」。
- 标签字段:中文名、英文名、印尼语名、排序、启用状态。三个名称都必填。
- 短剧 `tag_id` 单选,可空。编辑页用下拉,只列出启用中的标签。
- 已有短剧按原内容类型回填:`ANIME` → 漫剧,`LIVE` → 真人剧。
## 接口
- `GET/POST /admin/content-tags`,`PUT/DELETE /admin/content-tags/{id}`,权限沿用 `drama:list` / `create` / `update` / `delete`。
- 短剧保存增加 `tagId`。不传则保持原值;传 `0` 表示清空。
- 短剧列表返回 `tagId`、`tagName`、`tagNameEn`、`tagNameId`。
@@ -0,0 +1,72 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.service.ContentTagService;
import com.fruitshorts.content.vo.ContentTagRespVO;
import com.fruitshorts.content.vo.ContentTagSaveReqVO;
import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 短剧标签管理。短剧编辑时单选其中一个启用标签。 */
@Tag(name = "后台-标签")
@RestController
@RequestMapping("/admin/content-tags")
@RequiredArgsConstructor
public class ContentTagController {
private final ContentTagService contentTagService;
@Operation(summary = "标签列表")
@GetMapping
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<List<ContentTagRespVO>> list(
@RequestParam(required = false) Boolean enabledOnly) {
if (Boolean.TRUE.equals(enabledOnly)) {
return Result.ok(contentTagService.listEnabled());
}
return Result.ok(contentTagService.list());
}
@Operation(summary = "新建标签")
@PostMapping
@PreAuthorize("@ss.hasPermission('drama:create')")
@OperationLog(module = "内容管理", action = "新增标签")
public Result<Long> create(@Valid @RequestBody ContentTagSaveReqVO req) {
return Result.ok(contentTagService.create(req));
}
@Operation(summary = "编辑标签")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:update')")
@OperationLog(module = "内容管理", action = "编辑标签")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody ContentTagSaveReqVO req) {
contentTagService.update(id, req);
return Result.ok();
}
@Operation(summary = "删除标签")
@DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermission('drama:delete')")
@OperationLog(module = "内容管理", action = "删除标签")
public Result<Void> delete(@PathVariable Long id) {
contentTagService.delete(id);
return Result.ok();
}
}
@@ -0,0 +1,31 @@
// Author: fruit.com.cn
package com.fruitshorts.content.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 短剧标签。名称分中文、英文、印尼语。 */
@Data
@TableName("content_tag")
public class ContentTag {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 中文名称。 */
private String name;
private String nameEn;
private String nameId;
/** 已选语言的名称。 */
private String nameLocales;
/** 角标背景色,#RRGGBB。 */
private String bgColor;
private Integer sort;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -26,6 +26,10 @@ public class Drama {
private String categoryIds;
private String genre;
private String synopsis;
/** 标题语言表,JSON。 */
private String titleLocales;
/** 简介语言表,JSON。 */
private String synopsisLocales;
private String coverUrl;
private String horizontalCoverUrl;
private String language;
@@ -33,6 +37,8 @@ public class Drama {
private String dramaType;
/** 内容类型多选,逗号分隔(ANIME/LIVE),drama_type 保留第一个 */
private String dramaTypes;
/** 单选标签,对应 content_tag.id。 */
private Long tagId;
private Integer totalEpisodes;
private Integer freeEpisodes;
private Integer unlockCoin;
@@ -14,6 +14,10 @@ public class DramaCategory {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String name;
private String nameEn;
private String nameId;
/** 已选语言的名称。 */
private String nameLocales;
private Integer sort;
private Integer status;
}
@@ -16,7 +16,12 @@ public class DramaClassification {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 中文名称。 */
private String name;
private String nameEn;
private String nameId;
/** 已选语言的名称。 */
private String nameLocales;
private Integer sort;
private Integer status;
private LocalDateTime createdAt;
@@ -16,9 +16,18 @@ public class DramaPlaylistCategory {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 中文名称,列表和唯一性都用这一列。 */
private String name;
/** 分类描述,便于运营说明该分类的用途 */
private String nameEn;
private String nameId;
/** 中文描述。 */
private String description;
private String descriptionEn;
private String descriptionId;
/** 已选语言的名称。 */
private String nameLocales;
/** 已选语言的描述。 */
private String descriptionLocales;
private Integer sort;
private Integer status;
private LocalDateTime createdAt;
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
package com.fruitshorts.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.content.dal.entity.ContentTag;
public interface ContentTagMapper extends BaseMapper<ContentTag> {
}
@@ -0,0 +1,173 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** 内容名称的可选语言。中文必填,英文和印尼语按勾选保存。 */
public final class ContentLocaleTexts {
public static final String ZH = "zh";
public static final String EN = "en";
public static final String ID = "id";
private static final int NAME_LIMIT = 64;
private static final int TITLE_LIMIT = 80;
private static final int DESCRIPTION_LIMIT = 255;
private static final int SYNOPSIS_LIMIT = 1000;
private static final List<String> CODES = List.of(ZH, EN, ID);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final TypeReference<Map<String, String>> MAP_TYPE = new TypeReference<>() {
};
private ContentLocaleTexts() {
}
public static Map<String, String> names(Map<String, String> locales, String name,
String nameEn, String nameId) {
Map<String, String> source = locales == null ? legacy(name, nameEn, nameId) : locales;
return normalize(source, NAME_LIMIT, "名称");
}
public static Map<String, String> titles(Map<String, String> locales, String title) {
Map<String, String> source = locales == null ? legacy(title, null, null) : locales;
return normalize(source, TITLE_LIMIT, "名称");
}
public static Map<String, String> descriptions(Map<String, String> locales, Map<String, String> names,
String description, String descriptionEn,
String descriptionId) {
return optional(locales, names, description, descriptionEn, descriptionId, DESCRIPTION_LIMIT, "描述");
}
public static Map<String, String> synopses(Map<String, String> locales, Map<String, String> titles,
String synopsis) {
return optional(locales, titles, synopsis, null, null, SYNOPSIS_LIMIT, "简介");
}
private static Map<String, String> optional(Map<String, String> locales, Map<String, String> required,
String zh, String en, String id, int limit, String field) {
Map<String, String> source = locales == null ? legacy(zh, en, id) : locales;
Map<String, String> result = new LinkedHashMap<>();
for (String code : required.keySet()) {
String value = trim(source.get(code));
if (value.length() > limit) {
throw tooLong(code, field, limit);
}
if (!value.isEmpty()) {
result.put(code, value);
}
}
return result;
}
public static Map<String, String> read(String json, String zh, String en, String id) {
Map<String, String> parsed = parse(json);
Map<String, String> result = new LinkedHashMap<>();
put(result, ZH, first(parsed.get(ZH), zh));
put(result, EN, first(parsed.get(EN), en));
put(result, ID, first(parsed.get(ID), id));
return result;
}
public static String json(Map<String, String> locales) {
try {
return MAPPER.writeValueAsString(locales == null ? Map.of() : locales);
} catch (Exception ex) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "语言内容无法保存");
}
}
public static String value(Map<String, String> locales, String code) {
String text = locales == null ? null : locales.get(code);
return text == null ? "" : text;
}
private static Map<String, String> normalize(Map<String, String> source, int limit, String field) {
Map<String, String> result = new LinkedHashMap<>();
for (String code : CODES) {
if (!source.containsKey(code)) {
continue;
}
String value = trim(source.get(code));
if (value.isEmpty()) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请填写" + label(code) + field);
}
if (value.length() > limit) {
throw tooLong(code, field, limit);
}
result.put(code, value);
}
rejectUnknown(source);
if (!result.containsKey(ZH)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请选择中文并填写中文" + field);
}
return result;
}
private static Map<String, String> legacy(String zh, String en, String id) {
Map<String, String> source = new LinkedHashMap<>();
source.put(ZH, zh);
if (!trim(en).isEmpty()) {
source.put(EN, en);
}
if (!trim(id).isEmpty()) {
source.put(ID, id);
}
return source;
}
private static void rejectUnknown(Map<String, String> source) {
for (String code : source.keySet()) {
if (!CODES.contains(code)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "不支持的语言");
}
}
}
private static Map<String, String> parse(String json) {
if (json == null || json.isBlank()) {
return Map.of();
}
try {
Map<String, String> parsed = MAPPER.readValue(json, MAP_TYPE);
return parsed == null ? Map.of() : parsed;
} catch (Exception ex) {
return Map.of();
}
}
private static void put(Map<String, String> target, String code, String value) {
String text = trim(value);
if (!text.isEmpty()) {
target.put(code, text);
}
}
private static String first(String preferred, String fallback) {
return trim(preferred).isEmpty() ? fallback : preferred;
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private static String label(String code) {
return switch (code) {
case EN -> "英文";
case ID -> "印尼语";
default -> "中文";
};
}
private static ServiceException tooLong(String code, String field, int limit) {
return new ServiceException(ErrorCode.BAD_REQUEST, label(code) + field + "最多 " + limit + " 个字符");
}
}
@@ -0,0 +1,179 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.ContentTag;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.mapper.ContentTagMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.vo.ContentTagRespVO;
import com.fruitshorts.content.vo.ContentTagSaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** 短剧标签。名称先选语言,中文必填,英文和印尼语按勾选填写。 */
@Service
@RequiredArgsConstructor
public class ContentTagService {
private final ContentTagMapper tagMapper;
private final DramaMapper dramaMapper;
public List<ContentTagRespVO> list() {
return tagMapper.selectList(Wrappers.<ContentTag>lambdaQuery()
.orderByAsc(ContentTag::getSort)
.orderByDesc(ContentTag::getId))
.stream()
.map(this::toResp)
.toList();
}
public List<ContentTagRespVO> listEnabled() {
return tagMapper.selectList(Wrappers.<ContentTag>lambdaQuery()
.eq(ContentTag::getStatus, 1)
.orderByAsc(ContentTag::getSort)
.orderByDesc(ContentTag::getId))
.stream()
.map(this::toResp)
.toList();
}
public Long create(ContentTagSaveReqVO req) {
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), null);
ContentTag entity = new ContentTag();
writeNames(entity, locales);
entity.setBgColor(normalizeColor(req.getBgColor()));
Long count = tagMapper.selectCount(null);
entity.setSort(req.getSort() == null
? (count == null ? 0 : count.intValue()) + 1
: req.getSort());
entity.setStatus(req.getStatus() == null ? 1 : req.getStatus());
tagMapper.insert(entity);
return entity.getId();
}
public void update(Long id, ContentTagSaveReqVO req) {
ContentTag entity = getExisting(id);
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), id);
writeNames(entity, locales);
if (req.getBgColor() != null) {
entity.setBgColor(normalizeColor(req.getBgColor()));
}
if (req.getSort() != null) {
entity.setSort(req.getSort());
}
if (req.getStatus() != null) {
entity.setStatus(req.getStatus());
}
tagMapper.updateById(entity);
}
public void delete(Long id) {
getExisting(id);
dramaMapper.update(null, Wrappers.<Drama>lambdaUpdate()
.eq(Drama::getTagId, id)
.set(Drama::getTagId, null));
tagMapper.deleteById(id);
}
/** 短剧保存时校验标签存在且启用。空 id 表示不选。 */
public void requireEnabled(Long id) {
if (id == null || id <= 0L) {
return;
}
ContentTag tag = tagMapper.selectById(id);
if (tag == null || tag.getStatus() == null || tag.getStatus() != 1) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "标签不存在或已停用");
}
}
private Map<String, String> namesOf(ContentTagSaveReqVO req) {
return ContentLocaleTexts.names(req.getLocales(), req.getName(), req.getNameEn(), req.getNameId());
}
private void writeNames(ContentTag entity, Map<String, String> locales) {
entity.setName(locales.get(ContentLocaleTexts.ZH));
entity.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
entity.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
entity.setNameLocales(ContentLocaleTexts.json(locales));
}
private String normalizeColor(String color) {
if (color == null || color.isBlank()) {
return "#E5484D";
}
return color.trim().toUpperCase();
}
private ContentTag getExisting(Long id) {
ContentTag entity = id == null ? null : tagMapper.selectById(id);
if (entity == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "标签不存在");
}
return entity;
}
private void ensureNameAvailable(String name, Long excludeId) {
Long count = tagMapper.selectCount(Wrappers.<ContentTag>lambdaQuery()
.eq(ContentTag::getName, name.trim())
.ne(excludeId != null, ContentTag::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "标签名称已存在");
}
}
private ContentTagRespVO toResp(ContentTag tag) {
ContentTagRespVO vo = new ContentTagRespVO();
vo.setId(tag.getId());
Map<String, String> locales = ContentLocaleTexts.read(
tag.getNameLocales(), tag.getName(), tag.getNameEn(), tag.getNameId());
vo.setName(locales.get(ContentLocaleTexts.ZH));
vo.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
vo.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
vo.setLocales(locales);
vo.setBgColor(tag.getBgColor() == null || tag.getBgColor().isBlank() ? "#E5484D" : tag.getBgColor());
vo.setSort(tag.getSort());
vo.setStatus(tag.getStatus());
return vo;
}
/** 按 id 批量取出标签,供短剧列表带名称和背景色。 */
public Map<Long, ContentTag> findByIds(Collection<Long> ids) {
if (ids == null || ids.isEmpty()) {
return Map.of();
}
Map<Long, ContentTag> result = new HashMap<>();
for (ContentTag tag : tagMapper.selectBatchIds(ids)) {
if (tag != null && tag.getId() != null) {
result.put(tag.getId(), tag);
}
}
return result;
}
/** 按 id 批量取出标签语言表,供短剧列表拼卡片。 */
public Map<Long, Map<String, String>> localeMap(Collection<Long> ids) {
if (ids == null || ids.isEmpty()) {
return Map.of();
}
Map<Long, Map<String, String>> result = new HashMap<>();
for (ContentTag tag : tagMapper.selectBatchIds(ids)) {
if (tag == null || tag.getId() == null) {
continue;
}
result.put(tag.getId(), ContentLocaleTexts.read(
tag.getNameLocales(), tag.getName(), tag.getNameEn(), tag.getNameId()));
}
return result;
}
}
@@ -14,6 +14,7 @@ import com.fruitshorts.content.vo.DramaCategorySaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -30,21 +31,15 @@ public class DramaCategoryService {
Map<Long, Integer> counts = countDramasByCategory();
return dramaCategoryMapper.selectList(
Wrappers.<DramaCategory>lambdaQuery().orderByAsc(DramaCategory::getSort))
.stream().map(category -> {
DramaCategoryRespVO vo = new DramaCategoryRespVO();
vo.setId(category.getId());
vo.setName(category.getName());
vo.setSort(category.getSort());
vo.setStatus(category.getStatus());
vo.setDramaCount(counts.getOrDefault(category.getId(), 0));
return vo;
}).toList();
.stream().map(category -> toResp(category, counts.getOrDefault(category.getId(), 0)))
.toList();
}
public Long create(DramaCategorySaveReqVO req) {
ensureNameAvailable(req.getName(), null);
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), null);
DramaCategory category = new DramaCategory();
category.setName(req.getName().trim());
writeNames(category, locales);
Long count = dramaCategoryMapper.selectCount(null);
category.setSort(req.getSort() == null
? (count == null ? 0 : count.intValue()) + 1
@@ -61,20 +56,14 @@ public class DramaCategoryService {
public List<DramaCategoryRespVO> listBasic() {
return dramaCategoryMapper.selectList(
Wrappers.<DramaCategory>lambdaQuery().orderByAsc(DramaCategory::getSort))
.stream().map(category -> {
DramaCategoryRespVO vo = new DramaCategoryRespVO();
vo.setId(category.getId());
vo.setName(category.getName());
vo.setSort(category.getSort());
vo.setStatus(category.getStatus());
return vo;
}).toList();
.stream().map(category -> toResp(category, null)).toList();
}
public void update(Long id, DramaCategorySaveReqVO req) {
DramaCategory category = getExisting(id);
ensureNameAvailable(req.getName(), id);
category.setName(req.getName().trim());
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), id);
writeNames(category, locales);
if (req.getSort() != null) {
category.setSort(req.getSort());
}
@@ -126,4 +115,55 @@ public class DramaCategoryService {
return dramaCategoryMapper.selectList(null).stream()
.collect(Collectors.toMap(DramaCategory::getId, DramaCategory::getName, (a, b) -> a));
}
/** 类型 id 到已选语言名称。C 端卡片按当前语言取其中一项。 */
public Map<Long, Map<String, String>> localeMap() {
Map<Long, Map<String, String>> result = new HashMap<>();
for (DramaCategory category : dramaCategoryMapper.selectList(null)) {
result.put(category.getId(), readLocales(category));
}
return result;
}
/** 一部短剧选中的类型,按保存顺序返回语言表。 */
public List<Map<String, String>> localesForDrama(Drama drama, Map<Long, Map<String, String>> localesById) {
List<Map<String, String>> result = new ArrayList<>();
for (Long id : DramaMultiTagSupport.parseCategoryIds(drama)) {
Map<String, String> locales = localesById.get(id);
if (locales != null && !locales.isEmpty()) {
result.add(locales);
}
}
return result;
}
private DramaCategoryRespVO toResp(DramaCategory category, Integer dramaCount) {
Map<String, String> locales = readLocales(category);
DramaCategoryRespVO vo = new DramaCategoryRespVO();
vo.setId(category.getId());
vo.setName(ContentLocaleTexts.value(locales, ContentLocaleTexts.ZH));
vo.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
vo.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
vo.setLocales(locales);
vo.setSort(category.getSort());
vo.setStatus(category.getStatus());
vo.setDramaCount(dramaCount);
return vo;
}
private Map<String, String> namesOf(DramaCategorySaveReqVO req) {
return ContentLocaleTexts.names(req.getLocales(), req.getName(), req.getNameEn(), req.getNameId());
}
private void writeNames(DramaCategory category, Map<String, String> locales) {
category.setName(locales.get(ContentLocaleTexts.ZH));
category.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
category.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
category.setNameLocales(ContentLocaleTexts.json(locales));
}
private Map<String, String> readLocales(DramaCategory category) {
return ContentLocaleTexts.read(
category.getNameLocales(), category.getName(), category.getNameEn(), category.getNameId());
}
}
@@ -49,7 +49,12 @@ public class DramaClassificationService {
.stream().map(item -> {
DramaClassificationRespVO vo = new DramaClassificationRespVO();
vo.setId(item.getId());
vo.setName(item.getName());
Map<String, String> locales = ContentLocaleTexts.read(
item.getNameLocales(), item.getName(), item.getNameEn(), item.getNameId());
vo.setName(locales.get(ContentLocaleTexts.ZH));
vo.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
vo.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
vo.setLocales(locales);
vo.setSort(item.getSort());
vo.setStatus(item.getStatus());
vo.setUpdatedAt(item.getUpdatedAt());
@@ -59,9 +64,10 @@ public class DramaClassificationService {
}
public Long create(DramaClassificationSaveReqVO req) {
ensureNameAvailable(req.getName(), null);
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), null);
DramaClassification entity = new DramaClassification();
entity.setName(req.getName().trim());
writeNames(entity, locales);
Long count = classificationMapper.selectCount(null);
entity.setSort(req.getSort() == null
? (count == null ? 0 : count.intValue()) + 1
@@ -73,8 +79,9 @@ public class DramaClassificationService {
public void update(Long id, DramaClassificationSaveReqVO req) {
DramaClassification entity = getExisting(id);
ensureNameAvailable(req.getName(), id);
entity.setName(req.getName().trim());
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), id);
writeNames(entity, locales);
if (req.getSort() != null) {
entity.setSort(req.getSort());
}
@@ -182,6 +189,17 @@ public class DramaClassificationService {
return entity;
}
private Map<String, String> namesOf(DramaClassificationSaveReqVO req) {
return ContentLocaleTexts.names(req.getLocales(), req.getName(), req.getNameEn(), req.getNameId());
}
private void writeNames(DramaClassification entity, Map<String, String> locales) {
entity.setName(locales.get(ContentLocaleTexts.ZH));
entity.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
entity.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
entity.setNameLocales(ContentLocaleTexts.json(locales));
}
private void ensureNameAvailable(String name, Long excludeId) {
Long count = classificationMapper.selectCount(Wrappers.<DramaClassification>lambdaQuery()
.eq(DramaClassification::getName, name.trim())
@@ -52,6 +52,11 @@ final class DramaMultiTagSupport {
drama.setCategoryIds(String.join(",", values.stream().map(String::valueOf).toList()));
return;
}
if (req.getCategoryIds() != null) {
drama.setCategoryId(0L);
drama.setCategoryIds("");
return;
}
if (drama.getCategoryId() == null) {
drama.setCategoryId(0L);
}
@@ -49,8 +49,19 @@ public class DramaPlaylistCategoryService {
.stream().map(item -> {
DramaPlaylistCategoryRespVO vo = new DramaPlaylistCategoryRespVO();
vo.setId(item.getId());
vo.setName(item.getName());
vo.setDescription(item.getDescription());
Map<String, String> locales = ContentLocaleTexts.read(
item.getNameLocales(), item.getName(), item.getNameEn(), item.getNameId());
Map<String, String> descriptions = ContentLocaleTexts.read(
item.getDescriptionLocales(), item.getDescription(),
item.getDescriptionEn(), item.getDescriptionId());
vo.setName(locales.get(ContentLocaleTexts.ZH));
vo.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
vo.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
vo.setLocales(locales);
vo.setDescription(ContentLocaleTexts.value(descriptions, ContentLocaleTexts.ZH));
vo.setDescriptionEn(ContentLocaleTexts.value(descriptions, ContentLocaleTexts.EN));
vo.setDescriptionId(ContentLocaleTexts.value(descriptions, ContentLocaleTexts.ID));
vo.setDescriptionLocales(descriptions);
vo.setSort(item.getSort());
vo.setStatus(item.getStatus());
vo.setDramaCount(counts.getOrDefault(item.getId(), 0));
@@ -59,10 +70,10 @@ public class DramaPlaylistCategoryService {
}
public Long create(DramaPlaylistCategorySaveReqVO req) {
ensureNameAvailable(req.getName(), null);
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), null);
DramaPlaylistCategory entity = new DramaPlaylistCategory();
entity.setName(req.getName().trim());
entity.setDescription(req.getDescription() == null ? "" : req.getDescription().trim());
writeText(entity, req, locales);
Long count = categoryMapper.selectCount(null);
entity.setSort(req.getSort() == null
? (count == null ? 0 : count.intValue()) + 1
@@ -74,11 +85,9 @@ public class DramaPlaylistCategoryService {
public void update(Long id, DramaPlaylistCategorySaveReqVO req) {
DramaPlaylistCategory entity = getExisting(id);
ensureNameAvailable(req.getName(), id);
entity.setName(req.getName().trim());
if (req.getDescription() != null) {
entity.setDescription(req.getDescription().trim());
}
Map<String, String> locales = namesOf(req);
ensureNameAvailable(locales.get(ContentLocaleTexts.ZH), id);
writeText(entity, req, locales);
if (req.getSort() != null) {
entity.setSort(req.getSort());
}
@@ -178,6 +187,14 @@ public class DramaPlaylistCategoryService {
return counts;
}
/** C 端用:已启用剧单,按排序升序、再按 id 倒序。 */
public List<DramaPlaylistCategory> listEnabled() {
return categoryMapper.selectList(Wrappers.<DramaPlaylistCategory>lambdaQuery()
.eq(DramaPlaylistCategory::getStatus, 1)
.orderByAsc(DramaPlaylistCategory::getSort)
.orderByDesc(DramaPlaylistCategory::getId));
}
/** C 端用:只返回已启用(status=1)的剧单。 */
public DramaPlaylistCategory getEnabledById(Long id) {
DramaPlaylistCategory entity = getExisting(id);
@@ -206,6 +223,25 @@ public class DramaPlaylistCategoryService {
return entity;
}
private Map<String, String> namesOf(DramaPlaylistCategorySaveReqVO req) {
return ContentLocaleTexts.names(req.getLocales(), req.getName(), req.getNameEn(), req.getNameId());
}
private void writeText(DramaPlaylistCategory entity, DramaPlaylistCategorySaveReqVO req,
Map<String, String> locales) {
Map<String, String> descriptions = ContentLocaleTexts.descriptions(
req.getDescriptionLocales(), locales,
req.getDescription(), req.getDescriptionEn(), req.getDescriptionId());
entity.setName(locales.get(ContentLocaleTexts.ZH));
entity.setNameEn(ContentLocaleTexts.value(locales, ContentLocaleTexts.EN));
entity.setNameId(ContentLocaleTexts.value(locales, ContentLocaleTexts.ID));
entity.setNameLocales(ContentLocaleTexts.json(locales));
entity.setDescription(ContentLocaleTexts.value(descriptions, ContentLocaleTexts.ZH));
entity.setDescriptionEn(ContentLocaleTexts.value(descriptions, ContentLocaleTexts.EN));
entity.setDescriptionId(ContentLocaleTexts.value(descriptions, ContentLocaleTexts.ID));
entity.setDescriptionLocales(ContentLocaleTexts.json(descriptions));
}
private void ensureNameAvailable(String name, Long excludeId) {
Long count = categoryMapper.selectCount(Wrappers.<DramaPlaylistCategory>lambdaQuery()
.eq(DramaPlaylistCategory::getName, name.trim())
@@ -10,9 +10,11 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.ContentTag;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.entity.DramaReviewRecord;
import com.fruitshorts.content.dal.mapper.ContentTagMapper;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
@@ -28,20 +30,27 @@ import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class DramaService {
private static final DateTimeFormatter CODE_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
/** 全部短剧单页上限,与分类频道分页一致。 */
private static final int PUBLISHED_PAGE_SIZE_MAX = 50;
private final DramaMapper dramaMapper;
private final DramaEpisodeMapper dramaEpisodeMapper;
private final DramaReviewRecordMapper dramaReviewRecordMapper;
private final DramaCategoryService dramaCategoryService;
private final ContentTagMapper contentTagMapper;
private final ContentTagService contentTagService;
private final EpisodePriceSync episodePriceSync;
public PageResult<DramaRespVO> page(DramaPageReqVO req) {
@@ -122,6 +131,11 @@ public class DramaService {
}
dramaMapper.updateById(drama);
episodePriceSync.syncByDramaRule(drama);
if (req.getTagId() != null && req.getTagId() <= 0L) {
dramaMapper.update(null, Wrappers.<Drama>lambdaUpdate()
.eq(Drama::getId, drama.getId())
.set(Drama::getTagId, null));
}
}
@Transactional(rollbackFor = Exception.class)
@@ -226,10 +240,63 @@ public class DramaService {
return dramaMapper.selectPage(page, wrapper).getRecords();
}
/** C 端全部短剧:类型、分类、标签多选。 */
public PageResult<Drama> pagePublishedLibrary(List<Long> categoryIds, List<Long> classificationIds,
List<Long> tagIds, int pageIndex, int pageSize) {
int safeIndex = Math.max(1, pageIndex);
int safeSize = Math.max(1, Math.min(pageSize, PUBLISHED_PAGE_SIZE_MAX));
Page<Drama> page = new Page<>(safeIndex, safeSize);
LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>lambdaQuery()
.eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode());
applyLibraryFilters(wrapper, categoryIds, classificationIds, tagIds);
wrapper.orderByDesc(Drama::getSort).orderByDesc(Drama::getId);
IPage<Drama> result = dramaMapper.selectPage(page, wrapper);
return new PageResult<>(result.getTotal(), result.getRecords());
}
private void applyLibraryFilters(LambdaQueryWrapper<Drama> wrapper, List<Long> categoryIds,
List<Long> classificationIds, List<Long> tagIds) {
List<Long> types = positiveIds(categoryIds);
if (!types.isEmpty()) {
DramaMultiTagSupport.applyCategoryIdTokens(wrapper, types);
}
List<Long> tags = positiveIds(tagIds);
if (!tags.isEmpty()) {
wrapper.in(Drama::getTagId, tags);
}
List<Long> classes = positiveIds(classificationIds);
if (!classes.isEmpty()) {
wrapper.apply(classificationInClause(classes.size()), classes.toArray());
}
}
/**
* 分类关联用占位符,id 不拼进 SQL。
*/
static String classificationInClause(int size) {
StringBuilder sql = new StringBuilder(
"id IN (SELECT drama_id FROM drama_classification_drama WHERE classification_id IN (");
for (int index = 0; index < size; index++) {
if (index > 0) {
sql.append(',');
}
sql.append('{').append(index).append('}');
}
sql.append("))");
return sql.toString();
}
private List<Long> positiveIds(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return List.of();
}
return ids.stream().filter(id -> id != null && id > 0L).distinct().toList();
}
/** C 端分类频道:按分类分页查已上线短剧,含 total。 */
public PageResult<Drama> pagePublishedByCategory(Long categoryId, int pageIndex, int pageSize) {
int safeIndex = Math.max(1, pageIndex);
int safeSize = Math.max(1, Math.min(pageSize, 50));
int safeSize = Math.max(1, Math.min(pageSize, PUBLISHED_PAGE_SIZE_MAX));
Page<Drama> page = new Page<>(safeIndex, safeSize);
LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>lambdaQuery()
.eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode());
@@ -369,7 +436,13 @@ public class DramaService {
* 避免编辑表单没带 sort / language / trailerUrl 时把已有数据清掉。
*/
private void apply(Drama drama, DramaSaveReqVO req, boolean isNew) {
drama.setTitle(req.getTitle());
if (req.getTitleLocales() != null) {
Map<String, String> titles = ContentLocaleTexts.titles(req.getTitleLocales(), req.getTitle());
drama.setTitle(titles.get(ContentLocaleTexts.ZH));
drama.setTitleLocales(ContentLocaleTexts.json(titles));
} else if (isNew || req.getTitle() != null) {
drama.setTitle(req.getTitle());
}
if (isNew || req.getSubtitle() != null) {
drama.setSubtitle(req.getSubtitle() == null ? "" : req.getSubtitle());
}
@@ -379,7 +452,13 @@ public class DramaService {
if (isNew || req.getGenre() != null) {
drama.setGenre(req.getGenre() == null ? "" : req.getGenre());
}
if (isNew || req.getDescription() != null) {
if (req.getSynopsisLocales() != null) {
Map<String, String> titles = ContentLocaleTexts.titles(req.getTitleLocales(), req.getTitle());
Map<String, String> synopses = ContentLocaleTexts.synopses(
req.getSynopsisLocales(), titles, req.getDescription());
drama.setSynopsis(ContentLocaleTexts.value(synopses, ContentLocaleTexts.ZH));
drama.setSynopsisLocales(ContentLocaleTexts.json(synopses));
} else if (isNew || req.getDescription() != null) {
drama.setSynopsis(req.getDescription() == null ? "" : req.getDescription());
}
if (isNew || req.getCover() != null) {
@@ -395,6 +474,10 @@ public class DramaService {
if (isNew || req.getDramaTypes() != null || req.getDramaType() != null) {
DramaMultiTagSupport.applyDramaTypes(drama, req);
}
if (isNew || req.getTagId() != null) {
contentTagService.requireEnabled(req.getTagId());
drama.setTagId(req.getTagId() != null && req.getTagId() > 0L ? req.getTagId() : null);
}
if (isNew || req.getFreeEpisodes() != null) {
drama.setFreeEpisodes(req.getFreeEpisodes() == null ? 3 : req.getFreeEpisodes());
}
@@ -420,11 +503,10 @@ public class DramaService {
return List.of();
}
Map<Long, String> categories = dramaCategoryService.nameMap();
Map<Long, ContentTag> tags = loadTags(dramas);
return dramas.stream().map(drama -> {
List<Long> categoryIds = DramaMultiTagSupport.parseCategoryIds(drama);
Long primaryCategoryId = categoryIds.isEmpty()
? (drama.getCategoryId() == null ? 0L : drama.getCategoryId())
: categoryIds.get(0);
Long primaryCategoryId = categoryIds.isEmpty() ? null : categoryIds.get(0);
List<String> categoryNames =
DramaMultiTagSupport.categoryNames(drama, categoryIds, categories);
List<String> dramaTypes = DramaMultiTagSupport.parseDramaTypes(drama);
@@ -432,19 +514,28 @@ public class DramaService {
vo.setId(drama.getId());
vo.setCode(drama.getCode());
vo.setTitle(drama.getTitle());
vo.setSubtitle(drama.getSubtitle());
Map<String, String> titles = ContentLocaleTexts.read(
drama.getTitleLocales(), drama.getTitle(), null, null);
Map<String, String> synopses = ContentLocaleTexts.read(
drama.getSynopsisLocales(), drama.getSynopsis(), null, null);
vo.setTitle(ContentLocaleTexts.value(titles, ContentLocaleTexts.ZH));
vo.setTitleLocales(titles);
vo.setCategoryId(primaryCategoryId);
vo.setCategoryIds(categoryIds);
vo.setCategoryNames(categoryNames);
vo.setCategory(categories.getOrDefault(primaryCategoryId, ""));
vo.setCategory(primaryCategoryId == null ? "" : categories.getOrDefault(primaryCategoryId, ""));
vo.setGenre(drama.getGenre());
vo.setDescription(drama.getSynopsis());
vo.setDescription(ContentLocaleTexts.value(synopses, ContentLocaleTexts.ZH));
vo.setSynopsisLocales(synopses);
vo.setSubtitle(drama.getSubtitle());
vo.setCover(drama.getCoverUrl());
vo.setTrailerUrl(drama.getTrailerUrl());
vo.setLanguage(drama.getLanguage());
vo.setDramaType(dramaTypes.get(0));
vo.setDramaTypes(dramaTypes);
vo.setEpisodes(drama.getTotalEpisodes());
fillTag(vo, drama.getTagId() == null ? null : tags.get(drama.getTagId()));
vo.setFreeEpisodes(drama.getFreeEpisodes());
vo.setUnlockCoin(drama.getUnlockCoin());
vo.setIsHot(drama.getIsHot() != null && drama.getIsHot() == 1);
@@ -480,6 +571,29 @@ public class DramaService {
}
}
private Map<Long, ContentTag> loadTags(List<Drama> dramas) {
List<Long> ids = dramas.stream()
.map(Drama::getTagId)
.filter(id -> id != null && id > 0L)
.distinct()
.toList();
if (ids.isEmpty()) {
return new HashMap<>();
}
return contentTagMapper.selectBatchIds(ids).stream()
.collect(Collectors.toMap(ContentTag::getId, Function.identity(), (left, right) -> left));
}
private void fillTag(DramaRespVO vo, ContentTag tag) {
if (tag == null) {
return;
}
vo.setTagId(tag.getId());
vo.setTagName(tag.getName());
vo.setTagNameEn(tag.getNameEn());
vo.setTagNameId(tag.getNameId());
}
private Long nonNegativeOrZero(Long value, String fieldName) {
return value == null ? 0L : nonNegative(value, fieldName);
}
@@ -0,0 +1,19 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import lombok.Data;
/** 标签列表项。 */
@Data
public class ContentTagRespVO {
private Long id;
private String name;
private String nameEn;
private String nameId;
private java.util.Map<String, String> locales;
private String bgColor;
private Integer sort;
private Integer status;
}
@@ -0,0 +1,26 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
/** 标签新增或编辑。中文必填,其余语言按 locales 勾选。 */
@Data
public class ContentTagSaveReqVO {
@Size(max = 64, message = "中文名称最多 64 个字符")
private String name;
private String nameEn;
private String nameId;
/** 勾选的语言名称,key 为 zh / en / id。 */
private java.util.Map<String, String> locales;
@Pattern(regexp = "^$|^#[0-9A-Fa-f]{6}$", message = "背景色格式应为 #RRGGBB")
private String bgColor;
private Integer sort;
private Integer status;
}
@@ -4,11 +4,16 @@ package com.fruitshorts.content.vo;
import lombok.Data;
import java.util.Map;
@Data
public class DramaCategoryRespVO {
private Long id;
private String name;
private String nameEn;
private String nameId;
private Map<String, String> locales;
private Integer sort;
private Integer status;
/** 该类型下的短剧数量 */
@@ -2,15 +2,22 @@
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.Map;
@Data
public class DramaCategorySaveReqVO {
@NotBlank(message = "请填写类型名称")
@Size(max = 64, message = "中文名称最多 64 个字符")
private String name;
private String nameEn;
private String nameId;
/** 勾选的语言名称,key 为 zh / en / id。 */
private Map<String, String> locales;
private Integer sort;
private Integer status;
}
@@ -12,6 +12,9 @@ public class DramaClassificationRespVO {
private Long id;
private String name;
private String nameEn;
private String nameId;
private java.util.Map<String, String> locales;
private Integer sort;
private Integer status;
private Integer dramaCount;
@@ -2,16 +2,21 @@
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/** 分类新增/编辑请求体。 */
@Data
public class DramaClassificationSaveReqVO {
@NotBlank(message = "请填写分类名称")
@Size(max = 64, message = "中文名称最多 64 个字符")
private String name;
private String nameEn;
private String nameId;
/** 勾选的语言名称,key 为 zh / en / id。 */
private java.util.Map<String, String> locales;
private Integer sort;
private Integer status;
}
@@ -10,7 +10,13 @@ public class DramaPlaylistCategoryRespVO {
private Long id;
private String name;
private String nameEn;
private String nameId;
private String description;
private String descriptionEn;
private String descriptionId;
private java.util.Map<String, String> locales;
private java.util.Map<String, String> descriptionLocales;
private Integer sort;
private Integer status;
/** 该分类下已关联的短剧数量 */
@@ -2,7 +2,6 @@
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@@ -10,11 +9,19 @@ import lombok.Data;
@Data
public class DramaPlaylistCategorySaveReqVO {
@NotBlank(message = "请填写剧单分类名称")
@Size(max = 64, message = "中文名称最多 64 个字符")
private String name;
@Size(max = 255, message = "描述最多 255 个字符")
private String nameEn;
private String nameId;
/** 勾选的语言名称,key 为 zh / en / id。 */
private java.util.Map<String, String> locales;
private String description;
private String descriptionEn;
private String descriptionId;
/** 勾选语言对应的描述。 */
private java.util.Map<String, String> descriptionLocales;
private Integer sort;
private Integer status;
@@ -17,6 +17,7 @@ public class DramaRespVO {
private Long id;
private String code;
private String title;
private java.util.Map<String, String> titleLocales;
private String subtitle;
private Long categoryId;
private List<Long> categoryIds;
@@ -24,12 +25,17 @@ public class DramaRespVO {
private String category;
private String genre;
private String description;
private java.util.Map<String, String> synopsisLocales;
private String cover;
/** 整剧预告片地址 */
private String trailerUrl;
private String language;
private String dramaType;
private List<String> dramaTypes;
private Long tagId;
private String tagName;
private String tagNameEn;
private String tagNameId;
private Integer episodes;
private Integer freeEpisodes;
private Integer unlockCoin;
@@ -14,6 +14,8 @@ public class DramaSaveReqVO {
@NotBlank(message = "请填写短剧名称")
private String title;
/** 勾选语言的标题,key 为 zh / en / id。 */
private java.util.Map<String, String> titleLocales;
private String subtitle;
private Long categoryId;
@@ -21,6 +23,8 @@ public class DramaSaveReqVO {
private List<Long> categoryIds;
private String genre;
private String description;
/** 勾选语言的简介。 */
private java.util.Map<String, String> synopsisLocales;
private String cover;
/** 整剧预告片地址(复用已有的 drama.trailer_url 列) */
private String trailerUrl;
@@ -29,6 +33,8 @@ public class DramaSaveReqVO {
private String dramaType;
/** 后台多选优先,创作者中心可继续只传 dramaType */
private List<String> dramaTypes;
/** 单选标签。不传则保持原值。 */
private Long tagId;
private Integer freeEpisodes;
private Integer unlockCoin;
@PositiveOrZero(message = "收藏数不能小于 0")
@@ -0,0 +1,20 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/** 分类筛选 SQL 只保留占位符,不把 id 写进语句。 */
class DramaServiceClassificationClauseTest {
@Test
void should_use_placeholders_when_classification_ids_are_bound() {
String sql = DramaService.classificationInClause(2);
assertThat(sql).isEqualTo(
"id IN (SELECT drama_id FROM drama_classification_drama WHERE classification_id IN ({0},{1}))");
assertThat(sql).doesNotContain("211");
}
}
@@ -60,7 +60,10 @@ class DramaServiceTest {
DramaCategoryService categoryService = mock(DramaCategoryService.class);
EpisodePriceSync episodePriceSync = new EpisodePriceSync(dramaEpisodeMapper);
dramaService = new DramaService(
dramaMapper, dramaEpisodeMapper, reviewRecordMapper, categoryService, episodePriceSync);
dramaMapper, dramaEpisodeMapper, reviewRecordMapper, categoryService,
mock(com.fruitshorts.content.dal.mapper.ContentTagMapper.class),
mock(com.fruitshorts.content.service.ContentTagService.class),
episodePriceSync);
episodes = new ArrayList<>();
given(dramaEpisodeMapper.selectList(any())).willAnswer(invocation -> List.copyOf(episodes));
given(dramaEpisodeMapper.updateById(any(DramaEpisode.class))).willAnswer(invocation -> {
+35
View File
@@ -0,0 +1,35 @@
-- Author: fruit.com.cn
-- 短剧类型名称改为勾选语言。中文仍在 name,英文和印尼语写入原列与 locales。
ALTER TABLE drama_category ADD COLUMN IF NOT EXISTS name_en VARCHAR(64) NOT NULL DEFAULT '';
ALTER TABLE drama_category ADD COLUMN IF NOT EXISTS name_id VARCHAR(64) NOT NULL DEFAULT '';
ALTER TABLE drama_category ADD COLUMN IF NOT EXISTS name_locales TEXT NOT NULL DEFAULT '{}';
UPDATE drama_category
SET name_locales = (
SELECT COALESCE(json_object_agg(k, v), '{}'::json)::text
FROM (
SELECT 'zh' AS k, name AS v WHERE name <> ''
UNION ALL SELECT 'en', name_en WHERE name_en <> ''
UNION ALL SELECT 'id', name_id WHERE name_id <> ''
) s
)
WHERE name_locales = '{}';
UPDATE drama_category AS c
SET name_en = v.name_en,
name_id = v.name_id,
name_locales = json_build_object('zh', c.name, 'en', v.name_en, 'id', v.name_id)::text
FROM (VALUES
(1::bigint, 'Urban Romance', 'Romansa Urban'),
(2, 'Rebirth Comeback', 'Kelahiran Kembali'),
(3, 'Sweet Romance', 'Romansa Manis'),
(4, 'Suspense Revenge', 'Balas Dendam'),
(2112443900437336065, 'Campus Action', 'Aksi Kampus'),
(2112443936592236545, 'Horror Mystery', 'Horor Misteri'),
(2112444003856289794, 'Infinite Travel', 'Perjalanan Tanpa Batas'),
(2112444050467590146, 'Thriller Horror', 'Thriller Horor'),
(2112444103433261057, 'Indonesia Exclusive', 'Eksklusif Indonesia')
) AS v(id, name_en, name_id)
WHERE c.id = v.id
AND (c.name_en = '' OR c.name_id = '');
@@ -0,0 +1,10 @@
-- Author: fruit.com.cn
-- 首页运营分类名称补英文、印尼语。原 name 继续表示中文。
ALTER TABLE drama_classification
ADD COLUMN IF NOT EXISTS name_en VARCHAR(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS name_id VARCHAR(64) NOT NULL DEFAULT '';
COMMENT ON COLUMN drama_classification.name IS '中文名称';
COMMENT ON COLUMN drama_classification.name_en IS '英文名称';
COMMENT ON COLUMN drama_classification.name_id IS '印尼语名称';
+54
View File
@@ -0,0 +1,54 @@
-- Author: fruit.com.cn
-- 标签管理:三种语言名称。短剧单选一个标签。
CREATE TABLE IF NOT EXISTS content_tag (
id BIGINT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
name_en VARCHAR(64) NOT NULL DEFAULT '',
name_id VARCHAR(64) NOT NULL DEFAULT '',
sort INTEGER NOT NULL DEFAULT 0,
status SMALLINT NOT NULL DEFAULT 1,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_content_tag_name ON content_tag (name);
DROP TRIGGER IF EXISTS trg_content_tag_updated_at ON content_tag;
CREATE TRIGGER trg_content_tag_updated_at
BEFORE UPDATE ON content_tag
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
ALTER TABLE drama ADD COLUMN IF NOT EXISTS tag_id BIGINT;
CREATE INDEX IF NOT EXISTS idx_drama_tag ON drama (tag_id);
INSERT INTO content_tag (id, name, name_en, name_id, sort, status)
VALUES
(2113500000000000001, '漫剧', 'Anime', 'Anime', 1, 1),
(2113500000000000002, '真人剧', 'Live Action', 'Live Action', 2, 1)
ON CONFLICT (id) DO NOTHING;
UPDATE drama SET tag_id = 2113500000000000001
WHERE drama_type = 'ANIME' AND tag_id IS NULL;
UPDATE drama SET tag_id = 2113500000000000002
WHERE (drama_type IS NULL OR drama_type = 'LIVE') AND tag_id IS NULL;
INSERT INTO sys_menu (
id, parent_id, title, route_path, route_name, component, icon,
permission_code, type, sort, visible, status, super_admin_visible
) VALUES (
45, 3, '标签管理', 'content-tags', 'content-tags',
'views/contentManagement/TagManagementView', 'ep:price-tag',
'drama:list', 'MENU', 7, 1, 1, 1
) ON CONFLICT (id) DO NOTHING;
INSERT INTO sys_role_menu (id, role_id, menu_id)
SELECT v.id, v.role_id, 45
FROM (VALUES
(2113600000000000001, 1),
(2113600000000000002, 2),
(2113600000000000003, 3),
(2113600000000000004, 4)
) AS v(id, role_id)
WHERE NOT EXISTS (
SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = v.role_id AND rm.menu_id = 45
);
@@ -0,0 +1,13 @@
-- Author: fruit.com.cn
-- 短剧标题和简介按勾选语言保存。中文仍写 title / synopsis。
ALTER TABLE drama ADD COLUMN IF NOT EXISTS title_locales TEXT NOT NULL DEFAULT '{}';
ALTER TABLE drama ADD COLUMN IF NOT EXISTS synopsis_locales TEXT NOT NULL DEFAULT '{}';
UPDATE drama
SET title_locales = json_build_object('zh', title)::text
WHERE title_locales = '{}' AND title <> '';
UPDATE drama
SET synopsis_locales = json_build_object('zh', synopsis)::text
WHERE synopsis_locales = '{}' AND synopsis IS NOT NULL AND synopsis <> '';
+51
View File
@@ -0,0 +1,51 @@
-- Author: fruit.com.cn
-- 名称语言改为勾选保存。中文、英文、印尼语仍写原列,勾选结果记在 locales 文本里。
ALTER TABLE content_tag ADD COLUMN IF NOT EXISTS name_locales TEXT NOT NULL DEFAULT '{}';
ALTER TABLE drama_classification ADD COLUMN IF NOT EXISTS name_locales TEXT NOT NULL DEFAULT '{}';
ALTER TABLE drama_playlist_category ADD COLUMN IF NOT EXISTS name_locales TEXT NOT NULL DEFAULT '{}';
ALTER TABLE drama_playlist_category ADD COLUMN IF NOT EXISTS description_locales TEXT NOT NULL DEFAULT '{}';
UPDATE content_tag
SET name_locales = (
SELECT COALESCE(json_object_agg(k, v), '{}'::json)::text
FROM (
SELECT 'zh' AS k, name AS v WHERE name <> ''
UNION ALL SELECT 'en', name_en WHERE name_en <> ''
UNION ALL SELECT 'id', name_id WHERE name_id <> ''
) s
)
WHERE name_locales = '{}';
UPDATE drama_classification
SET name_locales = (
SELECT COALESCE(json_object_agg(k, v), '{}'::json)::text
FROM (
SELECT 'zh' AS k, name AS v WHERE name <> ''
UNION ALL SELECT 'en', name_en WHERE name_en <> ''
UNION ALL SELECT 'id', name_id WHERE name_id <> ''
) s
)
WHERE name_locales = '{}';
UPDATE drama_playlist_category
SET name_locales = (
SELECT COALESCE(json_object_agg(k, v), '{}'::json)::text
FROM (
SELECT 'zh' AS k, name AS v WHERE name <> ''
UNION ALL SELECT 'en', name_en WHERE name_en <> ''
UNION ALL SELECT 'id', name_id WHERE name_id <> ''
) s
)
WHERE name_locales = '{}';
UPDATE drama_playlist_category
SET description_locales = (
SELECT COALESCE(json_object_agg(k, v), '{}'::json)::text
FROM (
SELECT 'zh' AS k, description AS v WHERE description <> ''
UNION ALL SELECT 'en', description_en WHERE description_en <> ''
UNION ALL SELECT 'id', description_id WHERE description_id <> ''
) s
)
WHERE description_locales = '{}';
+15
View File
@@ -0,0 +1,15 @@
-- Author: fruit.com.cn
-- 剧单分类名称和描述补英文、印尼语。原 name/description 继续表示中文。
ALTER TABLE drama_playlist_category
ADD COLUMN IF NOT EXISTS name_en VARCHAR(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS name_id VARCHAR(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS description_en VARCHAR(255) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS description_id VARCHAR(255) NOT NULL DEFAULT '';
COMMENT ON COLUMN drama_playlist_category.name IS '中文名称';
COMMENT ON COLUMN drama_playlist_category.name_en IS '英文名称';
COMMENT ON COLUMN drama_playlist_category.name_id IS '印尼语名称';
COMMENT ON COLUMN drama_playlist_category.description IS '中文描述';
COMMENT ON COLUMN drama_playlist_category.description_en IS '英文描述';
COMMENT ON COLUMN drama_playlist_category.description_id IS '印尼语描述';
+7
View File
@@ -0,0 +1,7 @@
-- Author: fruit.com.cn
-- 标签角标背景色。客户端按 bg_color 绘制封面右上角标签。
ALTER TABLE content_tag ADD COLUMN IF NOT EXISTS bg_color VARCHAR(7) NOT NULL DEFAULT '#E5484D';
UPDATE content_tag SET bg_color = '#E5484D' WHERE name = '热剧' AND (bg_color = '' OR bg_color = '#E5484D');
UPDATE content_tag SET bg_color = '#3B82F6' WHERE name = '新剧';