From 65e8fbfc707e983d9236f2d4e28fce5863ecc3da Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 11:33:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(interaction):=20=E6=94=B6=E8=97=8F=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=E5=90=8C=E6=AD=A5=E7=BB=B4=E6=8A=A4=E5=B9=B6=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E8=AF=84=E8=AE=BA=E6=95=B0=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-phase1.md | 1 + .../plans/2026-10-21-K85-interaction-count.md | 46 +++++++ docs/plans/INBOX.md | 119 ++++++++++++------ docs/plans/PROGRESS.md | 28 +++++ .../app/controller/AppCommentController.java | 6 + .../AppCommentControllerContractTest.java | 43 +++++++ .../DramaServiceFavoriteCountTest.java | 57 +++++++++ .../playback/service/FavoriteServiceTest.java | 80 ++++++++++++ .../content/service/DramaService.java | 12 ++ .../playback/service/FavoriteService.java | 27 ++-- sql/backfill_favorite_count.sql | 10 ++ 11 files changed, 382 insertions(+), 47 deletions(-) create mode 100644 docs/plans/2026-10-21-K85-interaction-count.md create mode 100644 fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentControllerContractTest.java create mode 100644 fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFavoriteCountTest.java create mode 100644 fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/FavoriteServiceTest.java create mode 100644 sql/backfill_favorite_count.sql diff --git a/docs/api-phase1.md b/docs/api-phase1.md index a8f27c1..18eea04 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -297,6 +297,7 @@ app.register.max-per-device-per-day | --- | --- | --- | --- | | POST | `/app/comments` | 登录 | 发表评论/回复:过敏感词后按策略发布或转待审 | | GET | `/app/comments?dramaId=&cursor=&size=` | 匿名 | 游标翻页;不传 `rootId` 返回顶层评论,传了返回该楼层回复 | +| GET | `/app/comments/count?dramaId=` | 匿名 | 返回该剧未删除评论总数,`Long` 按字符串下发 | | DELETE | `/app/comments/{id}` | 登录 | 删除自己的评论(逻辑置为 `deleted`) | | POST | `/app/likes/toggle` | 登录 | 点赞/取消点赞,`targetType` 支持 `DRAMA` / `COMMENT` | | POST | `/app/reports` | 登录 | 举报评论/用户/短剧;同一目标只保留一条待处理工单 | diff --git a/docs/plans/2026-10-21-K85-interaction-count.md b/docs/plans/2026-10-21-K85-interaction-count.md new file mode 100644 index 0000000..40572b5 --- /dev/null +++ b/docs/plans/2026-10-21-K85-interaction-count.md @@ -0,0 +1,46 @@ +# K85 收藏数 / 评论数实时更新实施计划 + +## 目标 + +修复收藏数、评论数在列表、播放页和详情页不实时更新的问题,并让服务端收藏计数与关系数据保持一致。 + +## 前置结论 + +根因已由任务书确认,不再重复排查: + +- 收藏关系写入/删除时没有同步维护 `drama.favorite_count`。 +- 客户端首页收藏未复用现有计数增量逻辑。 +- 评论面板关闭后没有重新查询评论总数。 +- 详情页收藏切换后没有本地收藏数增量。 + +## A. FruitServer + +1. `DramaService` 新增原子收藏计数调整方法,参考现有点赞计数实现。 +2. `FavoriteService` 在收藏、取消收藏、批量取消时按实际删除/插入结果同步增减,避免重复操作导致漂移。 +3. 新增一次性 `sql/backfill_favorite_count.sql`,从 `user_favorite` 回填现有收藏数,不在应用启动时自动执行。 +4. `AppCommentController` 新增匿名评论总数接口,复用 `CommentService.countByDrama`。 +5. 补收藏同步维护、批量取消、评论计数接口测试。 +6. 门禁:JDK 21 下完整 `mvn test`。 + +## B. ReelShort + +1. `CommentApi` / `CommentRepository` 新增评论总数查询,兼容数字和字符串两种 Long 返回。 +2. 播放器统一维护按剧评论数缓存;评论面板关闭后刷新,失败保留旧值。 +3. 首页 feed 收藏复用 `_toggleSave`,与全屏播放页共用收藏增量更新。 +4. 详情页收藏成功后本地显示收藏数增量,失败回滚,不修改 `Drama` 模型。 +5. 补评论计数解析、收藏增量、首页 rail 刷新测试。 +6. 门禁:`dart analyze lib test` 零告警、`flutter test` 全过。 + +## 风险与处理 + +- 并发收藏切换可能造成计数竞争:服务端使用 SQL 原子自增/自减,减法不低于 0。 +- 历史收藏数全为 0:提供一次性回填脚本,由验收环境显式执行。 +- 评论面板取消打开时仍刷新一次计数,用于获取匿名新增评论带来的总数变化;查询失败静默保留旧值。 +- 详情页只在收藏状态确实变化时调整本地增量,避免重复点击造成累计误差。 + +## 合并与验收 + +- FruitServer:`fix/favorite-comment-count`,`--no-ff` 合回 `develop` 并推送。 +- ReelShort:`fix/interaction-count-refresh`,`--no-ff` 合回 `develop` 并推送。 +- 作者统一 `Zhou Weidong <502333560@qq.com>`。 +- 不修改 specs 仓库,不执行真机安装;完成后等待用户验收。 diff --git a/docs/plans/INBOX.md b/docs/plans/INBOX.md index 5f2e922..c24845a 100644 --- a/docs/plans/INBOX.md +++ b/docs/plans/INBOX.md @@ -1,58 +1,97 @@ # INBOX -## [已处理] K84-A 列表卡片与后台补充收藏 / 点赞 / 评论数 +## K85 收藏数 / 评论数实时更新 -### 背景 +### 现象 -右侧互动栏要显示收藏、评论、喜欢的数量,但首页 feed 等列表卡片拿不到这三个数。 +首页 feed 右侧互动栏已经能显示数字,但**收藏数、评论数点完不变**: -已确认: +- 收藏(点星标)后数字不动,杀掉 App 重进又回到旧值 +- 发表评论后评论数不动 -- 数据库 `drama` 表有 `favorite_count`、`like_count`(无 comment_count 列,评论数按 - `content_comment` 统计,见 `AppDramaDetailVO.commentCount`) -- `AppDramaDetailVO` 已返回 `likeCount / favoriteCount / commentCount` -- **`AppDramaCardVO`(列表卡片)只有 `viewCount`,缺这三个字段** → 客户端列表阶段拿不到 -- 后台 `DramaRespVO` 只有 `views`,也缺这三个数 -- 客户端 `Drama.fromJson` 已经解析 `likeCount / favoriteCount / commentCount`,**客户端无需改** +### 已定位的根因(勘察结论,不要再重复排查) -### 任务(FruitServer 仓库) +**后端(FruitServer)** -1. `fruitshorts-app-api/.../vo/AppDramaCardVO.java` - - 增加 `Long likeCount`、`Long favoriteCount`、`Long commentCount` -2. `fruitshorts-app-api/.../service/AppCatalogService.java` - - `cards()` 里一次性批量取评论数:`Map commentCounts = commentService.countByDramas(ids)` - - `toCard()` 填充: - - `likeCount` ← `drama.getLikeCount()` - - `favoriteCount` ← `drama.getFavoriteCount()` - - `commentCount` ← `commentCounts.getOrDefault(drama.getId(), 0L)` - - 不要逐条 `countByDrama`(避免 N+1) -3. `fruitshorts-module-interaction/.../service/CommentService.java` - - 新增 `Map countByDramas(Collection dramaIds)`: - `deleted = 0` 且 `drama_id in (...)`,按 `drama_id` 分组计数;空集合返回空 Map - - 现有 `countByDrama(Long)` 可复用该方法的单元素结果或保持不动 -4. 后台管理端(注意模块依赖方向:`module-interaction` 依赖 `module-content`,不能反向依赖) - - `fruitshorts-module-content/.../vo/DramaRespVO.java` 增加 `likeCount / favoriteCount / commentCount` - - `DramaService`(module-content)只填充 `likeCount / favoriteCount`(取实体字段), - `commentCount` 先留空 - - `fruitshorts-admin-api` 的 Drama 接口(`DramaController` 或 admin 侧小服务)在分页/详情返回前, - 用 `CommentService.countByDramas(ids)` 批量回填 `commentCount` - (分页一次查询;详情单条) +1. `drama.favorite_count` 从建剧时置 0 之后**没有任何地方自增/自减**: + - `FavoriteService.toggle()` 只写/删 `user_favorite`,注释写着「计数走异步聚合」, + 但仓库里**没有这个聚合任务**,所以收藏数永远是 0 + - `remove()` / `removeBatch()` 也不维护计数 + - `LikeService` 是同步维护 `drama.like_count` 的(`DramaService.adjustLikeCount`),收藏应对齐这个做法 +2. 数据库现状(本机 PG `fruitshorts`):`drama.favorite_count` 全为 0, + 而 `user_favorite` 实际有记录(剧 1 有 4 条、剧 2 有 2 条、剧 5 有 5 条……)→ 需要回填 +3. 评论数没有单个剧集的计数接口:`GET /app/comments` 是游标翻页,不返回总数 + +**客户端(ReelShort)** + +4. 首页 feed 的收藏回调(`player_view.dart` 约 468 行)是内联实现,**没有调用已有的 + `_applyFavoriteDelta`**;只有全屏播放页的 `_toggleSave`(`player_view_actions.dart`)调了 + → 首页收藏后数字不变 +5. 两处 rail 的 `commentCount: drama.commentCount` 是静态值,评论面板关闭后不刷新 +6. 详情页 `DramaStatsCard` 用 `drama.favoriteCount`,`drama_detail_logic.toggleFavorite()` + 切换后不更新显示 + +### 任务 + +#### A. FruitServer(后端) + +1. `fruitshorts-module-content/.../service/DramaService.java` + - 新增 `adjustFavoriteCount(Long dramaId, int delta)`:SQL 自增, + 参考现有 `adjustLikeCount`,减号用 `GREATEST(favorite_count - 1, 0)` +2. `fruitshorts-module-playback/.../service/FavoriteService.java` + - 类注释里「计数走异步聚合」改成与 `LikeService` 一致的同步维护说明 + - `toggle()`:插入关系后 `adjustFavoriteCount(+1)`;`physicalDelete` 返回行数 > 0 时 `-1` + - `remove()`:删除成功(返回行数 > 0)时 `-1` + - `removeBatch()`:按 dramaId 逐个 `physicalDelete` 并按其返回值 `-1` + (保持与前端「取消收藏」一致的物理删除语义,避免逻辑删除挡住再次收藏) +3. `fruitshorts-app-api/.../controller/AppCommentController.java` + - 新增 `GET /app/comments/count?dramaId={id}` → `Result`, + 内部 `commentService.countByDrama(dramaId)`(匿名可访问,与列表一致) +4. `sql/` 下加一次性回填脚本 `sql/backfill_favorite_count.sql` + - `UPDATE drama d SET favorite_count = (SELECT count(*) FROM user_favorite f WHERE f.drama_id = d.id AND f.deleted = 0);` + - 只放脚本,不要在应用启动时自动执行 5. 测试 - - `AppCatalogControllerContractTest`:列表卡片接口增加 `likeCount / favoriteCount / commentCount` 断言 - - 后台分页/详情如有测试,补充三个字段断言 - - `CommentService.countByDramas` 补单测(空集合 / 多剧集分组) + - `FavoriteService` 单测:收藏 +1、取消 -1、不会减到负数、removeBatch 多剧集各自扣减 + - `AppCommentController` 契约测试:`/app/comments/count` 返回数字 + +#### B. ReelShort(客户端) + +1. `lib/core/data/remote/comment_api.dart` + - 新增 `Future count(String dramaId)`,解析 `data`(Long 可能是字符串,用现有 `jsonToInt`) +2. `lib/core/data/repositories/comment_repository.dart` + - 接口与 `HttpCommentRepository` 同步新增 `count` +3. `lib/pages/player/player_view.dart` + - 首页 feed 的 `onSave` 改为复用 `_toggleSave(context, drama)`(内部已有 `_applyFavoriteDelta`) + - 新增 `Map _commentCounts` + - 新增 `_openComments(context, drama)`:`await openCommentsWithAuth(...)`, + 结束后 `await commentRepository.count(drama.id)` 并 `setState` 写入 `_commentCounts`; + 失败静默(保留旧值,不弹错) + - 首页 rail 与播放页 rail 的 `commentCount` 都改成 + `_commentCounts[drama.id] ?? drama.commentCount`;`onComments` 都走 `_openComments` +4. `lib/pages/drama_detail/drama_detail_logic.dart`(收藏数同步) + - 记录本地收藏增量,把调整后的收藏数提供给 `DramaStatsCard` + (不要改 `Drama` 模型;用 getter 或在 view 层叠加 delta) + - 只在切换成功且状态确实变化时 ±1,失败回滚 +5. 测试 + - `CommentApi.count` 的解析测试(数字 / 字符串两种返回) + - 收藏增量逻辑(可抽纯函数或独立小对象)单测:+1、-1、失败不变 ### 验收 -- 门禁:`mvn test` 全过。 -- 接口自测:`GET /app/home/feed`(或现有首页聚合接口)返回的每个卡片都带三个数字段。 +- 后端:`mvn test` 全过;`mvn -pl fruitshorts-app-api -am package -DskipTests` 出包; + 本地执行回填脚本后 `GET /app/home/feed` 的 `favoriteCount` 与 `user_favorite` 实际条数一致 +- 客户端:`dart analyze lib test` 零告警、`flutter test` 全过 +- 功能:收藏后 rail 数字立即 ±1;重进 App 数字不回退;发表评论后关闭面板,评论数 +1(重新拉取) ### Git -- 从 `develop` 拉 `feat/drama-card-counts`。 -- 提交:`feat(content): 列表卡片与后台返回收藏点赞评论数`,作者 `Zhou Weidong <502333560@qq.com>`。 -- `--no-ff` 合回 `develop` 并 push;`FruitServer/docs/plans/PROGRESS.md` 追加 K84-A 留痕。 +- FruitServer:从 `develop` 拉 `fix/favorite-comment-count`, + 提交 `fix(interaction): 收藏计数同步维护并新增评论数接口`,`--no-ff` 合回 `develop` 并 push +- ReelShort:从 `develop` 拉 `fix/interaction-count-refresh`, + 提交 `fix(player): 收藏与评论数实时刷新`,`--no-ff` 合回 `develop` 并 push +- 作者统一 `Zhou Weidong <502333560@qq.com>`;各自仓库 `docs/plans/PROGRESS.md` 追加 K85 留痕 ### 约束 -- 全程中文;禁止 `git add -A`;不改客户端代码、不改 specs 仓库;完成后停住等待验收。 +- 全程中文;禁止 `git add -A`;不改 specs 仓库;不动 `pubspec.lock` 以外的无关文件 +- 完成后停住,等待验收 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index d1e0326..24ff0e1 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -22912,3 +22912,31 @@ $ JAVA_HOME=/opt/homebrew/Cellar/openjdk@21/21.0.12.1/libexec/openjdk.jdk/Conten ``` 解读:JDK 21 下完整 Maven 测试通过,App/Admin 互动计数契约与既有模块测试均保持绿色。接口联调验收由用户执行。 + +## 2026-10-21 K85:收藏数 / 评论数实时更新(进行中) + +- 计划文档:`docs/plans/2026-10-21-K85-interaction-count.md` +- FruitServer 分支:`fix/favorite-comment-count` +- ReelShort 分支:`fix/interaction-count-refresh` +- 根因采用任务书已确认结论,不重复排查。 +- A 部分代码完成:收藏计数同步维护、评论总数接口、收藏数回填脚本和对应测试已加入。 +- A 部分定向测试通过:app-api 及依赖模块 `Tests run: 68, Failures: 0, Errors: 0`。 +- FruitServer 完整 `mvn test` 通过:14 个模块 `BUILD SUCCESS`,总耗时 11.824s。 +- `mvn -pl fruitshorts-app-api -am package -DskipTests` 通过,产出 `fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar`。 +- 本地执行 `sql/backfill_favorite_count.sql`:`UPDATE 8`;校验 `count_mismatches=0`,剧 1/2/5 收藏数分别为 4/3/5,与有效收藏关系一致。 +- 当前阶段:A 部分完成,准备提交合并后开始 ReelShort B 部分。 + +```text +$ JAVA_HOME=/opt/homebrew/Cellar/openjdk@21/21.0.12.1/libexec/openjdk.jdk/Contents/Home mvn test +[INFO] BUILD SUCCESS +[INFO] Total time: 11.824 s + +$ mvn -pl fruitshorts-app-api -am package -DskipTests +[INFO] BUILD SUCCESS +[INFO] Total time: 9.520 s + +$ psql -f sql/backfill_favorite_count.sql +UPDATE 8 +``` + +解读:后端完整测试和 app-api 打包均成功;本地收藏计数已回填,抽查剧 1/2/5 与关系表计数完全一致。 diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommentController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommentController.java index b63f70d..db44776 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommentController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommentController.java @@ -63,6 +63,12 @@ public class AppCommentController { return Result.ok(commentService.page(SecurityUtils.getUserIdOrNull(), query)); } + @Operation(summary = "短剧评论总数(可匿名访问)") + @GetMapping("/count") + public Result count(@RequestParam Long dramaId) { + return Result.ok(commentService.countByDrama(dramaId)); + } + @Operation(summary = "删除自己的评论") @DeleteMapping("/{id}") public Result delete(@PathVariable Long id) { diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentControllerContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentControllerContractTest.java new file mode 100644 index 0000000..b091433 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentControllerContractTest.java @@ -0,0 +1,43 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.controller; + +import com.fruitshorts.app.support.ControllerContractSupport; +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 static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +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; + +/** 评论总数接口契约。 */ +class AppCommentControllerContractTest { + + private CommentService commentService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + commentService = mock(CommentService.class); + mockMvc = ControllerContractSupport.mockMvcOf( + new AppCommentController(commentService)); + } + + @Test + void should_return_comment_count_as_string_when_requested() throws Exception { + given(commentService.countByDrama(7L)).willReturn(9L); + + mockMvc.perform(get("/app/comments/count").param("dramaId", "7")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value("9")) + .andExpect(jsonPath("$.data").isString()); + + verify(commentService).countByDrama(7L); + } +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFavoriteCountTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFavoriteCountTest.java new file mode 100644 index 0000000..e584b3f --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFavoriteCountTest.java @@ -0,0 +1,57 @@ +// 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.content.dal.entity.Drama; +import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper; +import com.fruitshorts.content.dal.mapper.DramaMapper; +import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper; +import com.fruitshorts.content.service.DramaCategoryService; +import com.fruitshorts.content.service.DramaService; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import static org.assertj.core.api.Assertions.assertThat; +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.verify; + +/** 收藏数减操作必须使用不低于 0 的原子 SQL。 */ +class DramaServiceFavoriteCountTest { + + @BeforeAll + static void initTableInfo() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), Drama.class); + } + + @Test + @SuppressWarnings("unchecked") + void should_use_greatest_zero_when_favorite_count_decrements() { + DramaMapper dramaMapper = mock(DramaMapper.class); + DramaService dramaService = new DramaService( + dramaMapper, + mock(DramaEpisodeMapper.class), + mock(DramaReviewRecordMapper.class), + mock(DramaCategoryService.class)); + given(dramaMapper.update(isNull(), any())).willReturn(1); + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + + dramaService.adjustFavoriteCount(7L, -1); + + verify(dramaMapper).update(isNull(), captor.capture()); + Wrapper wrapper = captor.getValue(); + LambdaUpdateWrapper update = (LambdaUpdateWrapper) wrapper; + assertThat(update.getSqlSet()) + .contains("GREATEST(favorite_count - 1, 0)"); + assertThat(update.getSqlSegment()).contains("id"); + } +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/FavoriteServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/FavoriteServiceTest.java new file mode 100644 index 0000000..e1df968 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/FavoriteServiceTest.java @@ -0,0 +1,80 @@ +// Author: fruit.com.cn + +package com.fruitshorts.playback.service; + +import com.fruitshorts.content.service.DramaService; +import com.fruitshorts.playback.dal.entity.UserFavorite; +import com.fruitshorts.playback.dal.mapper.UserFavoriteMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +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; + +/** 收藏关系变化时同步维护短剧收藏数。 */ +class FavoriteServiceTest { + + private UserFavoriteMapper favoriteMapper; + private DramaService dramaService; + private FavoriteService favoriteService; + + @BeforeEach + void setUp() { + favoriteMapper = mock(UserFavoriteMapper.class); + dramaService = mock(DramaService.class); + favoriteService = new FavoriteService(favoriteMapper, dramaService); + } + + @Test + void should_increment_favorite_count_when_new_favorite_is_created() { + given(favoriteMapper.selectOne(any())).willReturn(null); + given(favoriteMapper.insert(any(UserFavorite.class))).willReturn(1); + + boolean favorite = favoriteService.toggle(1L, 7L); + + assertThat(favorite).isTrue(); + verify(dramaService).adjustFavoriteCount(7L, 1); + } + + @Test + void should_decrement_favorite_count_when_toggle_removes_relation() { + given(favoriteMapper.selectOne(any())).willReturn(new UserFavorite()); + given(favoriteMapper.physicalDelete(1L, 7L)).willReturn(1); + + boolean favorite = favoriteService.toggle(1L, 7L); + + assertThat(favorite).isFalse(); + verify(dramaService).adjustFavoriteCount(7L, -1); + } + + @Test + void should_only_decrement_when_remove_actually_deletes_relation() { + given(favoriteMapper.physicalDelete(1L, 7L)).willReturn(1); + given(favoriteMapper.physicalDelete(1L, 8L)).willReturn(0); + + favoriteService.remove(1L, 7L); + favoriteService.remove(1L, 8L); + + verify(dramaService).adjustFavoriteCount(7L, -1); + verify(dramaService, never()).adjustFavoriteCount(8L, -1); + } + + @Test + void should_decrement_each_deleted_drama_when_batch_remove_is_called() { + given(favoriteMapper.physicalDelete(1L, 7L)).willReturn(1); + given(favoriteMapper.physicalDelete(1L, 8L)).willReturn(0); + given(favoriteMapper.physicalDelete(1L, 9L)).willReturn(1); + + favoriteService.removeBatch(1L, Arrays.asList(7L, 8L, 9L, null)); + + verify(dramaService).adjustFavoriteCount(7L, -1); + verify(dramaService, never()).adjustFavoriteCount(8L, -1); + verify(dramaService).adjustFavoriteCount(9L, -1); + } +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java index d87443d..24072c3 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java @@ -260,6 +260,18 @@ public class DramaService { : "like_count = GREATEST(like_count - 1, 0)")); } + /** 收藏数原子增减:并发下用 SQL 自增,不做读改写,避免丢更新。 */ + public void adjustFavoriteCount(Long dramaId, int delta) { + if (dramaId == null || delta == 0) { + return; + } + dramaMapper.update(null, Wrappers.lambdaUpdate() + .eq(Drama::getId, dramaId) + .setSql(delta > 0 + ? "favorite_count = favorite_count + 1" + : "favorite_count = GREATEST(favorite_count - 1, 0)")); + } + /** 剧集数量变化后同步冗余字段。 */ public void syncTotalEpisodes(Long dramaId) { Long count = dramaEpisodeMapper.selectCount( diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java index 3c89f7e..0044932 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java @@ -15,7 +15,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; -/** 收藏:切换、列表、批量删除。计数走异步聚合,不在事务里更新统计列。 */ +/** 收藏:切换、列表、批量删除;收藏数在同一事务内同步维护。 */ @Service @RequiredArgsConstructor public class FavoriteService { @@ -33,7 +33,10 @@ public class FavoriteService { .last("limit 1")); if (existing != null) { // 必须物理删除:唯一索引 (user_id, drama_id) 会挡住"取消后再收藏" - favoriteMapper.physicalDelete(userId, dramaId); + int removed = favoriteMapper.physicalDelete(userId, dramaId); + if (removed > 0) { + dramaService.adjustFavoriteCount(dramaId, -1); + } return false; } UserFavorite favorite = new UserFavorite(); @@ -41,6 +44,7 @@ public class FavoriteService { favorite.setDramaId(dramaId); favorite.setSort(0); favoriteMapper.insert(favorite); + dramaService.adjustFavoriteCount(dramaId, 1); return true; } @@ -79,7 +83,10 @@ public class FavoriteService { @Transactional(rollbackFor = Exception.class) public void remove(Long userId, Long dramaId) { - favoriteMapper.physicalDelete(userId, dramaId); + int removed = favoriteMapper.physicalDelete(userId, dramaId); + if (removed > 0) { + dramaService.adjustFavoriteCount(dramaId, -1); + } } @Transactional(rollbackFor = Exception.class) @@ -87,12 +94,18 @@ public class FavoriteService { if (dramaIds == null || dramaIds.isEmpty()) { return; } - favoriteMapper.delete(Wrappers.lambdaQuery() - .eq(UserFavorite::getUserId, userId) - .in(UserFavorite::getDramaId, dramaIds)); + for (Long dramaId : dramaIds) { + if (dramaId == null) { + continue; + } + int removed = favoriteMapper.physicalDelete(userId, dramaId); + if (removed > 0) { + dramaService.adjustFavoriteCount(dramaId, -1); + } + } } - /** 供收藏数聚合任务调用,标记最近变更时间。 */ + /** 查询用户对某部剧最近一次收藏时间。 */ public LocalDateTime lastChangedAt(Long userId, Long dramaId) { UserFavorite favorite = favoriteMapper.selectOne(Wrappers.lambdaQuery() .eq(UserFavorite::getUserId, userId) diff --git a/sql/backfill_favorite_count.sql b/sql/backfill_favorite_count.sql new file mode 100644 index 0000000..19d235c --- /dev/null +++ b/sql/backfill_favorite_count.sql @@ -0,0 +1,10 @@ +-- Author: fruit.com.cn +-- K85:按有效收藏关系回填短剧收藏数,仅由验收/运维手动执行一次。 + +UPDATE drama d +SET favorite_count = ( + SELECT COUNT(*) + FROM user_favorite f + WHERE f.drama_id = d.id + AND f.deleted = 0 +);