feat(favorites): 支持设备批量取消收藏

This commit is contained in:
Zhou Weidong committed 2026-10-27 10:33:59 +08:00
1 parent 5d44ac239b
commit 23321a0b0c
8 files changed
+147 -6

No files matched your search

@@ -87,4 +87,18 @@ public class AppFavoriteController {
}
return Result.ok();
}
/** 批量取消收藏,返回实际删除的关系数。 */
@Operation(summary = "批量取消收藏")
@PostMapping("/remove")
public Result<Integer> 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()));
}
}
@@ -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"))
@@ -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 {
@@ -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);