feat(favorites): 支持设备批量取消收藏
This commit is contained in:
1 parent
5d44ac239b
commit
23321a0b0c
8 files changed
+147
-6
No files matched your search
+33
-1
@@ -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`
|
||||
|
||||
+14
@@ -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()));
|
||||
}
|
||||
}
|
||||
+15
@@ -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"))
|
||||
|
||||
+39
@@ -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 {
|
||||
|
||||
+16
-1
@@ -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);
|
||||
|
||||
+1
@@ -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()
|
||||
|
||||
+23
-3
@@ -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;
|
||||
}
|
||||
|
||||
/** 查询用户对某部剧最近一次收藏时间。 */
|
||||
|
||||
+6
-1
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user