merge: 合并 K89 分集点赞与评论统计

This commit is contained in:
Zhou Weidong committed 2026-10-21 15:56:30 +08:00
commit dfc978c620
28 files changed
+683 -41

No files matched your search

+15 -8
View File
@@ -175,9 +175,10 @@ app.register.max-per-device-per-day
> 卡片(`AppDramaCardVO`)统一新增 `language` 字段(取值如 `zh-CN` / `id`),
> 首页、发现页、分类、收藏、搜索等所有下发卡片的场景都带该字段,客户端可据此做语言筛选。
>
> 卡片同时下发 `likeCount` / `favoriteCount` / `commentCount`(均为 JSON 字符串),
> 点赞、收藏取 `drama` 冗余计数字段,评论数由 `content_comment` 按 `drama_id` 批量统计,
> 排除逻辑删除与 `status = 'deleted'` 的记录。首页、发现、分类、收藏、搜索等列表卡片结构一致。
> 卡片同时下发 `likeCount` / `favoriteCount` / `commentCount`(均为 JSON 字符串)。
> `favoriteCount` 取整部剧;`likeCount` / `commentCount` 取卡片 `preview` 当前集的
> `drama_episode.like_count` 与 `content_comment.episode_id` 统计(排除逻辑删除与 `status = 'deleted'`);
> `viewCount` 热度仍取整部剧。首页、发现、分类、收藏、搜索等列表卡片结构一致。
>
> 卡片新增 `dramaType`:`ANIME` 表示漫剧,`LIVE` 表示真人剧。
@@ -218,6 +219,8 @@ app.register.max-per-device-per-day
"isFree": true,
"locked": false,
"unlockCoin": 0,
"likeCount": "45",
"commentCount": "8",
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c..."
}
]
@@ -225,14 +228,16 @@ app.register.max-per-device-per-day
}
```
详情里的互动数量(K30 新增,供播放页右侧收藏/评论/点赞直接展示):
详情里的互动数量:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `likeCount` | Long(JSON 字符串) | 取 `drama.like_count`,短剧累计点赞数 |
| `likeCount` | Long(JSON 字符串) | 剧级历史汇总,保留兼容;新客户端按集使用 `episodes[].likeCount` |
| `favoriteCount` | Long(JSON 字符串) | 取 `drama.favorite_count`,短剧累计收藏数 |
| `commentCount` | Long(JSON 字符串) | `content_comment` 中该剧未删除评论数:按 `drama_id` 统计,排除 `status = 'deleted'` 与逻辑删除记录 |
| `viewCount` | Long(JSON 字符串) | 取 `drama.view_count`,累计播放量,既有字段语义不变 |
| `episodes[].likeCount` | Long(JSON 字符串) | 本集点赞数,取 `drama_episode.like_count` |
| `episodes[].commentCount` | Long(JSON 字符串) | 本集未删除评论数,按 `episode_id` 统计 |
计数是只读快照:互动操作完成后客户端重新拉取详情即可刷新;`Long` 字段一律按字符串下发,
客户端需要转成数字再展示(与 `viewCount` 相同)。
@@ -253,7 +258,9 @@ app.register.max-per-device-per-day
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...",
"qualities": ["AUTO"],
"unlockCoin": 0,
"previewSeconds": 0
"previewSeconds": 0,
"likeCount": "45",
"commentCount": "8"
}
}
```
@@ -307,9 +314,9 @@ app.register.max-per-device-per-day
| --- | --- | --- | --- |
| POST | `/app/comments` | 登录 | 发表评论/回复:过敏感词后按策略发布或转待审 |
| GET | `/app/comments?dramaId=&cursor=&size=` | 匿名 | 游标翻页;不传 `rootId` 返回顶层评论,传了返回该楼层回复 |
| GET | `/app/comments/count?dramaId=` | 匿名 | 返回该剧未删除评论总数,`Long` 按字符串下发 |
| GET | `/app/comments/count?dramaId=&episodeId=` | 匿名 | 传 `episodeId` 返回该集评论数;仅传 `dramaId` 返回全剧兼容汇总 |
| DELETE | `/app/comments/{id}` | 登录 | 删除自己的评论(逻辑置为 `deleted`) |
| POST | `/app/likes/toggle` | 登录 | 点赞/取消点赞,`targetType` 支持 `DRAMA` / `COMMENT` |
| POST | `/app/likes/toggle` | 登录 | 点赞/取消点赞,`targetType` 支持 `DRAMA` / `EPISODE` / `COMMENT`;新客户端点赞按集 |
| POST | `/app/reports` | 登录 | 举报评论/用户/短剧;同一目标只保留一条待处理工单 |
**敏感词三档处置**:`BLOCK` 直接拒绝并返回原因、`REPLACE` 替换后放行、`REVIEW` 转人工待审。
+59
View File
@@ -0,0 +1,59 @@
# K89 点赞/评论按集、收藏/热度按剧实施计划
## 目标
- 收藏、热度继续归属整部剧。
- 点赞、评论归属具体剧集:新增 `drama_episode.like_count`,`content_like` 使用 `EPISODE` 目标。
- App 卡片、详情、播放响应按当前集装配点赞/评论;后台短剧/剧集页提供对应汇总与只读展示。
## FruitServer A
1. 数据库
- 新增 `sql/migrate-k89-episode-like.sql`,只提交不执行。
- `sql/schema-phase1.sql` 的 drama_episode 增量字段补 like_count,默认 0。
2. 分集点赞
- `DramaEpisode` 增加 likeCount;`EpisodeService` 提供原子增减与批量 SUM。
- `LikeService` 增加 `TYPE_EPISODE`,校验已上线剧集的已上线分集,切换时同步分集计数,返回该集最新值。
- 保留 DRAMA/COMMENT 点赞兼容。
3. 分集评论
- `CommentService.countByEpisodes` 按 episode_id 批量分组统计未删除评论。
- `/app/comments/count` 增加可选 episodeId;传集数返回该集,只有 dramaId 时维持全剧兼容语义。
4. App 装配
- 卡片 preview 集的 likeCount/commentCount 写入卡片;favoriteCount/viewCount 仍取剧。
- 详情 episodes[] 每项增加 likeCount/commentCount。
- 播放响应增加 episodeId 对应的 likeCount/commentCount。
5. 后台
- EpisodeRespVO 增加本集 like/comment 与所属剧 favorite/heat;Controller.list 批量回填。
- EpisodeSaveReqVO 增加可空 likeCount,update 仅覆盖非 null,负数 BAD_REQUEST。
- DramaRespVO likeCount 改为分集 SUM,commentCount 保持全剧汇总;分页一次批量聚合避免 N+1。
- 更新 API 契约。
6. 测试
- EPISODE 点赞 +1/-1、不越界、目标不存在。
- App 卡片按 preview 集、剧级 favorite/heat。
- 后台分集列表计数与剧级收藏/热度。
- 短剧分页 likeCount 为分集汇总。
## ReelShortWeb B
1. EpisodeItem / EpisodeSaveData 增加对应互动字段。
2. 剧集表格增加本集点赞、评论,以及所属剧收藏、热度列。
3. 编辑弹窗增加本集点赞输入和评论只读;“按集实时统计”。
4. 短剧管理保持收藏/热度可编辑,点赞/评论展示为后端只读汇总。
## 风险与处理
- 存量 `drama.like_count` 保留不删,不回填分集;新展示以分集 SUM 为准。
- App 评论数按 preview 集统计,只有 episodeId 为空的历史评论仍计入全剧汇总。
- 评论/点赞接口保持匿名/登录既有安全语义,不新增第三方依赖。
- 迁移脚本由 PM 执行;本地门禁不访问真实数据库。
## 门禁与 Git
- FruitServer:`mvn test` 全过;提交 `feat(interaction): 点赞与评论按集统计`;`--no-ff` 合回 develop 并 push。
- ReelShortWeb:`pnpm ts:check`、`pnpm build:dev` 通过;提交 `feat(admin): 剧集列表展示分集互动数据`;`--no-ff` 合回 develop 并 push。
- 作者统一 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`;各自 PROGRESS 追加 K89 留痕。
@@ -6,6 +6,7 @@ import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.dal.entity.DramaReviewRecord;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.content.vo.DramaPageReqVO;
import com.fruitshorts.content.vo.DramaRespVO;
import com.fruitshorts.content.vo.DramaSaveReqVO;
@@ -38,13 +39,14 @@ public class DramaController {
private final DramaService dramaService;
private final CommentService commentService;
private final EpisodeService episodeService;
@Operation(summary = "短剧分页查询")
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<PageResult<DramaRespVO>> page(DramaPageReqVO req) {
PageResult<DramaRespVO> page = dramaService.page(req);
fillCommentCounts(page.getList());
fillInteractionCounts(page.getList());
return Result.ok(page);
}
@@ -53,7 +55,7 @@ public class DramaController {
@PreAuthorize("@ss.hasPermission('drama:list')")
public Result<DramaRespVO> get(@PathVariable Long id) {
DramaRespVO drama = dramaService.get(id);
fillCommentCounts(List.of(drama));
fillInteractionCounts(List.of(drama));
return Result.ok(drama);
}
@@ -141,12 +143,16 @@ public class DramaController {
SecurityUtils.getUserId(), SecurityUtils.getUsername());
}
private void fillCommentCounts(List<DramaRespVO> dramas) {
private void fillInteractionCounts(List<DramaRespVO> dramas) {
if (dramas == null || dramas.isEmpty()) {
return;
}
List<Long> ids = dramas.stream().map(DramaRespVO::getId).toList();
Map<Long, Long> counts = commentService.countByDramas(ids);
dramas.forEach(drama -> drama.setCommentCount(counts.getOrDefault(drama.getId(), 0L)));
Map<Long, Long> likeCounts = episodeService.sumLikeCountsByDramas(ids);
Map<Long, Long> commentCounts = commentService.countByDramas(ids);
dramas.forEach(drama -> {
drama.setLikeCount(likeCounts.getOrDefault(drama.getId(), 0L));
drama.setCommentCount(commentCounts.getOrDefault(drama.getId(), 0L));
});
}
}
@@ -3,11 +3,14 @@
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.content.vo.EpisodeRespVO;
import com.fruitshorts.content.vo.EpisodeSaveReqVO;
import com.fruitshorts.content.vo.EpisodeSortReqVO;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.interaction.service.CommentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@@ -24,6 +27,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@Tag(name = "后台-剧集管理")
@RestController
@@ -32,13 +36,24 @@ import java.util.List;
public class EpisodeController {
private final EpisodeService episodeService;
private final CommentService commentService;
private final DramaService dramaService;
@Operation(summary = "剧集列表")
@GetMapping
@PreAuthorize("@ss.hasPermission('episode:list')")
public Result<List<EpisodeRespVO>> list(@RequestParam Long dramaId,
@RequestParam(required = false) String status) {
return Result.ok(episodeService.listByDrama(dramaId, status));
List<EpisodeRespVO> episodes = episodeService.listByDrama(dramaId, status);
Map<Long, Long> commentCounts = commentService.countByEpisodes(
episodes.stream().map(EpisodeRespVO::getId).toList());
Drama drama = dramaService.getExisting(dramaId);
episodes.forEach(episode -> {
episode.setCommentCount(commentCounts.getOrDefault(episode.getId(), 0L));
episode.setFavoriteCount(drama.getFavoriteCount());
episode.setHeat(drama.getViewCount());
});
return Result.ok(episodes);
}
@Operation(summary = "新建剧集")
@@ -5,6 +5,7 @@ package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.content.vo.DramaPageReqVO;
import com.fruitshorts.content.vo.DramaRespVO;
import com.fruitshorts.interaction.service.CommentService;
@@ -28,14 +29,16 @@ class DramaControllerContractTest {
private DramaService dramaService;
private CommentService commentService;
private EpisodeService episodeService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
dramaService = mock(DramaService.class);
commentService = mock(CommentService.class);
episodeService = mock(EpisodeService.class);
mockMvc = ControllerContractSupport.mockMvcOf(
new DramaController(dramaService, commentService));
new DramaController(dramaService, commentService, episodeService));
}
@Test
@@ -46,6 +49,8 @@ class DramaControllerContractTest {
.willReturn(new PageResult<>(2L, List.of(first, second)));
given(commentService.countByDramas(List.of(7L, 8L)))
.willReturn(Map.of(7L, 11L));
given(episodeService.sumLikeCountsByDramas(List.of(7L, 8L)))
.willReturn(Map.of(7L, 3200L, 8L, 120L));
mockMvc.perform(get("/admin/dramas/page"))
.andExpect(status().isOk())
@@ -65,6 +70,7 @@ class DramaControllerContractTest {
void should_fill_comment_count_when_detail_is_requested() throws Exception {
given(dramaService.get(7L)).willReturn(drama(7L, 3200L, 1800L));
given(commentService.countByDramas(List.of(7L))).willReturn(Map.of(7L, 11L));
given(episodeService.sumLikeCountsByDramas(List.of(7L))).willReturn(Map.of(7L, 3200L));
mockMvc.perform(get("/admin/dramas/7"))
.andExpect(status().isOk())
@@ -0,0 +1,70 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.content.vo.EpisodeRespVO;
import com.fruitshorts.interaction.service.CommentService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** K89:后台剧集列表按集回填点赞/评论,并带所属剧收藏/热度。 */
class EpisodeControllerContractTest {
private EpisodeService episodeService;
private CommentService commentService;
private DramaService dramaService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
episodeService = mock(EpisodeService.class);
commentService = mock(CommentService.class);
dramaService = mock(DramaService.class);
mockMvc = ControllerContractSupport.mockMvcOf(
new EpisodeController(episodeService, commentService, dramaService));
}
@Test
void should_return_episode_and_drama_interaction_counts() throws Exception {
given(episodeService.listByDrama(5L, null)).willReturn(List.of(
episode(11L, 10L),
episode(12L, 20L)));
given(commentService.countByEpisodes(List.of(11L, 12L)))
.willReturn(Map.of(11L, 3L));
Drama drama = new Drama();
drama.setFavoriteCount(88L);
drama.setViewCount(1000L);
given(dramaService.getExisting(5L)).willReturn(drama);
mockMvc.perform(get("/admin/episodes").param("dramaId", "5"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].likeCount").value("10"))
.andExpect(jsonPath("$.data[0].commentCount").value("3"))
.andExpect(jsonPath("$.data[0].favoriteCount").value("88"))
.andExpect(jsonPath("$.data[0].heat").value("1000"))
.andExpect(jsonPath("$.data[1].likeCount").value("20"))
.andExpect(jsonPath("$.data[1].commentCount").value("0"));
}
private EpisodeRespVO episode(Long id, Long likeCount) {
EpisodeRespVO episode = new EpisodeRespVO();
episode.setId(id);
episode.setDramaId(5L);
episode.setLikeCount(likeCount);
return episode;
}
}
@@ -3,6 +3,8 @@
package com.fruitshorts.app.controller;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.web.WebUtils;
@@ -24,6 +26,8 @@ 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;
/**
* 评论。
*
@@ -38,6 +42,7 @@ import org.springframework.web.bind.annotation.RestController;
public class AppCommentController {
private final CommentService commentService;
private final EpisodeService episodeService;
@Operation(summary = "发布评论")
@PostMapping
@@ -63,9 +68,18 @@ public class AppCommentController {
return Result.ok(commentService.page(SecurityUtils.getUserIdOrNull(), query));
}
@Operation(summary = "短剧评论总数(可匿名访问)")
@Operation(summary = "评论总数:传 episodeId 按集统计,仅 dramaId 时全剧统计(可匿名访问)")
@GetMapping("/count")
public Result<Long> count(@RequestParam Long dramaId) {
public Result<Long> count(@RequestParam Long dramaId,
@RequestParam(required = false) Long episodeId) {
if (episodeId != null) {
DramaEpisode episode = episodeService.getPublished(episodeId);
if (!dramaId.equals(episode.getDramaId())) {
return Result.ok(0L);
}
return Result.ok(commentService.countByEpisodes(List.of(episodeId))
.getOrDefault(episodeId, 0L));
}
return Result.ok(commentService.countByDrama(dramaId));
}
@@ -5,14 +5,20 @@ package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.vo.AppDramaDetailVO;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.web.WebUtils;
import com.fruitshorts.interaction.service.CommentService;
import com.fruitshorts.playback.service.PlaybackService;
import com.fruitshorts.playback.vo.PlayRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -26,6 +32,8 @@ public class AppDramaController {
private final AppCatalogService appCatalogService;
private final PlaybackService playbackService;
private final EpisodeService episodeService;
private final CommentService commentService;
/** 详情:包含剧集列表,客户端进详情页一次请求即可渲染 */
@Operation(summary = "短剧详情(含剧集列表,进详情页一次请求即可渲染)")
@@ -41,10 +49,14 @@ public class AppDramaController {
@Operation(summary = "播放票据:判定权益后签发短时效播放地址")
@GetMapping("/episodes/{id}/play")
public Result<PlayRespVO> play(@PathVariable Long id, HttpServletRequest request) {
return Result.ok(playbackService.play(
PlayRespVO vo = playbackService.play(
SecurityUtils.getUserIdOrNull(),
id,
request.getHeader("X-Device-Id"),
WebUtils.getClientIp(request)));
WebUtils.getClientIp(request));
DramaEpisode episode = episodeService.getExisting(id);
vo.setLikeCount(episode.getLikeCount() == null ? 0L : episode.getLikeCount());
vo.setCommentCount(commentService.countByEpisodes(List.of(id)).getOrDefault(id, 0L));
return Result.ok(vo);
}
}
@@ -106,6 +106,8 @@ public class AppCatalogService {
public AppDramaDetailVO dramaDetail(Long dramaId) {
Drama drama = dramaService.getPublished(dramaId);
List<DramaEpisode> episodes = episodeService.listPublishedByDrama(dramaId);
Map<Long, Long> episodeCommentCounts = commentService.countByEpisodes(
episodes.stream().map(DramaEpisode::getId).toList());
String categoryName = categoryNameMap().getOrDefault(drama.getCategoryId(), "");
AppDramaDetailVO detail = new AppDramaDetailVO();
@@ -123,7 +125,9 @@ public class AppCatalogService {
detail.setLikeCount(drama.getLikeCount());
detail.setFavoriteCount(drama.getFavoriteCount());
detail.setCommentCount(commentService.countByDrama(dramaId));
detail.setEpisodes(episodes.stream().map(this::toEpisode).toList());
detail.setEpisodes(episodes.stream()
.map(episode -> toEpisode(episode, episodeCommentCounts))
.toList());
return detail;
}
@@ -168,7 +172,8 @@ public class AppCatalogService {
private List<AppDramaCardVO> cards(List<Drama> dramas, Map<Long, String> categoryNames) {
List<Long> ids = dramas.stream().map(Drama::getId).toList();
Map<Long, DramaEpisode> previews = episodeService.firstPlayableByDramas(ids);
Map<Long, Long> commentCounts = commentService.countByDramas(ids);
Map<Long, Long> commentCounts = commentService.countByEpisodes(
previews.values().stream().map(DramaEpisode::getId).toList());
return dramas.stream()
.map(drama -> toCard(
drama, categoryNames, previews.get(drama.getId()), commentCounts))
@@ -187,16 +192,18 @@ public class AppCatalogService {
card.setTotalEpisodes(drama.getTotalEpisodes());
card.setIsHot(drama.getIsHot() != null && drama.getIsHot() == 1);
card.setViewCount(drama.getViewCount());
card.setLikeCount(drama.getLikeCount());
card.setLikeCount(preview == null || preview.getLikeCount() == null
? 0L : preview.getLikeCount());
card.setFavoriteCount(drama.getFavoriteCount());
card.setCommentCount(commentCounts.getOrDefault(drama.getId(), 0L));
card.setCommentCount(preview == null
? 0L : commentCounts.getOrDefault(preview.getId(), 0L));
card.setLanguage(drama.getLanguage());
card.setDramaType(drama.getDramaType());
card.setPreview(preview == null ? null : toEpisode(preview));
card.setPreview(preview == null ? null : toEpisode(preview, commentCounts));
return card;
}
private AppEpisodeVO toEpisode(DramaEpisode episode) {
private AppEpisodeVO toEpisode(DramaEpisode episode, Map<Long, Long> commentCounts) {
AppEpisodeVO vo = new AppEpisodeVO();
vo.setId(episode.getId());
vo.setNumber(episode.getEpNo());
@@ -208,6 +215,8 @@ public class AppCatalogService {
vo.setIsFree(free);
vo.setLocked(!free);
vo.setUnlockCoin(episode.getUnlockCoin());
vo.setLikeCount(episode.getLikeCount() == null ? 0L : episode.getLikeCount());
vo.setCommentCount(commentCounts.getOrDefault(episode.getId(), 0L));
if (free) {
// 播放地址统一在这里签名:客户端拿不到可长期复用的裸地址
vo.setPlayUrl(playbackSigner.sign(playableSource(episode)));
@@ -17,6 +17,8 @@ public class AppEpisodeVO {
/** true 表示需要解锁后才能播放 */
private Boolean locked;
private Integer unlockCoin;
private Long likeCount;
private Long commentCount;
/**
* 可播放时的地址;锁定或不含片源时为空。
* 详情页拿到就能直接播,不必按集再请求一次播放票据。
@@ -10,9 +10,11 @@ import com.fruitshorts.app.vo.AppDramaDetailVO;
import com.fruitshorts.app.vo.AppFeedVO;
import com.fruitshorts.app.vo.AppSectionVO;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.playback.service.PlaybackService;
import com.fruitshorts.interaction.service.CommentService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
@@ -43,7 +45,8 @@ class AppCatalogControllerContractTest {
catalogService = mock(AppCatalogService.class);
catalogMvc = ControllerContractSupport.mockMvcOf(new AppCatalogController(catalogService));
dramaMvc = ControllerContractSupport.mockMvcOf(
new AppDramaController(catalogService, mock(PlaybackService.class)));
new AppDramaController(catalogService, mock(PlaybackService.class),
mock(EpisodeService.class), mock(CommentService.class)));
}
@Test
@@ -3,11 +3,16 @@
package com.fruitshorts.app.controller;
import com.fruitshorts.app.support.ControllerContractSupport;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.service.CommentService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@@ -19,13 +24,15 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
class AppCommentControllerContractTest {
private CommentService commentService;
private EpisodeService episodeService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
commentService = mock(CommentService.class);
episodeService = mock(EpisodeService.class);
mockMvc = ControllerContractSupport.mockMvcOf(
new AppCommentController(commentService));
new AppCommentController(commentService, episodeService));
}
@Test
@@ -40,4 +47,21 @@ class AppCommentControllerContractTest {
verify(commentService).countByDrama(7L);
}
@Test
void should_count_comments_by_episode_when_episode_id_is_given() throws Exception {
DramaEpisode episode = new DramaEpisode();
episode.setId(11L);
episode.setDramaId(7L);
given(episodeService.getPublished(11L)).willReturn(episode);
given(commentService.countByEpisodes(List.of(11L))).willReturn(Map.of(11L, 4L));
mockMvc.perform(get("/app/comments/count")
.param("dramaId", "7")
.param("episodeId", "11"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").value("4"));
verify(commentService).countByEpisodes(List.of(11L));
}
}
@@ -5,6 +5,7 @@ package com.fruitshorts.app.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.framework.log.AccessDeniedRecorder;
import com.fruitshorts.framework.security.JwtAuthenticationFilter;
import com.fruitshorts.framework.security.JwtTokenProvider;
@@ -90,7 +91,7 @@ class AppCommentCountAnonymousSecurityTest {
@Bean
AppCommentController appCommentController(CommentService commentService) {
return new AppCommentController(commentService);
return new AppCommentController(commentService, mock(EpisodeService.class));
}
@Bean
@@ -3,6 +3,9 @@ package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.support.ControllerContractSupport;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.service.CommentService;
import com.fruitshorts.playback.service.PlaybackService;
import com.fruitshorts.playback.vo.PlayRespVO;
import org.junit.jupiter.api.BeforeEach;
@@ -10,6 +13,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
@@ -30,13 +34,22 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
class AppPlaybackContractTest {
private PlaybackService playbackService;
private EpisodeService episodeService;
private CommentService commentService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
playbackService = mock(PlaybackService.class);
episodeService = mock(EpisodeService.class);
commentService = mock(CommentService.class);
DramaEpisode episode = new DramaEpisode();
episode.setLikeCount(88L);
given(episodeService.getExisting(any())).willReturn(episode);
given(commentService.countByEpisodes(any())).willReturn(Map.of());
mockMvc = ControllerContractSupport.mockMvcOf(
new AppDramaController(mock(AppCatalogService.class), playbackService));
new AppDramaController(mock(AppCatalogService.class), playbackService,
episodeService, commentService));
}
@Test
@@ -52,7 +65,9 @@ class AppPlaybackContractTest {
.andExpect(jsonPath("$.data.episodeId").isString())
.andExpect(jsonPath("$.data.accessible").value(true))
.andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_FREE))
.andExpect(jsonPath("$.data.playUrl").value("/files/ep.mp4?s=1"));
.andExpect(jsonPath("$.data.playUrl").value("/files/ep.mp4?s=1"))
.andExpect(jsonPath("$.data.likeCount").value("88"))
.andExpect(jsonPath("$.data.commentCount").value("0"));
}
@Test
@@ -5,6 +5,7 @@ import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.app.vo.AppDramaDetailVO;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.BannerService;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaService;
@@ -55,10 +56,12 @@ class AppCatalogServiceTest {
drama.setLikeCount(3200L);
drama.setFavoriteCount(1800L);
drama.setViewCount(12400L);
DramaEpisode episode = episode(11L, 42L, 55L);
given(dramaService.getPublished(42L)).willReturn(drama);
given(episodeService.listPublishedByDrama(42L)).willReturn(List.of());
given(episodeService.listPublishedByDrama(42L)).willReturn(List.of(episode));
given(dramaCategoryService.listBasic()).willReturn(List.of());
given(commentService.countByDrama(42L)).willReturn(96L);
given(commentService.countByEpisodes(List.of(11L))).willReturn(Map.of(11L, 2L));
AppDramaDetailVO detail = catalogService.dramaDetail(42L);
@@ -66,6 +69,8 @@ class AppCatalogServiceTest {
assertThat(detail.getLikeCount()).isEqualTo(3200L);
assertThat(detail.getFavoriteCount()).isEqualTo(1800L);
assertThat(detail.getCommentCount()).isEqualTo(96L);
assertThat(detail.getEpisodes().get(0).getLikeCount()).isEqualTo(55L);
assertThat(detail.getEpisodes().get(0).getCommentCount()).isEqualTo(2L);
verify(commentService).countByDrama(42L);
}
@@ -83,10 +88,13 @@ class AppCatalogServiceTest {
second.setViewCount(800L);
second.setLikeCount(120L);
second.setFavoriteCount(80L);
given(episodeService.firstPlayableByDramas(List.of(1L, 2L))).willReturn(Map.of());
DramaEpisode firstEpisode = episode(11L, 1L, 320L);
DramaEpisode secondEpisode = episode(22L, 2L, 120L);
given(episodeService.firstPlayableByDramas(List.of(1L, 2L)))
.willReturn(Map.of(1L, firstEpisode, 2L, secondEpisode));
given(dramaCategoryService.listBasic()).willReturn(List.of());
given(commentService.countByDramas(List.of(1L, 2L)))
.willReturn(Map.of(1L, 9L));
given(commentService.countByEpisodes(List.of(11L, 22L)))
.willReturn(Map.of(11L, 9L));
List<AppDramaCardVO> cards = catalogService.toCards(List.of(first, second));
@@ -97,7 +105,7 @@ class AppCatalogServiceTest {
assertThat(cards.get(1).getLikeCount()).isEqualTo(120L);
assertThat(cards.get(1).getFavoriteCount()).isEqualTo(80L);
assertThat(cards.get(1).getCommentCount()).isZero();
verify(commentService).countByDramas(List.of(1L, 2L));
verify(commentService).countByEpisodes(List.of(11L, 22L));
verify(commentService, never()).countByDrama(any());
}
@@ -107,14 +115,17 @@ class AppCatalogServiceTest {
given(dramaService.listPublishedFeed(List.of("ANIME"), null, 21))
.willReturn(List.of(anime));
given(dramaCategoryService.listBasic()).willReturn(List.of());
given(episodeService.firstPlayableByDramas(List.of(1L))).willReturn(Map.of());
given(commentService.countByDramas(List.of(1L))).willReturn(Map.of(1L, 3L));
DramaEpisode preview = episode(11L, 1L, 45L);
given(episodeService.firstPlayableByDramas(List.of(1L)))
.willReturn(Map.of(1L, preview));
given(commentService.countByEpisodes(List.of(11L))).willReturn(Map.of(11L, 3L));
CursorResult<AppDramaCardVO> result = catalogService.feed("ANIME", null, null);
assertThat(result.getList()).hasSize(1);
assertThat(result.getList().get(0).getDramaType()).isEqualTo("ANIME");
assertThat(result.getList().get(0).getCommentCount()).isEqualTo(3L);
assertThat(result.getList().get(0).getLikeCount()).isEqualTo(45L);
assertThat(result.isHasMore()).isFalse();
verify(dramaService).listPublishedFeed(List.of("ANIME"), null, 21);
}
@@ -140,7 +151,7 @@ class AppCatalogServiceTest {
.willReturn(List.of(second));
given(dramaCategoryService.listBasic()).willReturn(List.of());
given(episodeService.firstPlayableByDramas(any())).willReturn(Map.of());
given(commentService.countByDramas(any())).willReturn(Map.of());
given(commentService.countByEpisodes(any())).willReturn(Map.of());
CursorResult<AppDramaCardVO> firstPage = catalogService.feed(null, null, 1);
CursorResult<AppDramaCardVO> secondPage = catalogService.feed(null, firstPage.getNextCursor(), 1);
@@ -160,4 +171,12 @@ class AppCatalogServiceTest {
drama.setDramaType(dramaType);
return drama;
}
private DramaEpisode episode(Long id, Long dramaId, Long likeCount) {
DramaEpisode episode = new DramaEpisode();
episode.setId(id);
episode.setDramaId(dramaId);
episode.setLikeCount(likeCount);
return episode;
}
}
@@ -109,4 +109,23 @@ class CommentServiceCountTest {
assertThat(lambda.getParamNameValuePairs().values())
.contains(7L, 8L, "deleted");
}
@Test
void should_group_comment_counts_by_episode() {
ArgumentCaptor<Wrapper<ContentComment>> captor = ArgumentCaptor.forClass(Wrapper.class);
given(commentMapper.selectMaps(captor.capture())).willReturn(List.of(
Map.of("episode_id", 11L, "comment_count", 4L),
Map.of("episode_id", 12L, "comment_count", 2L)));
Map<Long, Long> counts = commentService.countByEpisodes(
Arrays.asList(11L, 12L, 12L, null));
assertThat(counts).containsEntry(11L, 4L).containsEntry(12L, 2L);
Wrapper<ContentComment> wrapper = captor.getValue();
assertThat(wrapper.getSqlSegment())
.contains("episode_id")
.contains("status")
.contains("deleted")
.contains("GROUP BY");
}
}
@@ -0,0 +1,113 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.content.vo.EpisodeSaveReqVO;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/** K89:剧集点赞编辑、原子增减与剧级批量汇总。 */
class EpisodeServiceK89Test {
private DramaEpisodeMapper episodeMapper;
private EpisodeService episodeService;
@BeforeAll
static void initTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""), DramaEpisode.class);
}
@BeforeEach
void setUp() {
episodeMapper = mock(DramaEpisodeMapper.class);
episodeService = new EpisodeService(episodeMapper, mock(DramaService.class));
}
@Test
@SuppressWarnings("unchecked")
void should_use_greatest_zero_when_episode_like_decrements() {
given(episodeMapper.update(isNull(), any())).willReturn(1);
ArgumentCaptor<Wrapper<DramaEpisode>> captor = ArgumentCaptor.forClass(Wrapper.class);
episodeService.adjustLikeCount(11L, -1);
verify(episodeMapper).update(isNull(), captor.capture());
LambdaUpdateWrapper<DramaEpisode> update =
(LambdaUpdateWrapper<DramaEpisode>) captor.getValue();
assertThat(update.getSqlSet()).contains("GREATEST(like_count - 1, 0)");
}
@Test
void should_update_episode_like_count_without_overwriting_when_null() {
DramaEpisode episode = new DramaEpisode();
episode.setId(11L);
episode.setDramaId(5L);
episode.setEpNo(1);
episode.setLikeCount(8L);
given(episodeMapper.selectById(11L)).willReturn(episode);
EpisodeSaveReqVO req = new EpisodeSaveReqVO();
req.setDramaId(5L);
req.setNumber(1);
req.setLikeCount(20L);
episodeService.update(11L, req);
assertThat(episode.getLikeCount()).isEqualTo(20L);
}
@Test
void should_reject_negative_episode_like_count() {
DramaEpisode episode = new DramaEpisode();
episode.setId(11L);
episode.setDramaId(5L);
episode.setEpNo(1);
given(episodeMapper.selectById(11L)).willReturn(episode);
EpisodeSaveReqVO req = new EpisodeSaveReqVO();
req.setDramaId(5L);
req.setNumber(1);
req.setLikeCount(-1L);
assertThatThrownBy(() -> episodeService.update(11L, req))
.isInstanceOf(ServiceException.class)
.satisfies(error -> assertThat(((ServiceException) error).getCode())
.isEqualTo(ErrorCode.BAD_REQUEST.getCode()));
verify(episodeMapper, never()).updateById(any(DramaEpisode.class));
}
@Test
void should_sum_episode_likes_by_drama() {
given(episodeMapper.selectMaps(any())).willReturn(List.of(
Map.of("drama_id", 5L, "like_count", 120L),
Map.of("drama_id", 6L, "like_count", 30L)));
Map<Long, Long> sums = episodeService.sumLikeCountsByDramas(List.of(5L, 6L));
assertThat(sums).containsEntry(5L, 120L).containsEntry(6L, 30L);
}
}
@@ -0,0 +1,112 @@
// Author: fruit.com.cn
package com.fruitshorts.interaction.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.dal.entity.ContentLike;
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
import com.fruitshorts.interaction.dal.mapper.ContentLikeMapper;
import com.fruitshorts.interaction.vo.LikeToggleReqVO;
import com.fruitshorts.interaction.vo.LikeToggleRespVO;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/** K89:点赞目标支持已上线的分集。 */
class LikeServiceEpisodeTest {
private ContentLikeMapper likeMapper;
private EpisodeService episodeService;
private DramaService dramaService;
private LikeService likeService;
@BeforeAll
static void initTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""), ContentLike.class);
}
@BeforeEach
void setUp() {
likeMapper = mock(ContentLikeMapper.class);
episodeService = mock(EpisodeService.class);
dramaService = mock(DramaService.class);
likeService = new LikeService(
likeMapper,
mock(ContentCommentMapper.class),
dramaService,
episodeService);
}
@Test
void should_increment_episode_like_and_return_latest_count() {
DramaEpisode published = episode(6L);
given(episodeService.getPublished(11L)).willReturn(published);
given(dramaService.getPublished(5L)).willReturn(new Drama());
given(likeMapper.selectOne(any())).willReturn(null);
given(episodeService.getExisting(11L)).willReturn(published);
LikeToggleRespVO result = likeService.toggle(9L, request(11L));
assertThat(result.getLiked()).isTrue();
assertThat(result.getCount()).isEqualTo(6L);
verify(episodeService).adjustLikeCount(11L, 1);
}
@Test
void should_decrement_episode_like_when_cancelled() {
DramaEpisode published = episode(5L);
given(episodeService.getPublished(11L)).willReturn(published);
given(dramaService.getPublished(5L)).willReturn(new Drama());
given(likeMapper.selectOne(any())).willReturn(new ContentLike());
given(episodeService.getExisting(11L)).willReturn(published);
LikeToggleRespVO result = likeService.toggle(9L, request(11L));
assertThat(result.getLiked()).isFalse();
assertThat(result.getCount()).isEqualTo(5L);
verify(likeMapper).physicalDelete(9L, LikeService.TYPE_EPISODE, 11L);
verify(episodeService).adjustLikeCount(11L, -1);
}
@Test
void should_reject_missing_or_unpublished_episode() {
given(episodeService.getPublished(11L))
.willThrow(new ServiceException(ErrorCode.DATA_NOT_FOUND, "剧集不存在或未上线"));
assertThatThrownBy(() -> likeService.toggle(9L, request(11L)))
.isInstanceOf(ServiceException.class);
verify(likeMapper, never()).insert(any(ContentLike.class));
}
private DramaEpisode episode(Long likeCount) {
DramaEpisode episode = new DramaEpisode();
episode.setId(11L);
episode.setDramaId(5L);
episode.setLikeCount(likeCount);
return episode;
}
private LikeToggleReqVO request(Long episodeId) {
LikeToggleReqVO req = new LikeToggleReqVO();
req.setTargetType(LikeService.TYPE_EPISODE);
req.setTargetId(episodeId);
return req;
}
}
@@ -40,6 +40,8 @@ public class DramaEpisode {
private String status;
private LocalDateTime releaseAt;
private Long playCount;
/** 本集点赞数(点赞目标为 EPISODE) */
private Long likeCount;
private Integer sort;
private LocalDateTime createdAt;
/** updated_at 交给数据库的 ON UPDATE CURRENT_TIMESTAMP 维护 */
@@ -2,6 +2,7 @@
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
@@ -14,9 +15,12 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Service
@RequiredArgsConstructor
@@ -103,6 +107,7 @@ public class EpisodeService {
apply(episode, req);
episode.setStatus(req.getStatus() == null ? ContentStatus.DRAFT.getCode() : req.getStatus());
episode.setPlayCount(0L);
episode.setLikeCount(nonNegativeOrZero(req.getLikeCount()));
episode.setVideoAssetId(0L);
dramaEpisodeMapper.insert(episode);
dramaService.syncTotalEpisodes(req.getDramaId());
@@ -115,6 +120,9 @@ public class EpisodeService {
dramaService.getExisting(req.getDramaId());
ensureEpNoAvailable(req.getDramaId(), req.getNumber(), id);
apply(episode, req);
if (req.getLikeCount() != null) {
episode.setLikeCount(nonNegative(req.getLikeCount()));
}
if (req.getStatus() != null) {
episode.setStatus(req.getStatus());
}
@@ -157,6 +165,46 @@ public class EpisodeService {
.set(DramaEpisode::getStatus, ContentStatus.PUBLISHED.getCode()));
}
/** 本集点赞数原子增减,避免并发读改写丢计数。 */
public void adjustLikeCount(Long episodeId, int delta) {
if (episodeId == null || delta == 0) {
return;
}
dramaEpisodeMapper.update(null, Wrappers.<DramaEpisode>lambdaUpdate()
.eq(DramaEpisode::getId, episodeId)
.setSql(delta > 0
? "like_count = like_count + 1"
: "like_count = GREATEST(like_count - 1, 0)"));
}
/** 批量汇总多部剧的分集点赞数,避免后台逐剧 N+1 查询。 */
public Map<Long, Long> sumLikeCountsByDramas(Collection<Long> dramaIds) {
if (dramaIds == null || dramaIds.isEmpty()) {
return Map.of();
}
List<Long> ids = dramaIds.stream().filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) {
return Map.of();
}
QueryWrapper<DramaEpisode> wrapper = Wrappers.query();
wrapper.select("drama_id AS drama_id", "COALESCE(SUM(like_count), 0) AS like_count")
.in("drama_id", ids)
.eq("deleted", 0)
.groupBy("drama_id");
List<Map<String, Object>> rows = dramaEpisodeMapper.selectMaps(wrapper);
if (rows == null || rows.isEmpty()) {
return Map.of();
}
Map<Long, Long> counts = new HashMap<>();
for (Map<String, Object> row : rows) {
Long dramaId = asLong(row.get("drama_id"));
if (dramaId != null) {
counts.put(dramaId, asLong(row.get("like_count"), 0L));
}
}
return counts;
}
private void ensureEpNoAvailable(Long dramaId, Integer epNo, Long excludeId) {
Long count = dramaEpisodeMapper.selectCount(Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getDramaId, dramaId)
@@ -194,10 +242,31 @@ public class EpisodeService {
vo.setStatus(episode.getStatus());
vo.setStatusText(ContentStatus.textOf(episode.getStatus()));
vo.setViews(episode.getPlayCount());
vo.setLikeCount(episode.getLikeCount() == null ? 0L : episode.getLikeCount());
vo.setSort(episode.getSort());
return vo;
}
private Long nonNegativeOrZero(Long value) {
return value == null ? 0L : nonNegative(value);
}
private Long nonNegative(Long value) {
if (value < 0) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "本集点赞数不能小于 0");
}
return value;
}
private Long asLong(Object value) {
return value instanceof Number number ? number.longValue() : null;
}
private long asLong(Object value, long defaultValue) {
Long number = asLong(value);
return number == null ? defaultValue : number;
}
private String formatDuration(Integer seconds) {
int value = seconds == null ? 0 : seconds;
return String.format("%02d:%02d", value / 60, value % 60);
@@ -21,5 +21,13 @@ public class EpisodeRespVO {
private String status;
private String statusText;
private Long views;
/** 本集点赞数 */
private Long likeCount;
/** 本集评论数 */
private Long commentCount;
/** 所属剧收藏数,只读 */
private Long favoriteCount;
/** 所属剧热度(drama.view_count),只读 */
private Long heat;
private Integer sort;
}
@@ -3,6 +3,7 @@
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Data;
@Data
@@ -20,6 +21,8 @@ public class EpisodeSaveReqVO {
private String videoUrl;
private Boolean isFree;
private Integer unlockCoin;
@PositiveOrZero(message = "本集点赞数不能小于 0")
private Long likeCount;
private String status;
private Integer sort;
}
@@ -52,6 +52,7 @@ import java.util.Set;
public class CommentService {
private static final String DRAMA_ID_COLUMN = "drama_id";
private static final String EPISODE_ID_COLUMN = "episode_id";
private static final String COMMENT_COUNT_COLUMN = "comment_count";
public static final String STATUS_PENDING = "pending";
@@ -222,6 +223,35 @@ public class CommentService {
return counts;
}
/** 批量统计多集未删除评论数,按 episode_id 分组。 */
public Map<Long, Long> countByEpisodes(Collection<Long> episodeIds) {
if (episodeIds == null || episodeIds.isEmpty()) {
return Map.of();
}
List<Long> ids = episodeIds.stream().filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) {
return Map.of();
}
QueryWrapper<ContentComment> wrapper = Wrappers.query();
wrapper.select(EPISODE_ID_COLUMN, "COUNT(*) AS " + COMMENT_COUNT_COLUMN)
.in(EPISODE_ID_COLUMN, ids)
.eq("deleted", 0)
.ne("status", STATUS_DELETED)
.groupBy(EPISODE_ID_COLUMN);
List<Map<String, Object>> rows = commentMapper.selectMaps(wrapper);
if (rows == null || rows.isEmpty()) {
return Map.of();
}
Map<Long, Long> counts = new HashMap<>();
for (Map<String, Object> row : rows) {
Long episodeId = asLong(row.get(EPISODE_ID_COLUMN));
if (episodeId != null) {
counts.put(episodeId, asLong(row.get(COMMENT_COUNT_COLUMN), 0L));
}
}
return counts;
}
private Long asLong(Object value) {
return value instanceof Number number ? number.longValue() : null;
}
@@ -5,7 +5,9 @@ package com.fruitshorts.interaction.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.DramaEpisode;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.dal.entity.ContentComment;
import com.fruitshorts.interaction.dal.entity.ContentLike;
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
@@ -29,17 +31,19 @@ import java.util.Locale;
public class LikeService {
public static final String TYPE_DRAMA = "DRAMA";
public static final String TYPE_EPISODE = "EPISODE";
public static final String TYPE_COMMENT = "COMMENT";
private final ContentLikeMapper likeMapper;
private final ContentCommentMapper commentMapper;
private final DramaService dramaService;
private final EpisodeService episodeService;
@Transactional(rollbackFor = Exception.class)
public LikeToggleRespVO toggle(Long userId, LikeToggleReqVO req) {
String type = req.getTargetType().trim().toUpperCase(Locale.ROOT);
if (!TYPE_DRAMA.equals(type) && !TYPE_COMMENT.equals(type)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "只支持对短剧或评论点赞");
if (!TYPE_DRAMA.equals(type) && !TYPE_EPISODE.equals(type) && !TYPE_COMMENT.equals(type)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "只支持对短剧、剧集或评论点赞");
}
Long targetId = req.getTargetId();
validateTarget(type, targetId);
@@ -74,6 +78,10 @@ public class LikeService {
return dramaService.getExisting(targetId).getLikeCount() == null
? 0L : dramaService.getExisting(targetId).getLikeCount();
}
if (TYPE_EPISODE.equals(type)) {
return episodeService.getExisting(targetId).getLikeCount() == null
? 0L : episodeService.getExisting(targetId).getLikeCount();
}
ContentComment comment = commentMapper.selectById(targetId);
return comment == null || comment.getLikeCount() == null ? 0L : comment.getLikeCount();
}
@@ -83,6 +91,10 @@ public class LikeService {
dramaService.adjustLikeCount(targetId, delta);
return;
}
if (TYPE_EPISODE.equals(type)) {
episodeService.adjustLikeCount(targetId, delta);
return;
}
commentMapper.update(null, Wrappers.<ContentComment>lambdaUpdate()
.eq(ContentComment::getId, targetId)
.setSql(delta > 0
@@ -95,6 +107,11 @@ public class LikeService {
dramaService.getPublished(targetId);
return;
}
if (TYPE_EPISODE.equals(type)) {
DramaEpisode episode = episodeService.getPublished(targetId);
dramaService.getPublished(episode.getDramaId());
return;
}
ContentComment comment = commentMapper.selectById(targetId);
if (comment == null || CommentService.STATUS_PUBLISHED.equals(comment.getStatus()) == false) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "评论不存在或未通过审核");
@@ -10,7 +10,7 @@ import lombok.Data;
@Data
public class LikeToggleReqVO {
/** DRAMA/COMMENT */
/** DRAMA/EPISODE/COMMENT */
@NotBlank(message = "缺少 targetType")
private String targetType;
@@ -27,4 +27,8 @@ public class PlayRespVO {
private Integer unlockCoin;
/** 试看秒数,0 表示不限制 */
private Integer previewSeconds;
/** 本集点赞数 */
private Long likeCount;
/** 本集评论数 */
private Long commentCount;
}
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS like_count BIGINT NOT NULL DEFAULT 0;
COMMENT ON COLUMN drama_episode.like_count IS '本集点赞数(点赞目标为 EPISODE)';
+1
View File
@@ -44,6 +44,7 @@ ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS preview_seconds INT
ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS subtitle_url VARCHAR(512) NOT NULL DEFAULT '';
ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS subtitle_lang VARCHAR(16) NOT NULL DEFAULT '';
ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS is_vip_only SMALLINT NOT NULL DEFAULT 0; -- 预留:会员专属(第二阶段)
ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS like_count BIGINT NOT NULL DEFAULT 0; -- 本集点赞数
-- 媒资:CDN 地址与转码状态
ALTER TABLE media_asset ADD COLUMN IF NOT EXISTS cdn_url VARCHAR(512) NOT NULL DEFAULT '';