From 23321a0b0c9ffd109c36f3805dcbe5c358845c47 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 27 Oct 2026 10:33:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(favorites):=20=E6=94=AF=E6=8C=81=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E6=89=B9=E9=87=8F=E5=8F=96=E6=B6=88=E6=94=B6=E8=97=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-phase1.md | 34 +++++++++++++++- .../app/controller/AppFavoriteController.java | 14 +++++++ .../AppCommentCountAnonymousSecurityTest.java | 15 +++++++ .../AppLibraryPagingContractTest.java | 39 +++++++++++++++++++ .../playback/service/FavoriteServiceTest.java | 17 +++++++- .../framework/security/SecurityConfig.java | 1 + .../playback/service/FavoriteService.java | 26 +++++++++++-- .../playback/vo/FavoriteRemoveReqVO.java | 7 +++- 8 files changed, 147 insertions(+), 6 deletions(-) diff --git a/docs/api-phase1.md b/docs/api-phase1.md index e14f4bd..bbcc9cd 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -360,7 +360,7 @@ app.register.max-per-device-per-day | POST | `/app/favorites/toggle` | 可选登录 | 收藏/取消收藏,返回切换后状态;匿名按 `X-Device-Id` | | GET | `/app/favorites` | 可选登录 | 收藏分页;匿名按设备读取,`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 | | DELETE | `/app/favorites/{dramaId}` | 可选登录 | 取消收藏;匿名按 `X-Device-Id` | -| POST | `/app/favorites/remove` | 登录 | 批量取消收藏,请求体 `{ "dramaIds": ["12", "15"] }` | +| POST | `/app/favorites/remove` | 可选登录 | 批量取消收藏;匿名按 `X-Device-Id`,返回实际删除数量 | 进度合并规则:同一集内只前进不后退;换集直接采纳新集;`finished` 只在接近片尾或客户端显式上报时置位。 这样用户换设备不会看到进度倒退。 @@ -371,6 +371,38 @@ app.register.max-per-device-per-day 同一部剧的进度先比较集号,同集再比较播放位置,最后用更新时间兜底,只保留更靠前的一条; 观看流水按剧集去重并保留更靠后的位置。`playCount` 只取最终保留记录,不重复累加。 +#### 批量取消收藏 + +`POST /app/favorites/remove` + +```json +{ + "dramaIds": ["12", "15"], + "deviceId": "optional-device-id" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `dramaIds` | array<Long> | 是 | 待取消的短剧 ID,最多 200 条,服务端去重后删除 | +| `deviceId` | string | 否 | 未登录且未携带 `X-Device-Id` 时的设备号兜底 | + +身份解析优先登录用户,其次 `X-Device-Id`,最后请求体 `deviceId`。登录用户按 +`user_id` 删除,匿名用户按 `device_id` 删除;匿名写操作沿用设备级限流。 +所有删除和剧级收藏数回退在单一事务内完成,其中任一步失败会整体回滚。 + +响应 `data` 为实际删除的收藏关系数: + +```json +{ + "code": 0, + "message": "ok", + "data": 2 +} +``` + +空列表返回业务码 `400`;超过 200 条同样返回 `400`。 + #### 收藏分页 `GET /app/favorites?pageIndex=1&pageSize=20` diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppFavoriteController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppFavoriteController.java index abf01f1..b1b2376 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppFavoriteController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppFavoriteController.java @@ -87,4 +87,18 @@ public class AppFavoriteController { } return Result.ok(); } + + /** 批量取消收藏,返回实际删除的关系数。 */ + @Operation(summary = "批量取消收藏") + @PostMapping("/remove") + public Result removeBatch( + @Valid @RequestBody FavoriteRemoveReqVO req, + HttpServletRequest request) { + InteractionIdentity identity = identityResolver.resolve( + SecurityUtils.getUserIdOrNull(), + request.getHeader("X-Device-Id"), + req.getDeviceId()); + anonymousRateLimiter.check(identity, "favorite"); + return Result.ok(favoriteService.removeBatch(identity, req.getDramaIds())); + } } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java index fcdbbf6..41f35c4 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java @@ -47,6 +47,7 @@ import java.util.List; import static org.mockito.BDDMockito.given; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.mock; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -90,6 +91,8 @@ class AppCommentCountAnonymousSecurityTest { given(episodeStatsService.get(11L, null, "device-a")).willReturn(stats); given(favoriteService.page(any(InteractionIdentity.class), any(Integer.class), any(Integer.class))) .willReturn(new PageResult<>(0L, List.of())); + given(favoriteService.removeBatch(any(InteractionIdentity.class), anyList())) + .willReturn(1); given(catalogService.toCards(List.of())).willReturn(List.of()); given(watchProgressService.recentPage( any(InteractionIdentity.class), any(Integer.class), any(Integer.class))) @@ -177,6 +180,18 @@ class AppCommentCountAnonymousSecurityTest { .andExpect(jsonPath("$.code").value(0)); } + @Test + void should_allow_anonymous_favorite_batch_removal_through_security_chain() + throws Exception { + mockMvc.perform(post("/app/favorites/remove") + .header("X-Device-Id", "device-a") + .contentType("application/json") + .content("{\"dramaIds\":[\"7\"]}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(1)); + } + @Test void should_keep_paid_and_entitlement_operations_login_required() throws Exception { mockMvc.perform(post("/app/episodes/11/unlock")) diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppLibraryPagingContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppLibraryPagingContractTest.java index 6038e70..52600d1 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppLibraryPagingContractTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppLibraryPagingContractTest.java @@ -22,6 +22,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.web.servlet.MockMvc; import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; import static org.mockito.BDDMockito.given; @@ -30,6 +31,7 @@ import static org.mockito.ArgumentMatchers.eq; 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.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -136,6 +138,43 @@ class AppLibraryPagingContractTest { verify(progressService).recentPage(any(InteractionIdentity.class), eq(2), eq(20)); } + @Test + void should_remove_favorite_batch_for_signed_in_user() throws Exception { + given(favoriteService.removeBatch(any(InteractionIdentity.class), eq(List.of(7L, 8L)))) + .willReturn(2); + + favoriteMvc.perform(post("/app/favorites/remove") + .contentType("application/json") + .content("{\"dramaIds\":[\"7\",\"8\"]}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(2)); + + verify(favoriteService).removeBatch(any(InteractionIdentity.class), eq(List.of(7L, 8L))); + } + + @Test + void should_reject_empty_favorite_batch() throws Exception { + favoriteMvc.perform(post("/app/favorites/remove") + .contentType("application/json") + .content("{\"dramaIds\":[]}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(400)) + .andExpect(jsonPath("$.message").value("dramaIds 不能为空")); + } + + @Test + void should_reject_favorite_batch_over_limit() throws Exception { + String dramaIds = String.join(",", Collections.nCopies(201, "\"7\"")); + + favoriteMvc.perform(post("/app/favorites/remove") + .contentType("application/json") + .content("{\"dramaIds\":[" + dramaIds + "]}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(400)) + .andExpect(jsonPath("$.message").value("dramaIds 最多 200 条")); + } + @Test void should_keep_total_and_return_empty_list_when_watch_page_is_out_of_range() throws Exception { 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 index 2bdc863..dd5229d 100644 --- 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 @@ -120,13 +120,28 @@ class FavoriteServiceTest { given(favoriteMapper.physicalDelete(1L, 8L)).willReturn(0); given(favoriteMapper.physicalDelete(1L, 9L)).willReturn(1); - favoriteService.removeBatch(1L, Arrays.asList(7L, 8L, 9L, null)); + int deleted = + favoriteService.removeBatch(1L, Arrays.asList(7L, 8L, 9L, 7L, null)); + assertThat(deleted).isEqualTo(2); verify(dramaService).adjustFavoriteCount(7L, -1); verify(dramaService, never()).adjustFavoriteCount(8L, -1); verify(dramaService).adjustFavoriteCount(9L, -1); } + @Test + void should_remove_anonymous_batch_by_device_and_return_deleted_count() { + given(favoriteMapper.physicalDeleteDevice("device-a", 7L)).willReturn(1); + given(favoriteMapper.physicalDeleteDevice("device-a", 8L)).willReturn(1); + + int deleted = favoriteService.removeBatch( + new InteractionIdentity(null, "device-a"), List.of(7L, 8L)); + + assertThat(deleted).isEqualTo(2); + verify(dramaService).adjustFavoriteCount(7L, -1); + verify(dramaService).adjustFavoriteCount(8L, -1); + } + @Test void should_use_database_page_and_keep_favorite_order_when_listing() { UserFavorite first = favorite(51L); diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java index 7c0a231..c637ada 100644 --- a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java @@ -84,6 +84,7 @@ public class SecurityConfig { /* 非付费互动允许设备身份;解锁、钱包与权益接口仍只允许登录用户。 */ .requestMatchers(HttpMethod.POST, "/app/likes/toggle").permitAll() .requestMatchers(HttpMethod.POST, "/app/favorites/toggle").permitAll() + .requestMatchers(HttpMethod.POST, "/app/favorites/remove").permitAll() .requestMatchers(HttpMethod.DELETE, "/app/favorites/*").permitAll() .requestMatchers(HttpMethod.POST, "/app/comments").permitAll() .requestMatchers(HttpMethod.DELETE, "/app/comments/*").permitAll() 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 9236504..76094db 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 @@ -206,19 +206,39 @@ public class FavoriteService { } @Transactional(rollbackFor = Exception.class) - public void removeBatch(Long userId, List dramaIds) { + public int removeBatch(Long userId, List dramaIds) { + return removeBatchInternal(new InteractionIdentity(userId, ""), dramaIds); + } + + /** 登录账号或匿名设备在同一事务内批量取消收藏。 */ + @Transactional(rollbackFor = Exception.class) + public int removeBatch(InteractionIdentity identity, List dramaIds) { + return removeBatchInternal(identity, dramaIds); + } + + private int removeBatchInternal( + InteractionIdentity identity, List dramaIds) { if (dramaIds == null || dramaIds.isEmpty()) { - return; + return 0; } + Set normalized = new HashSet<>(); for (Long dramaId : dramaIds) { if (dramaId == null) { continue; } - int removed = favoriteMapper.physicalDelete(userId, dramaId); + normalized.add(dramaId); + } + int deleted = 0; + for (Long dramaId : normalized) { + int removed = identity.authenticated() + ? favoriteMapper.physicalDelete(identity.userId(), dramaId) + : favoriteMapper.physicalDeleteDevice(identity.deviceId(), dramaId); if (removed > 0) { dramaService.adjustFavoriteCount(dramaId, -1); + deleted += removed; } } + return deleted; } /** 查询用户对某部剧最近一次收藏时间。 */ diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/FavoriteRemoveReqVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/FavoriteRemoveReqVO.java index 7dda519..2bb6503 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/FavoriteRemoveReqVO.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/FavoriteRemoveReqVO.java @@ -4,6 +4,7 @@ package com.fruitshorts.playback.vo; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.util.List; import lombok.Data; @@ -11,6 +12,10 @@ import lombok.Data; @Data public class FavoriteRemoveReqVO { - @NotEmpty(message = "缺少 dramaIds") + @NotEmpty(message = "dramaIds 不能为空") + @Size(max = 200, message = "dramaIds 最多 200 条") private List<@NotNull Long> dramaIds; + + /** 未登录且未携带 X-Device-Id 时的兜底设备号。 */ + private String deviceId; }