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

+33 -1
View File
@@ -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`
@@ -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);
@@ -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()
@@ -206,19 +206,39 @@ public class FavoriteService {
}
@Transactional(rollbackFor = Exception.class)
public void removeBatch(Long userId, List<Long> dramaIds) {
public int removeBatch(Long userId, List<Long> dramaIds) {
return removeBatchInternal(new InteractionIdentity(userId, ""), dramaIds);
}
/** 登录账号或匿名设备在同一事务内批量取消收藏。 */
@Transactional(rollbackFor = Exception.class)
public int removeBatch(InteractionIdentity identity, List<Long> dramaIds) {
return removeBatchInternal(identity, dramaIds);
}
private int removeBatchInternal(
InteractionIdentity identity, List<Long> dramaIds) {
if (dramaIds == null || dramaIds.isEmpty()) {
return;
return 0;
}
Set<Long> 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;
}
/** 查询用户对某部剧最近一次收藏时间。 */
@@ -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;
}