diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AppVersionAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AppVersionAdminController.java index 434c421..165b4e2 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AppVersionAdminController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AppVersionAdminController.java @@ -12,6 +12,8 @@ import com.fruitshorts.system.vo.AppVersionSaveReqVO; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; @@ -56,6 +58,17 @@ public class AppVersionAdminController { return Result.ok(appVersionService.create(req)); } + @Operation(summary = "批量新建版本(一次事务)") + @PostMapping("/batch") + @PreAuthorize("@ss.hasPermission('app-version:create')") + @OperationLog(module = "版本管理", action = "批量新建版本") + public Result> batchCreate( + @NotEmpty(message = "请至少传一条") + @Size(max = 16, message = "单次不超过 16 条") + @Valid @RequestBody java.util.List<@Valid AppVersionSaveReqVO> reqs) { + return Result.ok(appVersionService.batchCreate(reqs)); + } + @Operation(summary = "编辑版本") @PutMapping("/{id}") @PreAuthorize("@ss.hasPermission('app-version:update')") diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java index 77fbee7..11f9b6a 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java @@ -6,12 +6,15 @@ import com.fruitshorts.app.service.AppCatalogService; import com.fruitshorts.app.vo.AppCategoryVO; import com.fruitshorts.app.vo.AppFeedVO; import com.fruitshorts.app.vo.AppDramaCardVO; +import com.fruitshorts.app.vo.AppPlaylistDetailVO; import com.fruitshorts.common.pojo.CursorResult; +import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.common.pojo.Result; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -56,4 +59,19 @@ public class AppCatalogController { public Result> categories() { return Result.ok(appCatalogService.categories()); } + + @Operation(summary = "分类频道:按分类 ID 分页查短剧卡片") + @GetMapping("/categories/{id}/dramas") + public Result> categoryDramas( + @PathVariable Long id, + @RequestParam(defaultValue = "1") int pageIndex, + @RequestParam(defaultValue = "20") int pageSize) { + return Result.ok(appCatalogService.categoryDramas(id, pageIndex, pageSize)); + } + + @Operation(summary = "剧单详情:剧单基本信息 + 该剧单下已上线短剧卡片") + @GetMapping("/playlists/{id}") + public Result playlistDetail(@PathVariable Long id) { + return Result.ok(appCatalogService.playlistDetail(id)); + } } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java index c73a5b3..580747e 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java @@ -8,14 +8,18 @@ import com.fruitshorts.app.vo.AppDramaCardVO; import com.fruitshorts.app.vo.AppDramaDetailVO; import com.fruitshorts.app.vo.AppEpisodeVO; import com.fruitshorts.app.vo.AppFeedVO; +import com.fruitshorts.app.vo.AppPlaylistDetailVO; import com.fruitshorts.app.vo.AppSectionVO; import com.fruitshorts.common.pojo.CursorResult; +import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.content.dal.entity.ContentBanner; import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.dal.entity.DramaEpisode; import com.fruitshorts.content.enums.BannerPosition; import com.fruitshorts.content.service.BannerService; import com.fruitshorts.content.service.DramaCategoryService; +import com.fruitshorts.content.service.DramaClassificationService; +import com.fruitshorts.content.service.DramaPlaylistCategoryService; import com.fruitshorts.content.service.DramaService; import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.interaction.service.CommentService; @@ -49,6 +53,8 @@ public class AppCatalogService { private final EpisodeService episodeService; private final BannerService bannerService; private final DramaCategoryService dramaCategoryService; + private final DramaClassificationService dramaClassificationService; + private final DramaPlaylistCategoryService dramaPlaylistCategoryService; private final PlaybackSigner playbackSigner; private final CommentService commentService; private final ThumbnailUrlResolver thumbnailUrlResolver; @@ -69,13 +75,42 @@ public class AppCatalogService { categorySections(DISCOVER_SECTION_SIZE, categoryNameMap())); } + /** 分类频道:运营自定义的精选/新剧/榜单等(drama_classification)。 */ public List categories() { - return dramaCategoryService.listBasic().stream() - .filter(category -> category.getStatus() == null || category.getStatus() == 1) - .map(category -> new AppCategoryVO(category.getId(), category.getName())) + return dramaClassificationService.listEnabled().stream() + .map(c -> new AppCategoryVO(c.getId(), c.getName())) .toList(); } + /** 分类频道:按分类分页查已上线短剧卡片(通过分类关联表过滤)。 */ + public PageResult categoryDramas(Long classificationId, + int pageIndex, int pageSize) { + dramaClassificationService.getEnabledById(classificationId); // 校验存在/启用 + List allIds = dramaClassificationService.listDramaIdsByClassification(classificationId); + if (allIds.isEmpty()) { + return PageResult.empty(); + } + // 只取已上线的,再按关联表原始顺序排 + Map orderMap = new HashMap<>(); + for (int i = 0; i < allIds.size(); i++) orderMap.put(allIds.get(i), i); + List published = dramaService.listPublishedByIds(allIds); + List ordered = published.stream() + .sorted((a, b) -> Integer.compare( + orderMap.getOrDefault(a.getId(), Integer.MAX_VALUE), + orderMap.getOrDefault(b.getId(), Integer.MAX_VALUE))) + .toList(); + int safeIndex = Math.max(1, pageIndex); + int safeSize = Math.max(1, Math.min(pageSize, 50)); + long total = ordered.size(); + int fromIndex = (safeIndex - 1) * safeSize; + if (fromIndex >= ordered.size()) { + return new PageResult<>(total, List.of()); + } + int toIndex = Math.min(fromIndex + safeSize, ordered.size()); + List pageRows = ordered.subList(fromIndex, toIndex); + return new PageResult<>(total, cards(pageRows, categoryNameMap())); + } + public CursorResult feed( String types, Long categoryId, Long cursor, Integer size) { int pageSize = size == null ? DEFAULT_FEED_SIZE : Math.max(1, Math.min(size, MAX_FEED_SIZE)); @@ -137,6 +172,33 @@ public class AppCatalogService { return detail; } + /** C 端剧单详情:剧单基本信息 + 该剧单下所有已上线短剧卡片。 */ + public AppPlaylistDetailVO playlistDetail(Long playlistId) { + var category = dramaPlaylistCategoryService.getEnabledById(playlistId); + List allIds = dramaPlaylistCategoryService.listDramaIdsByCategory(playlistId); + List published = dramaService.listPublishedByIds(allIds); + // 保留关联表原始顺序:按 allIds 位置重新排序 + Map orderMap = new HashMap<>(); + for (int i = 0; i < allIds.size(); i++) { + orderMap.put(allIds.get(i), i); + } + List ordered = published.stream() + .sorted((a, b) -> { + Integer ia = orderMap.getOrDefault(a.getId(), Integer.MAX_VALUE); + Integer ib = orderMap.getOrDefault(b.getId(), Integer.MAX_VALUE); + return Integer.compare(ia, ib); + }) + .toList(); + + AppPlaylistDetailVO vo = new AppPlaylistDetailVO(); + vo.setId(category.getId()); + vo.setName(category.getName()); + vo.setDescription(category.getDescription()); + vo.setDramaCount(ordered.size()); + vo.setList(cards(ordered, categoryNameMap())); + return vo; + } + private List banners(String position) { return bannerService.listActive(position).stream().map(this::toBanner).toList(); } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppPlaylistDetailVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppPlaylistDetailVO.java new file mode 100644 index 0000000..acc89b6 --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppPlaylistDetailVO.java @@ -0,0 +1,19 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.vo; + +import lombok.Data; + +import java.util.List; + +/** C 端剧单详情:基本信息 + 该剧单下已上线的短剧卡片。 */ +@Data +public class AppPlaylistDetailVO { + + private Long id; + private String name; + private String description; + /** 该剧单下已上线短剧的实际条数(被下架/下线的不计入) */ + private Integer dramaCount; + private List list; +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java index 5e147de..745bc2d 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java @@ -8,6 +8,8 @@ import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.dal.entity.DramaEpisode; import com.fruitshorts.content.service.BannerService; import com.fruitshorts.content.service.DramaCategoryService; +import com.fruitshorts.content.service.DramaClassificationService; +import com.fruitshorts.content.service.DramaPlaylistCategoryService; import com.fruitshorts.content.service.DramaService; import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.interaction.service.CommentService; @@ -45,12 +47,15 @@ class AppCatalogServiceTest { episodeService = mock(EpisodeService.class); BannerService bannerService = mock(BannerService.class); dramaCategoryService = mock(DramaCategoryService.class); + DramaClassificationService dramaClassificationService = mock(DramaClassificationService.class); + DramaPlaylistCategoryService dramaPlaylistCategoryService = mock(DramaPlaylistCategoryService.class); PlaybackSigner playbackSigner = mock(PlaybackSigner.class); thumbnailUrlResolver = mock(ThumbnailUrlResolver.class); given(thumbnailUrlResolver.resolveAll(any())).willReturn(Map.of()); commentService = mock(CommentService.class); catalogService = new AppCatalogService(dramaService, episodeService, bannerService, - dramaCategoryService, playbackSigner, commentService, thumbnailUrlResolver); + dramaCategoryService, dramaClassificationService, dramaPlaylistCategoryService, + playbackSigner, commentService, thumbnailUrlResolver); } @Test diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java index 1c1f523..6422ebe 100644 --- a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java @@ -38,6 +38,7 @@ public class SecurityConfig { "/app/discover/**", "/app/feed", "/app/categories/**", + "/app/playlists/**", "/app/banners/**", "/app/dramas/**", "/app/search", 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 96f2df4..31a43ef 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 @@ -27,6 +27,7 @@ import org.springframework.util.StringUtils; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * 运营分类服务。分类是独立于「短剧类型(drama_category)」的一套概念, @@ -190,6 +191,37 @@ public class DramaClassificationService { } } + // ========== C 端只读视图 ========== + + /** C 端:只返回已启用(status=1)的分类,按 sort 升序。 */ + public List listEnabled() { + return classificationMapper.selectList(Wrappers.lambdaQuery() + .eq(DramaClassification::getStatus, 1) + .orderByAsc(DramaClassification::getSort) + .orderByAsc(DramaClassification::getId)); + } + + /** C 端:校验分类存在且启用。 */ + public DramaClassification getEnabledById(Long id) { + DramaClassification entity = getExisting(id); + if (entity.getStatus() != 1) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "分类不存在或已下架"); + } + return entity; + } + + /** C 端:分类下关联的全部 dramaId(按关联创建时间 desc)。 */ + public List listDramaIdsByClassification(Long classificationId) { + return classificationDramaMapper.selectList( + Wrappers.lambdaQuery() + .eq(DramaClassificationDrama::getClassificationId, classificationId) + .orderByDesc(DramaClassificationDrama::getCreatedAt) + .orderByDesc(DramaClassificationDrama::getId)) + .stream() + .map(DramaClassificationDrama::getDramaId) + .collect(Collectors.toList()); + } + private DramaBriefVO toBrief(Drama drama) { DramaBriefVO vo = new DramaBriefVO(); vo.setId(drama.getId()); 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 7507847..9d2e676 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 @@ -27,6 +27,7 @@ import org.springframework.util.StringUtils; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * 剧单分类服务:分类直接关联短剧,没有「剧单」这一层。 @@ -177,6 +178,26 @@ public class DramaPlaylistCategoryService { return counts; } + /** C 端用:只返回已启用(status=1)的剧单。 */ + public DramaPlaylistCategory getEnabledById(Long id) { + DramaPlaylistCategory entity = getExisting(id); + if (entity.getStatus() == null || entity.getStatus() != 1) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "剧单不存在或已下架"); + } + return entity; + } + + /** C 端用:剧单下关联的全部 dramaId(按 created_at desc)。 */ + public List listDramaIdsByCategory(Long categoryId) { + return categoryDramaMapper.selectList(Wrappers.lambdaQuery() + .eq(DramaPlaylistCategoryDrama::getCategoryId, categoryId) + .orderByDesc(DramaPlaylistCategoryDrama::getCreatedAt) + .orderByDesc(DramaPlaylistCategoryDrama::getId)) + .stream() + .map(DramaPlaylistCategoryDrama::getDramaId) + .collect(Collectors.toList()); + } + private DramaPlaylistCategory getExisting(Long id) { DramaPlaylistCategory entity = id == null ? null : categoryMapper.selectById(id); if (entity == null) { 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 01f0974..1c01677 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 @@ -226,6 +226,22 @@ public class DramaService { return dramaMapper.selectPage(page, wrapper).getRecords(); } + /** 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)); + Page page = new Page<>(safeIndex, safeSize); + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() + .eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode()); + if (categoryId != null && categoryId > 0) { + DramaMultiTagSupport.applyCategoryIdTokens(wrapper, List.of(categoryId)); + } + wrapper.orderByDesc(Drama::getSort).orderByDesc(Drama::getViewCount) + .orderByDesc(Drama::getId); + IPage result = dramaMapper.selectPage(page, wrapper); + return new PageResult<>(result.getTotal(), result.getRecords()); + } + /** 已上线短剧,按上线时间倒序 */ public List listPublishedLatest(long limit) { Page page = new Page<>(1, Math.max(1, limit)); diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AppVersionService.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AppVersionService.java index 1f4172a..0f2a775 100644 --- a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AppVersionService.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AppVersionService.java @@ -64,6 +64,30 @@ public class AppVersionService { return v.getId(); } + /** 批量新建:一次事务全插或全回滚。 */ + @Transactional(rollbackFor = Exception.class) + public List batchCreate(List reqs) { + if (reqs == null || reqs.isEmpty()) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "请至少传一条"); + } + if (reqs.size() > 16) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "单次不超过 16 条"); + } + LocalDateTime now = LocalDateTime.now(); + List ids = new java.util.ArrayList<>(); + for (AppVersionSaveReqVO req : reqs) { + String platform = normalizePlatform(req.getPlatform()); + checkDuplicate(platform, req.getVersionCode(), null); + AppVersion v = new AppVersion(); + applyFields(v, req, platform); + v.setStatus(1); + v.setReleasedAt(now); + versionMapper.insert(v); + ids.add(v.getId()); + } + return ids; + } + @Transactional(rollbackFor = Exception.class) public void update(Long id, AppVersionSaveReqVO req) { String platform = normalizePlatform(req.getPlatform()); diff --git a/sql/patch-2026-10-23-app-version-fix.sql b/sql/patch-2026-10-23-app-version-fix.sql new file mode 100644 index 0000000..17e5a4f --- /dev/null +++ b/sql/patch-2026-10-23-app-version-fix.sql @@ -0,0 +1,32 @@ +-- Author: fruit.com.cn +-- 修正 App 版本管理的菜单与权限: +-- 1) 删除误加的 BUTTON 类型菜单 40-44(项目其他模块都只在 permission_code 里挂权限点, +-- 不在 sys_menu 里建按钮行,否则前端菜单渲染会多出无用条目) +-- 2) 在 sys_permission 里加 app-version 完整权限点 +-- 3) 给超级管理员(role_id=1)授权这些权限点 + +-- 1) 删除多余的 BUTTON 菜单 +DELETE FROM sys_role_menu WHERE menu_id IN (40, 41, 42, 43, 44); +DELETE FROM sys_menu WHERE id IN (40, 41, 42, 43, 44); + +-- 2) 权限点(code 唯一约束兜底) +INSERT INTO sys_permission (id, code, name, group_name, type, sort) VALUES + (50000101, 'app-version:list', '版本管理-查看', 'App 版本', 'BUTTON', 1), + (50000102, 'app-version:create', '版本管理-新增', 'App 版本', 'BUTTON', 2), + (50000103, 'app-version:update', '版本管理-编辑', 'App 版本', 'BUTTON', 3), + (50000104, 'app-version:delete', '版本管理-删除', 'App 版本', 'BUTTON', 4) +ON CONFLICT (code) DO NOTHING; + +-- 3) 超管授权(id 按现有 MAX+1 顺延) +INSERT INTO sys_role_permission (id, role_id, permission_id) +SELECT 50001, 1, p.id FROM sys_permission p WHERE p.code = 'app-version:list' +ON CONFLICT DO NOTHING; +INSERT INTO sys_role_permission (id, role_id, permission_id) +SELECT 50002, 1, p.id FROM sys_permission p WHERE p.code = 'app-version:create' +ON CONFLICT DO NOTHING; +INSERT INTO sys_role_permission (id, role_id, permission_id) +SELECT 50003, 1, p.id FROM sys_permission p WHERE p.code = 'app-version:update' +ON CONFLICT DO NOTHING; +INSERT INTO sys_role_permission (id, role_id, permission_id) +SELECT 50004, 1, p.id FROM sys_permission p WHERE p.code = 'app-version:delete' +ON CONFLICT DO NOTHING;