merge: 合并 K89 分集点赞与评论统计
This commit is contained in:
commit
dfc978c620
28 files changed
+683
-41
No files matched your search
+15
-8
@@ -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` 转人工待审。
|
||||
|
||||
@@ -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 留痕。
|
||||
+11
-5
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -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 = "新建剧集")
|
||||
|
||||
+7
-1
@@ -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())
|
||||
|
||||
+70
@@ -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;
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+14
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
+15
-6
@@ -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;
|
||||
/**
|
||||
* 可播放时的地址;锁定或不含片源时为空。
|
||||
* 详情页拿到就能直接播,不必按集再请求一次播放票据。
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
+25
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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
|
||||
|
||||
+17
-2
@@ -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
|
||||
|
||||
+27
-8
@@ -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;
|
||||
}
|
||||
}
|
||||
+19
@@ -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");
|
||||
}
|
||||
}
|
||||
+113
@@ -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);
|
||||
}
|
||||
}
|
||||
+112
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
@@ -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 维护 */
|
||||
|
||||
+69
@@ -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
@@ -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;
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
|
||||
+19
-2
@@ -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, "评论不存在或未通过审核");
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
@@ -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)';
|
||||
@@ -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 '';
|
||||
|
||||
Reference in new issue
Block a user