feat(watch): 支持设备观看与登录合并
This commit is contained in:
1 parent
4b0fbd73d3
commit
848c746505
14 files changed
+613
-70
No files matched your search
+17
-11
@@ -350,21 +350,27 @@ app.register.max-per-device-per-day
|
|||||||
|
|
||||||
### 1.5 观看记录与收藏(已实现)
|
### 1.5 观看记录与收藏(已实现)
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 鉴权 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| POST | `/app/watch/progress` | 上报进度(建议每 10s 一次,切集时补一次) |
|
| POST | `/app/watch/progress` | 可选登录 | 上报进度;匿名按 `X-Device-Id`,建议每 10s 一次,切集时补一次 |
|
||||||
| GET | `/app/watch/progress?dramaId=` | 某剧续看位置 |
|
| GET | `/app/watch/progress?dramaId=` | 可选登录 | 某剧续看位置,匿名按设备读取 |
|
||||||
| GET | `/app/watch/recent` | 最近观看分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 |
|
| GET | `/app/watch/recent` | 可选登录 | 最近观看分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 |
|
||||||
| DELETE | `/app/watch/progress/{dramaId}` | 删除某剧记录 |
|
| DELETE | `/app/watch/progress/{dramaId}` | 可选登录 | 删除当前账号或设备的某剧记录 |
|
||||||
| DELETE | `/app/watch/progress` | 清空记录 |
|
| DELETE | `/app/watch/progress` | 可选登录 | 清空当前账号或设备的记录 |
|
||||||
| POST | `/app/favorites/toggle` | 可选登录 | 收藏/取消收藏,返回切换后状态;匿名按 `X-Device-Id` |
|
| POST | `/app/favorites/toggle` | 可选登录 | 收藏/取消收藏,返回切换后状态;匿名按 `X-Device-Id` |
|
||||||
| GET | `/app/favorites` | 收藏分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 |
|
| GET | `/app/favorites` | 可选登录 | 收藏分页;匿名按设备读取,`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 |
|
||||||
| DELETE | `/app/favorites/{dramaId}` | 可选登录 | 取消收藏;匿名按 `X-Device-Id` |
|
| DELETE | `/app/favorites/{dramaId}` | 可选登录 | 取消收藏;匿名按 `X-Device-Id` |
|
||||||
| POST | `/app/favorites/remove` | 批量取消收藏,请求体 `{ "dramaIds": ["12", "15"] }` |
|
| POST | `/app/favorites/remove` | 登录 | 批量取消收藏,请求体 `{ "dramaIds": ["12", "15"] }` |
|
||||||
|
|
||||||
进度合并规则:同一集内只前进不后退;换集直接采纳新集;`finished` 只在接近片尾或客户端显式上报时置位。
|
进度合并规则:同一集内只前进不后退;换集直接采纳新集;`finished` 只在接近片尾或客户端显式上报时置位。
|
||||||
这样用户换设备不会看到进度倒退。
|
这样用户换设备不会看到进度倒退。
|
||||||
|
|
||||||
|
收藏与观看数据的身份解析顺序统一为登录 `userId` → `X-Device-Id` → 请求参数或请求体
|
||||||
|
`deviceId`,全部缺失时返回业务码 `200007`。匿名读取不按设备限流;上报、删除和清空等写操作
|
||||||
|
沿用设备级限流。登录或注册成功后,同一事务会把设备收藏、观看进度和观看流水合并到账号:
|
||||||
|
同一部剧的进度先比较集号,同集再比较播放位置,最后用更新时间兜底,只保留更靠前的一条;
|
||||||
|
观看流水按剧集去重并保留更靠后的位置。`playCount` 只取最终保留记录,不重复累加。
|
||||||
|
|
||||||
#### 收藏分页
|
#### 收藏分页
|
||||||
|
|
||||||
`GET /app/favorites?pageIndex=1&pageSize=20`
|
`GET /app/favorites?pageIndex=1&pageSize=20`
|
||||||
@@ -484,8 +490,8 @@ app.register.max-per-device-per-day
|
|||||||
**非付费互动身份与合并**:点赞、收藏、评论的身份解析顺序为登录 `userId` →
|
**非付费互动身份与合并**:点赞、收藏、评论的身份解析顺序为登录 `userId` →
|
||||||
`X-Device-Id` → 请求体 `deviceId`。三者都没有时返回业务码 `200007`。匿名写入按设备号
|
`X-Device-Id` → 请求体 `deviceId`。三者都没有时返回业务码 `200007`。匿名写入按设备号
|
||||||
每分钟限流;评论同时做长度、空白、短时间重复内容和频率校验。登录或注册成功后,服务端
|
每分钟限流;评论同时做长度、空白、短时间重复内容和频率校验。登录或注册成功后,服务端
|
||||||
在同一登录事务内把该设备未归属的点赞、收藏、评论迁移到账号;账号已有同内容时保留账号
|
在同一登录事务内把该设备未归属的点赞、收藏、评论、观看进度和观看流水迁移到账号;
|
||||||
记录并删除设备重复记录,计数同步回退,不重复累加。
|
账号已有同内容时保留账号记录并删除设备重复记录,计数同步回退,不重复累加。
|
||||||
|
|
||||||
匿名评论展示为「游客」,头像为空占位。解锁、金币/VIP 权益、锁定集正片票据不在本规则内,
|
匿名评论展示为「游客」,头像为空占位。解锁、金币/VIP 权益、锁定集正片票据不在本规则内,
|
||||||
仍必须登录。
|
仍必须登录。
|
||||||
|
|||||||
+8
-2
@@ -59,9 +59,15 @@ public class AppFavoriteController {
|
|||||||
@GetMapping
|
@GetMapping
|
||||||
public Result<PageResult<AppDramaCardVO>> list(
|
public Result<PageResult<AppDramaCardVO>> list(
|
||||||
@RequestParam(defaultValue = "1") int pageIndex,
|
@RequestParam(defaultValue = "1") int pageIndex,
|
||||||
@RequestParam(defaultValue = "20") int pageSize) {
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
@RequestParam(required = false) String deviceId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
InteractionIdentity identity = identityResolver.resolve(
|
||||||
|
SecurityUtils.getUserIdOrNull(),
|
||||||
|
request.getHeader("X-Device-Id"),
|
||||||
|
deviceId);
|
||||||
PageResult<Drama> page = favoriteService.page(
|
PageResult<Drama> page = favoriteService.page(
|
||||||
SecurityUtils.getAppUserId(), pageIndex, pageSize);
|
identity, pageIndex, pageSize);
|
||||||
return Result.ok(new PageResult<>(
|
return Result.ok(new PageResult<>(
|
||||||
page.getTotal(), appCatalogService.toCards(page.getList())));
|
page.getTotal(), appCatalogService.toCards(page.getList())));
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-10
@@ -4,6 +4,9 @@ package com.fruitshorts.app.controller;
|
|||||||
|
|
||||||
import com.fruitshorts.common.pojo.PageResult;
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
import com.fruitshorts.common.pojo.Result;
|
import com.fruitshorts.common.pojo.Result;
|
||||||
|
import com.fruitshorts.common.interaction.InteractionIdentity;
|
||||||
|
import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
|
||||||
|
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
|
||||||
import com.fruitshorts.framework.security.SecurityUtils;
|
import com.fruitshorts.framework.security.SecurityUtils;
|
||||||
import com.fruitshorts.playback.service.WatchProgressService;
|
import com.fruitshorts.playback.service.WatchProgressService;
|
||||||
import com.fruitshorts.playback.vo.ProgressRespVO;
|
import com.fruitshorts.playback.vo.ProgressRespVO;
|
||||||
@@ -11,6 +14,7 @@ import com.fruitshorts.playback.vo.ProgressSaveReqVO;
|
|||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -29,19 +33,34 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
public class AppWatchController {
|
public class AppWatchController {
|
||||||
|
|
||||||
private final WatchProgressService progressService;
|
private final WatchProgressService progressService;
|
||||||
|
private final InteractionIdentityResolver identityResolver;
|
||||||
|
private final AnonymousInteractionRateLimiter anonymousRateLimiter;
|
||||||
|
|
||||||
/** 上报进度:客户端每 10 秒一次,切集时立即上报一次。 */
|
/** 上报进度:客户端每 10 秒一次,切集时立即上报一次。 */
|
||||||
@Operation(summary = "上报观看进度(客户端每 10 秒一次)")
|
@Operation(summary = "上报观看进度(客户端每 10 秒一次)")
|
||||||
@PostMapping("/progress")
|
@PostMapping("/progress")
|
||||||
public Result<ProgressRespVO> save(@Valid @RequestBody ProgressSaveReqVO req) {
|
public Result<ProgressRespVO> save(@Valid @RequestBody ProgressSaveReqVO req,
|
||||||
return Result.ok(progressService.save(SecurityUtils.getAppUserId(), req));
|
HttpServletRequest request) {
|
||||||
|
InteractionIdentity identity = identityResolver.resolve(
|
||||||
|
SecurityUtils.getUserIdOrNull(),
|
||||||
|
request.getHeader("X-Device-Id"),
|
||||||
|
req.getDeviceId());
|
||||||
|
anonymousRateLimiter.check(identity, "watch");
|
||||||
|
return Result.ok(progressService.save(identity, req));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 某部剧的续看位置,进详情页时用它决定"续看第 N 集"。 */
|
/** 某部剧的续看位置,进详情页时用它决定"续看第 N 集"。 */
|
||||||
@Operation(summary = "某部剧的续看位置")
|
@Operation(summary = "某部剧的续看位置")
|
||||||
@GetMapping("/progress")
|
@GetMapping("/progress")
|
||||||
public Result<ProgressRespVO> ofDrama(@RequestParam Long dramaId) {
|
public Result<ProgressRespVO> ofDrama(
|
||||||
return Result.ok(progressService.ofDrama(SecurityUtils.getAppUserId(), dramaId));
|
@RequestParam Long dramaId,
|
||||||
|
@RequestParam(required = false) String deviceId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
InteractionIdentity identity = identityResolver.resolve(
|
||||||
|
SecurityUtils.getUserIdOrNull(),
|
||||||
|
request.getHeader("X-Device-Id"),
|
||||||
|
deviceId);
|
||||||
|
return Result.ok(progressService.ofDrama(identity, dramaId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 最近观看分页,契约与 /app/favorites 一致:pageIndex/pageSize。 */
|
/** 最近观看分页,契约与 /app/favorites 一致:pageIndex/pageSize。 */
|
||||||
@@ -49,22 +68,43 @@ public class AppWatchController {
|
|||||||
@GetMapping("/recent")
|
@GetMapping("/recent")
|
||||||
public Result<PageResult<ProgressRespVO>> recent(
|
public Result<PageResult<ProgressRespVO>> recent(
|
||||||
@RequestParam(defaultValue = "1") int pageIndex,
|
@RequestParam(defaultValue = "1") int pageIndex,
|
||||||
@RequestParam(defaultValue = "20") int pageSize) {
|
@RequestParam(defaultValue = "20") int pageSize,
|
||||||
|
@RequestParam(required = false) String deviceId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
InteractionIdentity identity = identityResolver.resolve(
|
||||||
|
SecurityUtils.getUserIdOrNull(),
|
||||||
|
request.getHeader("X-Device-Id"),
|
||||||
|
deviceId);
|
||||||
return Result.ok(progressService.recentPage(
|
return Result.ok(progressService.recentPage(
|
||||||
SecurityUtils.getAppUserId(), pageIndex, pageSize));
|
identity, pageIndex, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "删除某部剧的观看记录")
|
@Operation(summary = "删除某部剧的观看记录")
|
||||||
@DeleteMapping("/progress/{dramaId}")
|
@DeleteMapping("/progress/{dramaId}")
|
||||||
public Result<Void> remove(@PathVariable Long dramaId) {
|
public Result<Void> remove(
|
||||||
progressService.remove(SecurityUtils.getAppUserId(), dramaId);
|
@PathVariable Long dramaId,
|
||||||
|
@RequestParam(required = false) String deviceId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
InteractionIdentity identity = identityResolver.resolve(
|
||||||
|
SecurityUtils.getUserIdOrNull(),
|
||||||
|
request.getHeader("X-Device-Id"),
|
||||||
|
deviceId);
|
||||||
|
anonymousRateLimiter.check(identity, "watch");
|
||||||
|
progressService.remove(identity, dramaId);
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "清空观看记录")
|
@Operation(summary = "清空观看记录")
|
||||||
@DeleteMapping("/progress")
|
@DeleteMapping("/progress")
|
||||||
public Result<Void> clear() {
|
public Result<Void> clear(
|
||||||
progressService.clear(SecurityUtils.getAppUserId());
|
@RequestParam(required = false) String deviceId,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
InteractionIdentity identity = identityResolver.resolve(
|
||||||
|
SecurityUtils.getUserIdOrNull(),
|
||||||
|
request.getHeader("X-Device-Id"),
|
||||||
|
deviceId);
|
||||||
|
anonymousRateLimiter.check(identity, "watch");
|
||||||
|
progressService.clear(identity);
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+12
-2
@@ -5,6 +5,7 @@ package com.fruitshorts.app.service;
|
|||||||
import com.fruitshorts.interaction.service.CommentService;
|
import com.fruitshorts.interaction.service.CommentService;
|
||||||
import com.fruitshorts.interaction.service.LikeService;
|
import com.fruitshorts.interaction.service.LikeService;
|
||||||
import com.fruitshorts.playback.service.FavoriteService;
|
import com.fruitshorts.playback.service.FavoriteService;
|
||||||
|
import com.fruitshorts.playback.service.WatchProgressService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -19,6 +20,7 @@ public class InteractionAccountMergeService {
|
|||||||
private final LikeService likeService;
|
private final LikeService likeService;
|
||||||
private final FavoriteService favoriteService;
|
private final FavoriteService favoriteService;
|
||||||
private final CommentService commentService;
|
private final CommentService commentService;
|
||||||
|
private final WatchProgressService watchProgressService;
|
||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void merge(Long userId, String deviceId) {
|
public void merge(Long userId, String deviceId) {
|
||||||
@@ -27,11 +29,15 @@ public class InteractionAccountMergeService {
|
|||||||
favoriteService.mergeDeviceIntoUser(userId, deviceId);
|
favoriteService.mergeDeviceIntoUser(userId, deviceId);
|
||||||
CommentService.MergeResult comments =
|
CommentService.MergeResult comments =
|
||||||
commentService.mergeDeviceIntoUser(userId, deviceId);
|
commentService.mergeDeviceIntoUser(userId, deviceId);
|
||||||
|
WatchProgressService.MergeResult watch =
|
||||||
|
watchProgressService.mergeDeviceIntoUser(userId, deviceId);
|
||||||
log.info(
|
log.info(
|
||||||
"interaction-merge userId={} deviceId={} "
|
"interaction-merge userId={} deviceId={} "
|
||||||
+ "likeMigrated={} likeDuplicates={} "
|
+ "likeMigrated={} likeDuplicates={} "
|
||||||
+ "favoriteMigrated={} favoriteDuplicates={} "
|
+ "favoriteMigrated={} favoriteDuplicates={} "
|
||||||
+ "commentMigrated={} commentDuplicates={}",
|
+ "commentMigrated={} commentDuplicates={} "
|
||||||
|
+ "watchProgressMigrated={} watchProgressIgnored={} "
|
||||||
|
+ "watchHistoryMigrated={} watchHistoryIgnored={}",
|
||||||
userId,
|
userId,
|
||||||
deviceId,
|
deviceId,
|
||||||
likes.migrated(),
|
likes.migrated(),
|
||||||
@@ -39,6 +45,10 @@ public class InteractionAccountMergeService {
|
|||||||
favorites.migrated(),
|
favorites.migrated(),
|
||||||
favorites.duplicates(),
|
favorites.duplicates(),
|
||||||
comments.migrated(),
|
comments.migrated(),
|
||||||
comments.duplicates());
|
comments.duplicates(),
|
||||||
|
watch.progressMigrated(),
|
||||||
|
watch.progressIgnored(),
|
||||||
|
watch.historyMigrated(),
|
||||||
|
watch.historyIgnored());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+65
@@ -8,9 +8,13 @@ import com.fruitshorts.app.service.AppCatalogService;
|
|||||||
import com.fruitshorts.app.vo.AppEpisodeStatsVO;
|
import com.fruitshorts.app.vo.AppEpisodeStatsVO;
|
||||||
import com.fruitshorts.common.exception.ErrorCode;
|
import com.fruitshorts.common.exception.ErrorCode;
|
||||||
import com.fruitshorts.common.pojo.CursorResult;
|
import com.fruitshorts.common.pojo.CursorResult;
|
||||||
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
|
import com.fruitshorts.common.interaction.InteractionIdentity;
|
||||||
import com.fruitshorts.interaction.service.LikeService;
|
import com.fruitshorts.interaction.service.LikeService;
|
||||||
import com.fruitshorts.playback.service.FavoriteService;
|
import com.fruitshorts.playback.service.FavoriteService;
|
||||||
import com.fruitshorts.playback.service.PlaybackService;
|
import com.fruitshorts.playback.service.PlaybackService;
|
||||||
|
import com.fruitshorts.playback.service.WatchProgressService;
|
||||||
|
import com.fruitshorts.playback.vo.ProgressRespVO;
|
||||||
import com.fruitshorts.content.service.EpisodeService;
|
import com.fruitshorts.content.service.EpisodeService;
|
||||||
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
|
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
|
||||||
import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
|
import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
|
||||||
@@ -42,9 +46,11 @@ import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.mockito.BDDMockito.given;
|
import static org.mockito.BDDMockito.given;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.mock;
|
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.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@@ -63,6 +69,10 @@ class AppCommentCountAnonymousSecurityTest {
|
|||||||
private AppCatalogService catalogService;
|
private AppCatalogService catalogService;
|
||||||
@Autowired
|
@Autowired
|
||||||
private AppEpisodeStatsService episodeStatsService;
|
private AppEpisodeStatsService episodeStatsService;
|
||||||
|
@Autowired
|
||||||
|
private FavoriteService favoriteService;
|
||||||
|
@Autowired
|
||||||
|
private WatchProgressService watchProgressService;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
@@ -78,6 +88,16 @@ class AppCommentCountAnonymousSecurityTest {
|
|||||||
stats.setLiked(true);
|
stats.setLiked(true);
|
||||||
stats.setFavorited(false);
|
stats.setFavorited(false);
|
||||||
given(episodeStatsService.get(11L, null, "device-a")).willReturn(stats);
|
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(catalogService.toCards(List.of())).willReturn(List.of());
|
||||||
|
given(watchProgressService.recentPage(
|
||||||
|
any(InteractionIdentity.class), any(Integer.class), any(Integer.class)))
|
||||||
|
.willReturn(new PageResult<>(0L, List.of()));
|
||||||
|
given(watchProgressService.ofDrama(any(InteractionIdentity.class), any(Long.class)))
|
||||||
|
.willReturn(null);
|
||||||
|
given(watchProgressService.save(any(InteractionIdentity.class), any()))
|
||||||
|
.willReturn(new ProgressRespVO());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -128,6 +148,35 @@ class AppCommentCountAnonymousSecurityTest {
|
|||||||
.andExpect(jsonPath("$.data.favoriteCount").value(8));
|
.andExpect(jsonPath("$.data.favoriteCount").value(8));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_allow_anonymous_library_and_watch_operations() throws Exception {
|
||||||
|
mockMvc.perform(get("/app/favorites").header("X-Device-Id", "device-a"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
mockMvc.perform(get("/app/watch/recent").header("X-Device-Id", "device-a"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
mockMvc.perform(get("/app/watch/progress")
|
||||||
|
.header("X-Device-Id", "device-a")
|
||||||
|
.param("dramaId", "7"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
mockMvc.perform(post("/app/watch/progress")
|
||||||
|
.header("X-Device-Id", "device-a")
|
||||||
|
.contentType("application/json")
|
||||||
|
.content("{\"episodeId\":11,\"positionMs\":1000}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
mockMvc.perform(delete("/app/watch/progress/7")
|
||||||
|
.header("X-Device-Id", "device-a"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
mockMvc.perform(delete("/app/watch/progress")
|
||||||
|
.header("X-Device-Id", "device-a"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void should_keep_paid_and_entitlement_operations_login_required() throws Exception {
|
void should_keep_paid_and_entitlement_operations_login_required() throws Exception {
|
||||||
mockMvc.perform(post("/app/episodes/11/unlock"))
|
mockMvc.perform(post("/app/episodes/11/unlock"))
|
||||||
@@ -183,6 +232,11 @@ class AppCommentCountAnonymousSecurityTest {
|
|||||||
return mock(FavoriteService.class);
|
return mock(FavoriteService.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
WatchProgressService watchProgressService() {
|
||||||
|
return mock(WatchProgressService.class);
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
AppInteractionController appInteractionController(
|
AppInteractionController appInteractionController(
|
||||||
LikeService likeService,
|
LikeService likeService,
|
||||||
@@ -207,6 +261,17 @@ class AppCommentCountAnonymousSecurityTest {
|
|||||||
rateLimiter);
|
rateLimiter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
AppWatchController appWatchController(
|
||||||
|
WatchProgressService watchProgressService,
|
||||||
|
InteractionIdentityResolver identityResolver,
|
||||||
|
AnonymousInteractionRateLimiter rateLimiter) {
|
||||||
|
return new AppWatchController(
|
||||||
|
watchProgressService,
|
||||||
|
identityResolver,
|
||||||
|
rateLimiter);
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
AppEpisodeStatsService appEpisodeStatsService() {
|
AppEpisodeStatsService appEpisodeStatsService() {
|
||||||
return mock(AppEpisodeStatsService.class);
|
return mock(AppEpisodeStatsService.class);
|
||||||
|
|||||||
+20
-14
@@ -6,6 +6,7 @@ import com.fruitshorts.app.service.AppCatalogService;
|
|||||||
import com.fruitshorts.app.support.ControllerContractSupport;
|
import com.fruitshorts.app.support.ControllerContractSupport;
|
||||||
import com.fruitshorts.app.vo.AppDramaCardVO;
|
import com.fruitshorts.app.vo.AppDramaCardVO;
|
||||||
import com.fruitshorts.common.pojo.PageResult;
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
|
import com.fruitshorts.common.interaction.InteractionIdentity;
|
||||||
import com.fruitshorts.content.dal.entity.Drama;
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
import com.fruitshorts.framework.security.LoginUser;
|
import com.fruitshorts.framework.security.LoginUser;
|
||||||
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
|
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
|
||||||
@@ -24,6 +25,8 @@ import java.time.LocalDateTime;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.mockito.BDDMockito.given;
|
import static org.mockito.BDDMockito.given;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.verify;
|
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.get;
|
||||||
@@ -46,14 +49,17 @@ class AppLibraryPagingContractTest {
|
|||||||
favoriteService = mock(FavoriteService.class);
|
favoriteService = mock(FavoriteService.class);
|
||||||
catalogService = mock(AppCatalogService.class);
|
catalogService = mock(AppCatalogService.class);
|
||||||
progressService = mock(WatchProgressService.class);
|
progressService = mock(WatchProgressService.class);
|
||||||
|
InteractionIdentityResolver identityResolver = new InteractionIdentityResolver();
|
||||||
|
AnonymousInteractionRateLimiter rateLimiter =
|
||||||
|
mock(AnonymousInteractionRateLimiter.class);
|
||||||
favoriteMvc = ControllerContractSupport.mockMvcOf(
|
favoriteMvc = ControllerContractSupport.mockMvcOf(
|
||||||
new AppFavoriteController(
|
new AppFavoriteController(
|
||||||
favoriteService,
|
favoriteService,
|
||||||
catalogService,
|
catalogService,
|
||||||
mock(InteractionIdentityResolver.class),
|
identityResolver,
|
||||||
mock(AnonymousInteractionRateLimiter.class)));
|
rateLimiter));
|
||||||
watchMvc = ControllerContractSupport.mockMvcOf(
|
watchMvc = ControllerContractSupport.mockMvcOf(
|
||||||
new AppWatchController(progressService));
|
new AppWatchController(progressService, identityResolver, rateLimiter));
|
||||||
SecurityContextHolder.getContext().setAuthentication(
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
new UsernamePasswordAuthenticationToken(
|
new UsernamePasswordAuthenticationToken(
|
||||||
new LoginUser(USER_ID, "app-user", "APP_USER", List.of()),
|
new LoginUser(USER_ID, "app-user", "APP_USER", List.of()),
|
||||||
@@ -72,9 +78,9 @@ class AppLibraryPagingContractTest {
|
|||||||
Drama first = drama(101L, "收藏一");
|
Drama first = drama(101L, "收藏一");
|
||||||
Drama second = drama(102L, "收藏二");
|
Drama second = drama(102L, "收藏二");
|
||||||
Drama third = drama(103L, "收藏三");
|
Drama third = drama(103L, "收藏三");
|
||||||
given(favoriteService.page(USER_ID, 1, 20))
|
given(favoriteService.page(any(InteractionIdentity.class), eq(1), eq(20)))
|
||||||
.willReturn(new PageResult<>(21L, List.of(first, second)));
|
.willReturn(new PageResult<>(21L, List.of(first, second)));
|
||||||
given(favoriteService.page(USER_ID, 2, 20))
|
given(favoriteService.page(any(InteractionIdentity.class), eq(2), eq(20)))
|
||||||
.willReturn(new PageResult<>(21L, List.of(third)));
|
.willReturn(new PageResult<>(21L, List.of(third)));
|
||||||
given(catalogService.toCards(List.of(first, second)))
|
given(catalogService.toCards(List.of(first, second)))
|
||||||
.willReturn(List.of(card(101L), card(102L)));
|
.willReturn(List.of(card(101L), card(102L)));
|
||||||
@@ -95,8 +101,8 @@ class AppLibraryPagingContractTest {
|
|||||||
.andExpect(jsonPath("$.data.list.length()").value(1))
|
.andExpect(jsonPath("$.data.list.length()").value(1))
|
||||||
.andExpect(jsonPath("$.data.list[0].id").value("103"));
|
.andExpect(jsonPath("$.data.list[0].id").value("103"));
|
||||||
|
|
||||||
verify(favoriteService).page(USER_ID, 1, 20);
|
verify(favoriteService).page(any(InteractionIdentity.class), eq(1), eq(20));
|
||||||
verify(favoriteService).page(USER_ID, 2, 20);
|
verify(favoriteService).page(any(InteractionIdentity.class), eq(2), eq(20));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -104,9 +110,9 @@ class AppLibraryPagingContractTest {
|
|||||||
ProgressRespVO first = progress(12L, 88L, 6, "剧名一", 68);
|
ProgressRespVO first = progress(12L, 88L, 6, "剧名一", 68);
|
||||||
ProgressRespVO second = progress(13L, 99L, 2, "剧名二", 20);
|
ProgressRespVO second = progress(13L, 99L, 2, "剧名二", 20);
|
||||||
ProgressRespVO third = progress(14L, 100L, 1, "剧名三", 16);
|
ProgressRespVO third = progress(14L, 100L, 1, "剧名三", 16);
|
||||||
given(progressService.recentPage(USER_ID, 1, 20))
|
given(progressService.recentPage(any(InteractionIdentity.class), eq(1), eq(20)))
|
||||||
.willReturn(new PageResult<>(23L, List.of(first, second)));
|
.willReturn(new PageResult<>(23L, List.of(first, second)));
|
||||||
given(progressService.recentPage(USER_ID, 2, 20))
|
given(progressService.recentPage(any(InteractionIdentity.class), eq(2), eq(20)))
|
||||||
.willReturn(new PageResult<>(23L, List.of(third)));
|
.willReturn(new PageResult<>(23L, List.of(third)));
|
||||||
|
|
||||||
watchMvc.perform(get("/app/watch/recent"))
|
watchMvc.perform(get("/app/watch/recent"))
|
||||||
@@ -126,14 +132,14 @@ class AppLibraryPagingContractTest {
|
|||||||
.andExpect(jsonPath("$.data.list[0].dramaId").value("14"))
|
.andExpect(jsonPath("$.data.list[0].dramaId").value("14"))
|
||||||
.andExpect(jsonPath("$.data.list[0].dramaTitle").value("剧名三"));
|
.andExpect(jsonPath("$.data.list[0].dramaTitle").value("剧名三"));
|
||||||
|
|
||||||
verify(progressService).recentPage(USER_ID, 1, 20);
|
verify(progressService).recentPage(any(InteractionIdentity.class), eq(1), eq(20));
|
||||||
verify(progressService).recentPage(USER_ID, 2, 20);
|
verify(progressService).recentPage(any(InteractionIdentity.class), eq(2), eq(20));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void should_keep_total_and_return_empty_list_when_watch_page_is_out_of_range()
|
void should_keep_total_and_return_empty_list_when_watch_page_is_out_of_range()
|
||||||
throws Exception {
|
throws Exception {
|
||||||
given(progressService.recentPage(USER_ID, 9, 20))
|
given(progressService.recentPage(any(InteractionIdentity.class), eq(9), eq(20)))
|
||||||
.willReturn(new PageResult<>(23L, List.of()));
|
.willReturn(new PageResult<>(23L, List.of()));
|
||||||
|
|
||||||
watchMvc.perform(get("/app/watch/recent").param("pageIndex", "9"))
|
watchMvc.perform(get("/app/watch/recent").param("pageIndex", "9"))
|
||||||
@@ -146,7 +152,7 @@ class AppLibraryPagingContractTest {
|
|||||||
@Test
|
@Test
|
||||||
void should_ignore_removed_limit_and_still_return_page_result() throws Exception {
|
void should_ignore_removed_limit_and_still_return_page_result() throws Exception {
|
||||||
// limit 已从契约移除:即使客户端误传,也应走分页并返回 PageResult。
|
// limit 已从契约移除:即使客户端误传,也应走分页并返回 PageResult。
|
||||||
given(progressService.recentPage(USER_ID, 1, 20))
|
given(progressService.recentPage(any(InteractionIdentity.class), eq(1), eq(20)))
|
||||||
.willReturn(new PageResult<>(3L, List.of(progress(12L, 88L, 6, "剧名一", 68))));
|
.willReturn(new PageResult<>(3L, List.of(progress(12L, 88L, 6, "剧名一", 68))));
|
||||||
|
|
||||||
watchMvc.perform(get("/app/watch/recent").param("limit", "5"))
|
watchMvc.perform(get("/app/watch/recent").param("limit", "5"))
|
||||||
@@ -156,7 +162,7 @@ class AppLibraryPagingContractTest {
|
|||||||
.andExpect(jsonPath("$.data.list.length()").value(1))
|
.andExpect(jsonPath("$.data.list.length()").value(1))
|
||||||
.andExpect(jsonPath("$.data.list[0].dramaTitle").value("剧名一"));
|
.andExpect(jsonPath("$.data.list[0].dramaTitle").value("剧名一"));
|
||||||
|
|
||||||
verify(progressService).recentPage(USER_ID, 1, 20);
|
verify(progressService).recentPage(any(InteractionIdentity.class), eq(1), eq(20));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Drama drama(Long id, String title) {
|
private Drama drama(Long id, String title) {
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.app.service;
|
||||||
|
|
||||||
|
import com.fruitshorts.interaction.service.CommentService;
|
||||||
|
import com.fruitshorts.interaction.service.LikeService;
|
||||||
|
import com.fruitshorts.playback.service.FavoriteService;
|
||||||
|
import com.fruitshorts.playback.service.WatchProgressService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.mockito.BDDMockito.given;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
class InteractionAccountMergeServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_merge_all_device_interactions_and_watch_data() {
|
||||||
|
LikeService likeService = mock(LikeService.class);
|
||||||
|
FavoriteService favoriteService = mock(FavoriteService.class);
|
||||||
|
CommentService commentService = mock(CommentService.class);
|
||||||
|
WatchProgressService watchService = mock(WatchProgressService.class);
|
||||||
|
given(likeService.mergeDeviceIntoUser(9L, "device-a"))
|
||||||
|
.willReturn(new LikeService.MergeResult(1, 0));
|
||||||
|
given(favoriteService.mergeDeviceIntoUser(9L, "device-a"))
|
||||||
|
.willReturn(new FavoriteService.MergeResult(1, 0));
|
||||||
|
given(commentService.mergeDeviceIntoUser(9L, "device-a"))
|
||||||
|
.willReturn(new CommentService.MergeResult(1, 0));
|
||||||
|
given(watchService.mergeDeviceIntoUser(9L, "device-a"))
|
||||||
|
.willReturn(new WatchProgressService.MergeResult(2, 1, 3, 2));
|
||||||
|
InteractionAccountMergeService service = new InteractionAccountMergeService(
|
||||||
|
likeService, favoriteService, commentService, watchService);
|
||||||
|
|
||||||
|
service.merge(9L, "device-a");
|
||||||
|
|
||||||
|
verify(likeService).mergeDeviceIntoUser(9L, "device-a");
|
||||||
|
verify(favoriteService).mergeDeviceIntoUser(9L, "device-a");
|
||||||
|
verify(commentService).mergeDeviceIntoUser(9L, "device-a");
|
||||||
|
verify(watchService).mergeDeviceIntoUser(9L, "device-a");
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -157,6 +157,24 @@ class FavoriteServiceTest {
|
|||||||
verify(dramaService).listPublishedByIds(List.of(51L, 52L));
|
verify(dramaService).listPublishedByIds(List.of(51L, 52L));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_page_anonymous_favorites_by_device() {
|
||||||
|
Page<UserFavorite> databasePage = new Page<>(1, 20, 0);
|
||||||
|
databasePage.setRecords(List.of());
|
||||||
|
given(favoriteMapper.selectPage(any(Page.class), any())).willReturn(databasePage);
|
||||||
|
|
||||||
|
favoriteService.page(
|
||||||
|
new InteractionIdentity(null, "device-a"), 1, 20);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<Wrapper<UserFavorite>> wrapperCaptor =
|
||||||
|
ArgumentCaptor.forClass(Wrapper.class);
|
||||||
|
verify(favoriteMapper).selectPage(any(Page.class), wrapperCaptor.capture());
|
||||||
|
assertThat(wrapperCaptor.getValue().getSqlSegment())
|
||||||
|
.contains("user_id IS NULL")
|
||||||
|
.contains("device_id");
|
||||||
|
}
|
||||||
|
|
||||||
private UserFavorite favorite(Long dramaId) {
|
private UserFavorite favorite(Long dramaId) {
|
||||||
UserFavorite favorite = new UserFavorite();
|
UserFavorite favorite = new UserFavorite();
|
||||||
favorite.setDramaId(dramaId);
|
favorite.setDramaId(dramaId);
|
||||||
|
|||||||
+128
-2
@@ -7,11 +7,13 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|||||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.fruitshorts.common.pojo.PageResult;
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
|
import com.fruitshorts.common.interaction.InteractionIdentity;
|
||||||
import com.fruitshorts.content.dal.entity.Drama;
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
import com.fruitshorts.content.service.EpisodeService;
|
import com.fruitshorts.content.service.EpisodeService;
|
||||||
import com.fruitshorts.playback.dal.entity.WatchProgress;
|
import com.fruitshorts.playback.dal.entity.WatchProgress;
|
||||||
|
import com.fruitshorts.playback.dal.entity.WatchHistory;
|
||||||
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
|
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
|
||||||
import com.fruitshorts.playback.dal.mapper.WatchProgressMapper;
|
import com.fruitshorts.playback.dal.mapper.WatchProgressMapper;
|
||||||
import com.fruitshorts.playback.vo.ProgressRespVO;
|
import com.fruitshorts.playback.vo.ProgressRespVO;
|
||||||
@@ -43,6 +45,7 @@ class WatchProgressServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private WatchProgressMapper progressMapper;
|
private WatchProgressMapper progressMapper;
|
||||||
|
private WatchHistoryMapper historyMapper;
|
||||||
private DramaService dramaService;
|
private DramaService dramaService;
|
||||||
private EpisodeService episodeService;
|
private EpisodeService episodeService;
|
||||||
private WatchProgressService progressService;
|
private WatchProgressService progressService;
|
||||||
@@ -50,11 +53,12 @@ class WatchProgressServiceTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
progressMapper = mock(WatchProgressMapper.class);
|
progressMapper = mock(WatchProgressMapper.class);
|
||||||
|
historyMapper = mock(WatchHistoryMapper.class);
|
||||||
dramaService = mock(DramaService.class);
|
dramaService = mock(DramaService.class);
|
||||||
episodeService = mock(EpisodeService.class);
|
episodeService = mock(EpisodeService.class);
|
||||||
progressService = new WatchProgressService(
|
progressService = new WatchProgressService(
|
||||||
progressMapper,
|
progressMapper,
|
||||||
mock(WatchHistoryMapper.class),
|
historyMapper,
|
||||||
dramaService,
|
dramaService,
|
||||||
episodeService);
|
episodeService);
|
||||||
}
|
}
|
||||||
@@ -120,7 +124,7 @@ class WatchProgressServiceTest {
|
|||||||
hidden.setId(9L);
|
hidden.setId(9L);
|
||||||
hidden.setUserId(7L);
|
hidden.setUserId(7L);
|
||||||
hidden.setDeleted(1);
|
hidden.setDeleted(1);
|
||||||
given(progressMapper.selectAny(7L, 5L)).willReturn(hidden);
|
given(progressMapper.selectAnyForIdentity(7L, "", 5L)).willReturn(hidden);
|
||||||
given(progressMapper.selectById(9L)).willReturn(hidden);
|
given(progressMapper.selectById(9L)).willReturn(hidden);
|
||||||
ProgressSaveReqVO req = new ProgressSaveReqVO();
|
ProgressSaveReqVO req = new ProgressSaveReqVO();
|
||||||
req.setEpisodeId(88L);
|
req.setEpisodeId(88L);
|
||||||
@@ -134,6 +138,117 @@ class WatchProgressServiceTest {
|
|||||||
assertThat(hidden.getDeleted()).isZero();
|
assertThat(hidden.getDeleted()).isZero();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_save_and_read_anonymous_progress_by_device() {
|
||||||
|
DramaEpisode episode = episode(88L, 1);
|
||||||
|
episode.setDramaId(5L);
|
||||||
|
given(episodeService.getPublished(88L)).willReturn(episode);
|
||||||
|
given(dramaService.getPublished(5L)).willReturn(drama(5L, "剧名", 10));
|
||||||
|
given(progressMapper.selectAnyForIdentity(null, "device-a", 5L))
|
||||||
|
.willReturn(null);
|
||||||
|
given(progressMapper.insert(any(WatchProgress.class))).willAnswer(invocation -> {
|
||||||
|
WatchProgress saved = invocation.getArgument(0);
|
||||||
|
saved.setId(99L);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
given(progressMapper.selectById(99L)).willAnswer(invocation -> {
|
||||||
|
WatchProgress saved = new WatchProgress();
|
||||||
|
saved.setId(99L);
|
||||||
|
saved.setDramaId(5L);
|
||||||
|
saved.setEpisodeId(88L);
|
||||||
|
saved.setPositionMs(12_000L);
|
||||||
|
saved.setDurationMs(60_000L);
|
||||||
|
saved.setPlayCount(1);
|
||||||
|
saved.setDeviceId("device-a");
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
ProgressSaveReqVO req = new ProgressSaveReqVO();
|
||||||
|
req.setEpisodeId(88L);
|
||||||
|
req.setPositionMs(12_000L);
|
||||||
|
req.setDurationMs(60_000L);
|
||||||
|
|
||||||
|
progressService.save(new InteractionIdentity(null, "device-a"), req);
|
||||||
|
|
||||||
|
ArgumentCaptor<WatchProgress> progressCaptor =
|
||||||
|
ArgumentCaptor.forClass(WatchProgress.class);
|
||||||
|
verify(progressMapper).insert(progressCaptor.capture());
|
||||||
|
assertThat(progressCaptor.getValue().getUserId()).isNull();
|
||||||
|
assertThat(progressCaptor.getValue().getDeviceId()).isEqualTo("device-a");
|
||||||
|
ArgumentCaptor<WatchHistory> historyCaptor =
|
||||||
|
ArgumentCaptor.forClass(WatchHistory.class);
|
||||||
|
verify(historyMapper).insert(historyCaptor.capture());
|
||||||
|
assertThat(historyCaptor.getValue().getUserId()).isNull();
|
||||||
|
assertThat(historyCaptor.getValue().getDeviceId()).isEqualTo("device-a");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_merge_watch_data_and_keep_more_advanced_progress() {
|
||||||
|
DramaEpisode episode = episode(88L, 2);
|
||||||
|
episode.setDramaId(5L);
|
||||||
|
given(episodeService.listByIds(List.of(88L))).willReturn(List.of(episode));
|
||||||
|
|
||||||
|
WatchProgress deviceProgress = progress(5L, 88L);
|
||||||
|
deviceProgress.setId(1L);
|
||||||
|
deviceProgress.setPositionMs(90_000L);
|
||||||
|
deviceProgress.setPlayCount(3);
|
||||||
|
given(progressMapper.selectList(any())).willReturn(List.of(deviceProgress));
|
||||||
|
WatchProgress accountProgress = progress(5L, 88L);
|
||||||
|
accountProgress.setId(2L);
|
||||||
|
accountProgress.setUserId(9L);
|
||||||
|
accountProgress.setPositionMs(60_000L);
|
||||||
|
accountProgress.setPlayCount(2);
|
||||||
|
given(progressMapper.selectAnyForIdentity(9L, "", 5L))
|
||||||
|
.willReturn(accountProgress);
|
||||||
|
|
||||||
|
WatchHistory deviceHistory = history(5L, 88L, 90_000L);
|
||||||
|
deviceHistory.setId(3L);
|
||||||
|
given(historyMapper.selectList(any())).willReturn(List.of(deviceHistory));
|
||||||
|
WatchHistory accountHistory = history(5L, 88L, 60_000L);
|
||||||
|
accountHistory.setId(4L);
|
||||||
|
accountHistory.setUserId(9L);
|
||||||
|
given(historyMapper.selectOne(any())).willReturn(accountHistory);
|
||||||
|
|
||||||
|
WatchProgressService.MergeResult result =
|
||||||
|
progressService.mergeDeviceIntoUser(9L, "device-a");
|
||||||
|
|
||||||
|
assertThat(result.progressMigrated()).isEqualTo(1);
|
||||||
|
assertThat(result.progressIgnored()).isZero();
|
||||||
|
assertThat(result.historyMigrated()).isEqualTo(1);
|
||||||
|
assertThat(result.historyIgnored()).isZero();
|
||||||
|
assertThat(accountProgress.getPositionMs()).isEqualTo(90_000L);
|
||||||
|
assertThat(accountProgress.getPlayCount()).isEqualTo(3);
|
||||||
|
verify(progressMapper).updateAny(accountProgress);
|
||||||
|
verify(progressMapper).deleteById(1L);
|
||||||
|
verify(historyMapper).updateById(accountHistory);
|
||||||
|
verify(historyMapper).deleteById(3L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_ignore_device_progress_when_account_progress_is_ahead() {
|
||||||
|
DramaEpisode episode = episode(88L, 2);
|
||||||
|
episode.setDramaId(5L);
|
||||||
|
given(episodeService.listByIds(List.of(88L))).willReturn(List.of(episode));
|
||||||
|
|
||||||
|
WatchProgress deviceProgress = progress(5L, 88L);
|
||||||
|
deviceProgress.setId(1L);
|
||||||
|
deviceProgress.setPositionMs(50_000L);
|
||||||
|
given(progressMapper.selectList(any())).willReturn(List.of(deviceProgress));
|
||||||
|
WatchProgress accountProgress = progress(5L, 88L);
|
||||||
|
accountProgress.setId(2L);
|
||||||
|
accountProgress.setUserId(9L);
|
||||||
|
accountProgress.setPositionMs(70_000L);
|
||||||
|
given(progressMapper.selectAnyForIdentity(9L, "", 5L))
|
||||||
|
.willReturn(accountProgress);
|
||||||
|
|
||||||
|
WatchProgressService.MergeResult result =
|
||||||
|
progressService.mergeDeviceIntoUser(9L, "device-a");
|
||||||
|
|
||||||
|
assertThat(result.progressMigrated()).isZero();
|
||||||
|
assertThat(result.progressIgnored()).isEqualTo(1);
|
||||||
|
verify(progressMapper, never()).updateAny(any(WatchProgress.class));
|
||||||
|
verify(progressMapper).deleteById(1L);
|
||||||
|
}
|
||||||
|
|
||||||
private Drama drama(Long id, String title, int totalEpisodes) {
|
private Drama drama(Long id, String title, int totalEpisodes) {
|
||||||
Drama drama = new Drama();
|
Drama drama = new Drama();
|
||||||
drama.setId(id);
|
drama.setId(id);
|
||||||
@@ -149,4 +264,15 @@ class WatchProgressServiceTest {
|
|||||||
episode.setEpNo(epNo);
|
episode.setEpNo(epNo);
|
||||||
return episode;
|
return episode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private WatchHistory history(Long dramaId, Long episodeId, long positionMs) {
|
||||||
|
WatchHistory history = new WatchHistory();
|
||||||
|
history.setDramaId(dramaId);
|
||||||
|
history.setEpisodeId(episodeId);
|
||||||
|
history.setPositionMs(positionMs);
|
||||||
|
history.setDurationMs(120_000L);
|
||||||
|
history.setDeviceId("device-a");
|
||||||
|
history.setWatchedAt(LocalDateTime.of(2026, 10, 27, 9, 0));
|
||||||
|
return history;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+6
@@ -89,6 +89,12 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(HttpMethod.DELETE, "/app/comments/*").permitAll()
|
.requestMatchers(HttpMethod.DELETE, "/app/comments/*").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/app/comments").permitAll()
|
.requestMatchers(HttpMethod.GET, "/app/comments").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/app/comments/count").permitAll()
|
.requestMatchers(HttpMethod.GET, "/app/comments/count").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/app/favorites").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/app/watch/recent").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/app/watch/progress").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/app/watch/progress").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.DELETE, "/app/watch/progress/*").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.DELETE, "/app/watch/progress").permitAll()
|
||||||
.requestMatchers(PUBLIC_PATHS).permitAll()
|
.requestMatchers(PUBLIC_PATHS).permitAll()
|
||||||
.anyRequest().authenticated())
|
.anyRequest().authenticated())
|
||||||
.exceptionHandling(handling -> handling
|
.exceptionHandling(handling -> handling
|
||||||
|
|||||||
+11
-3
@@ -10,13 +10,21 @@ import org.apache.ibatis.annotations.Update;
|
|||||||
|
|
||||||
public interface WatchProgressMapper extends BaseMapper<WatchProgress> {
|
public interface WatchProgressMapper extends BaseMapper<WatchProgress> {
|
||||||
|
|
||||||
/** 含逻辑删除行。唯一索引不区分 deleted,再看同一部剧必须能找回旧行。 */
|
/** 按登录用户或匿名设备读取同一部剧的记录,包含逻辑删除行。 */
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT * FROM watch_progress
|
SELECT * FROM watch_progress
|
||||||
WHERE user_id = #{userId} AND drama_id = #{dramaId}
|
WHERE (
|
||||||
|
(#{userId} IS NOT NULL AND user_id = #{userId})
|
||||||
|
OR
|
||||||
|
(#{userId} IS NULL AND user_id IS NULL AND device_id = #{deviceId})
|
||||||
|
)
|
||||||
|
AND drama_id = #{dramaId}
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""")
|
""")
|
||||||
WatchProgress selectAny(@Param("userId") Long userId, @Param("dramaId") Long dramaId);
|
WatchProgress selectAnyForIdentity(
|
||||||
|
@Param("userId") Long userId,
|
||||||
|
@Param("deviceId") String deviceId,
|
||||||
|
@Param("dramaId") Long dramaId);
|
||||||
|
|
||||||
/** 恢复并更新。手写 SQL 不带 deleted=0 条件,逻辑删除行也能写回。 */
|
/** 恢复并更新。手写 SQL 不带 deleted=0 条件,逻辑删除行也能写回。 */
|
||||||
@Update("""
|
@Update("""
|
||||||
|
|||||||
+7
-2
@@ -152,14 +152,19 @@ public class FavoriteService {
|
|||||||
|
|
||||||
/** 分页查询收藏短剧,保持收藏时间与 dramaId 稳定排序。 */
|
/** 分页查询收藏短剧,保持收藏时间与 dramaId 稳定排序。 */
|
||||||
public PageResult<Drama> page(Long userId, int pageIndex, int pageSize) {
|
public PageResult<Drama> page(Long userId, int pageIndex, int pageSize) {
|
||||||
|
return page(new InteractionIdentity(userId, ""), pageIndex, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页查询当前登录账号或匿名设备的收藏短剧。 */
|
||||||
|
public PageResult<Drama> page(
|
||||||
|
InteractionIdentity identity, int pageIndex, int pageSize) {
|
||||||
int safeIndex = Math.max(pageIndex, 1);
|
int safeIndex = Math.max(pageIndex, 1);
|
||||||
int safeSize = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
int safeSize = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
||||||
Page<UserFavorite> request = new Page<>(safeIndex, safeSize);
|
Page<UserFavorite> request = new Page<>(safeIndex, safeSize);
|
||||||
String publishedDramaSql = "SELECT id FROM drama WHERE deleted = 0 AND status = '"
|
String publishedDramaSql = "SELECT id FROM drama WHERE deleted = 0 AND status = '"
|
||||||
+ ContentStatus.PUBLISHED.getCode() + "'";
|
+ ContentStatus.PUBLISHED.getCode() + "'";
|
||||||
IPage<UserFavorite> result = favoriteMapper.selectPage(request,
|
IPage<UserFavorite> result = favoriteMapper.selectPage(request,
|
||||||
Wrappers.<UserFavorite>lambdaQuery()
|
identityQuery(identity)
|
||||||
.eq(UserFavorite::getUserId, userId)
|
|
||||||
.inSql(UserFavorite::getDramaId, publishedDramaSql)
|
.inSql(UserFavorite::getDramaId, publishedDramaSql)
|
||||||
.orderByDesc(UserFavorite::getCreatedAt)
|
.orderByDesc(UserFavorite::getCreatedAt)
|
||||||
.orderByDesc(UserFavorite::getDramaId));
|
.orderByDesc(UserFavorite::getDramaId));
|
||||||
|
|||||||
+206
-24
@@ -3,9 +3,11 @@
|
|||||||
package com.fruitshorts.playback.service;
|
package com.fruitshorts.playback.service;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.fruitshorts.common.pojo.PageResult;
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
|
import com.fruitshorts.common.interaction.InteractionIdentity;
|
||||||
import com.fruitshorts.content.dal.entity.Drama;
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||||
import com.fruitshorts.content.service.ContentLocaleTexts;
|
import com.fruitshorts.content.service.ContentLocaleTexts;
|
||||||
@@ -21,6 +23,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -33,7 +36,7 @@ import java.util.Objects;
|
|||||||
* 观看进度。
|
* 观看进度。
|
||||||
*
|
*
|
||||||
* <p>每次上报都写库在高并发下会成为瓶颈(1 万并发 × 每 10 秒一次 ≈ 1000 写/秒),
|
* <p>每次上报都写库在高并发下会成为瓶颈(1 万并发 × 每 10 秒一次 ≈ 1000 写/秒),
|
||||||
* 所以生产形态是「先写 Redis,后台任务按 user+drama 合并后批量 UPSERT」。
|
* 所以生产形态是「先写 Redis,后台任务按登录账号或设备 + drama 合并后批量 UPSERT」。
|
||||||
* 这里先落地权威表与合并规则,缓存层在接入 Redis 时补齐,接口契约不变。
|
* 这里先落地权威表与合并规则,缓存层在接入 Redis 时补齐,接口契约不变。
|
||||||
*
|
*
|
||||||
* <p>合并规则:以客户端上报的 positionMs 为准,但只在「更靠后」时覆盖,
|
* <p>合并规则:以客户端上报的 positionMs 为准,但只在「更靠后」时覆盖,
|
||||||
@@ -53,6 +56,11 @@ public class WatchProgressService {
|
|||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public ProgressRespVO save(Long userId, ProgressSaveReqVO req) {
|
public ProgressRespVO save(Long userId, ProgressSaveReqVO req) {
|
||||||
|
return save(new InteractionIdentity(userId, ""), req);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public ProgressRespVO save(InteractionIdentity identity, ProgressSaveReqVO req) {
|
||||||
DramaEpisode episode = episodeService.getPublished(req.getEpisodeId());
|
DramaEpisode episode = episodeService.getPublished(req.getEpisodeId());
|
||||||
Drama drama = dramaService.getPublished(episode.getDramaId());
|
Drama drama = dramaService.getPublished(episode.getDramaId());
|
||||||
long position = req.getPositionMs() == null ? 0L : Math.max(0L, req.getPositionMs());
|
long position = req.getPositionMs() == null ? 0L : Math.max(0L, req.getPositionMs());
|
||||||
@@ -60,11 +68,11 @@ public class WatchProgressService {
|
|||||||
boolean finished = Boolean.TRUE.equals(req.getFinished())
|
boolean finished = Boolean.TRUE.equals(req.getFinished())
|
||||||
|| (duration > 0 && position + FINISH_THRESHOLD_MS >= duration);
|
|| (duration > 0 && position + FINISH_THRESHOLD_MS >= duration);
|
||||||
|
|
||||||
LoadedProgress loaded = loadForSave(userId, drama.getId(), episode, position, duration,
|
LoadedProgress loaded = loadForSave(identity, drama.getId(), episode, position, duration,
|
||||||
finished, req);
|
finished, req);
|
||||||
WatchProgress existing = loaded.progress();
|
WatchProgress existing = loaded.progress();
|
||||||
if (loaded.created()) {
|
if (loaded.created()) {
|
||||||
appendHistory(userId, drama.getId(), episode.getId(), position, duration,
|
appendHistory(identity, drama.getId(), episode.getId(), position, duration,
|
||||||
existing.getSource(), existing.getDeviceId());
|
existing.getSource(), existing.getDeviceId());
|
||||||
return toResp(progressMapper.selectById(existing.getId()));
|
return toResp(progressMapper.selectById(existing.getId()));
|
||||||
}
|
}
|
||||||
@@ -74,7 +82,7 @@ public class WatchProgressService {
|
|||||||
&& existing.getEpisodeId().equals(episode.getId());
|
&& existing.getEpisodeId().equals(episode.getId());
|
||||||
if (hidden && sameEpisode) {
|
if (hidden && sameEpisode) {
|
||||||
existing.setPlayCount((existing.getPlayCount() == null ? 0 : existing.getPlayCount()) + 1);
|
existing.setPlayCount((existing.getPlayCount() == null ? 0 : existing.getPlayCount()) + 1);
|
||||||
appendHistory(userId, drama.getId(), episode.getId(), position, duration,
|
appendHistory(identity, drama.getId(), episode.getId(), position, duration,
|
||||||
req.getSource(), existing.getDeviceId());
|
req.getSource(), existing.getDeviceId());
|
||||||
}
|
}
|
||||||
if (!sameEpisode) {
|
if (!sameEpisode) {
|
||||||
@@ -84,7 +92,7 @@ public class WatchProgressService {
|
|||||||
existing.setDurationMs(duration);
|
existing.setDurationMs(duration);
|
||||||
existing.setFinished(finished ? 1 : 0);
|
existing.setFinished(finished ? 1 : 0);
|
||||||
existing.setPlayCount((existing.getPlayCount() == null ? 0 : existing.getPlayCount()) + 1);
|
existing.setPlayCount((existing.getPlayCount() == null ? 0 : existing.getPlayCount()) + 1);
|
||||||
appendHistory(userId, drama.getId(), episode.getId(), position, duration,
|
appendHistory(identity, drama.getId(), episode.getId(), position, duration,
|
||||||
req.getSource(), existing.getDeviceId());
|
req.getSource(), existing.getDeviceId());
|
||||||
} else {
|
} else {
|
||||||
existing.setDurationMs(Math.max(existing.getDurationMs() == null ? 0L : existing.getDurationMs(),
|
existing.setDurationMs(Math.max(existing.getDurationMs() == null ? 0L : existing.getDurationMs(),
|
||||||
@@ -96,7 +104,9 @@ public class WatchProgressService {
|
|||||||
existing.setFinished(1);
|
existing.setFinished(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
existing.setDeviceId(req.getDeviceId() == null ? "" : req.getDeviceId());
|
existing.setDeviceId(identity.authenticated()
|
||||||
|
? (req.getDeviceId() == null ? "" : req.getDeviceId())
|
||||||
|
: identity.deviceId());
|
||||||
existing.setSource(req.getSource() == null ? "" : req.getSource());
|
existing.setSource(req.getSource() == null ? "" : req.getSource());
|
||||||
if (hidden) {
|
if (hidden) {
|
||||||
existing.setDeleted(0);
|
existing.setDeleted(0);
|
||||||
@@ -107,18 +117,22 @@ public class WatchProgressService {
|
|||||||
return toResp(progressMapper.selectById(existing.getId()));
|
return toResp(progressMapper.selectById(existing.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private LoadedProgress loadForSave(Long userId, Long dramaId, DramaEpisode episode, long position,
|
private LoadedProgress loadForSave(InteractionIdentity identity, Long dramaId,
|
||||||
|
DramaEpisode episode, long position,
|
||||||
long duration, boolean finished, ProgressSaveReqVO req) {
|
long duration, boolean finished, ProgressSaveReqVO req) {
|
||||||
WatchProgress existing = progressMapper.selectAny(userId, dramaId);
|
WatchProgress existing = progressMapper.selectAnyForIdentity(
|
||||||
|
identity.userId(), identity.deviceId(), dramaId);
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
return new LoadedProgress(existing, false);
|
return new LoadedProgress(existing, false);
|
||||||
}
|
}
|
||||||
WatchProgress created = newProgress(userId, dramaId, episode, position, duration, finished, req);
|
WatchProgress created = newProgress(
|
||||||
|
identity, dramaId, episode, position, duration, finished, req);
|
||||||
try {
|
try {
|
||||||
progressMapper.insert(created);
|
progressMapper.insert(created);
|
||||||
return new LoadedProgress(created, true);
|
return new LoadedProgress(created, true);
|
||||||
} catch (DuplicateKeyException ex) {
|
} catch (DuplicateKeyException ex) {
|
||||||
WatchProgress raced = progressMapper.selectAny(userId, dramaId);
|
WatchProgress raced = progressMapper.selectAnyForIdentity(
|
||||||
|
identity.userId(), identity.deviceId(), dramaId);
|
||||||
if (raced == null) {
|
if (raced == null) {
|
||||||
throw ex;
|
throw ex;
|
||||||
}
|
}
|
||||||
@@ -126,17 +140,20 @@ public class WatchProgressService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private WatchProgress newProgress(Long userId, Long dramaId, DramaEpisode episode, long position,
|
private WatchProgress newProgress(InteractionIdentity identity, Long dramaId,
|
||||||
|
DramaEpisode episode, long position,
|
||||||
long duration, boolean finished, ProgressSaveReqVO req) {
|
long duration, boolean finished, ProgressSaveReqVO req) {
|
||||||
WatchProgress progress = new WatchProgress();
|
WatchProgress progress = new WatchProgress();
|
||||||
progress.setUserId(userId);
|
progress.setUserId(identity.userId());
|
||||||
progress.setDramaId(dramaId);
|
progress.setDramaId(dramaId);
|
||||||
progress.setEpisodeId(episode.getId());
|
progress.setEpisodeId(episode.getId());
|
||||||
progress.setPositionMs(position);
|
progress.setPositionMs(position);
|
||||||
progress.setDurationMs(duration);
|
progress.setDurationMs(duration);
|
||||||
progress.setFinished(finished ? 1 : 0);
|
progress.setFinished(finished ? 1 : 0);
|
||||||
progress.setPlayCount(1);
|
progress.setPlayCount(1);
|
||||||
progress.setDeviceId(req.getDeviceId() == null ? "" : req.getDeviceId());
|
progress.setDeviceId(identity.authenticated()
|
||||||
|
? (req.getDeviceId() == null ? "" : req.getDeviceId())
|
||||||
|
: identity.deviceId());
|
||||||
progress.setSource(req.getSource() == null ? "" : req.getSource());
|
progress.setSource(req.getSource() == null ? "" : req.getSource());
|
||||||
return progress;
|
return progress;
|
||||||
}
|
}
|
||||||
@@ -145,8 +162,11 @@ public class WatchProgressService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ProgressRespVO ofDrama(Long userId, Long dramaId) {
|
public ProgressRespVO ofDrama(Long userId, Long dramaId) {
|
||||||
WatchProgress progress = progressMapper.selectOne(Wrappers.<WatchProgress>lambdaQuery()
|
return ofDrama(new InteractionIdentity(userId, ""), dramaId);
|
||||||
.eq(WatchProgress::getUserId, userId)
|
}
|
||||||
|
|
||||||
|
public ProgressRespVO ofDrama(InteractionIdentity identity, Long dramaId) {
|
||||||
|
WatchProgress progress = progressMapper.selectOne(identityQuery(identity)
|
||||||
.eq(WatchProgress::getDramaId, dramaId)
|
.eq(WatchProgress::getDramaId, dramaId)
|
||||||
.last("limit 1"));
|
.last("limit 1"));
|
||||||
return progress == null ? null : toResp(progress);
|
return progress == null ? null : toResp(progress);
|
||||||
@@ -154,12 +174,16 @@ public class WatchProgressService {
|
|||||||
|
|
||||||
/** 分页查询最近观看,按更新时间倒序并以 id 兜底保证稳定。 */
|
/** 分页查询最近观看,按更新时间倒序并以 id 兜底保证稳定。 */
|
||||||
public PageResult<ProgressRespVO> recentPage(Long userId, int pageIndex, int pageSize) {
|
public PageResult<ProgressRespVO> recentPage(Long userId, int pageIndex, int pageSize) {
|
||||||
|
return recentPage(new InteractionIdentity(userId, ""), pageIndex, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PageResult<ProgressRespVO> recentPage(
|
||||||
|
InteractionIdentity identity, int pageIndex, int pageSize) {
|
||||||
int safeIndex = Math.max(pageIndex, 1);
|
int safeIndex = Math.max(pageIndex, 1);
|
||||||
int safeSize = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
int safeSize = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
||||||
Page<WatchProgress> request = new Page<>(safeIndex, safeSize);
|
Page<WatchProgress> request = new Page<>(safeIndex, safeSize);
|
||||||
IPage<WatchProgress> result = progressMapper.selectPage(request,
|
IPage<WatchProgress> result = progressMapper.selectPage(request,
|
||||||
Wrappers.<WatchProgress>lambdaQuery()
|
identityQuery(identity)
|
||||||
.eq(WatchProgress::getUserId, userId)
|
|
||||||
.orderByDesc(WatchProgress::getUpdatedAt)
|
.orderByDesc(WatchProgress::getUpdatedAt)
|
||||||
.orderByDesc(WatchProgress::getId));
|
.orderByDesc(WatchProgress::getId));
|
||||||
return new PageResult<>(result.getTotal(), enrichDisplay(result.getRecords()));
|
return new PageResult<>(result.getTotal(), enrichDisplay(result.getRecords()));
|
||||||
@@ -167,15 +191,23 @@ public class WatchProgressService {
|
|||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void remove(Long userId, Long dramaId) {
|
public void remove(Long userId, Long dramaId) {
|
||||||
progressMapper.delete(Wrappers.<WatchProgress>lambdaQuery()
|
remove(new InteractionIdentity(userId, ""), dramaId);
|
||||||
.eq(WatchProgress::getUserId, userId)
|
}
|
||||||
|
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void remove(InteractionIdentity identity, Long dramaId) {
|
||||||
|
progressMapper.delete(identityQuery(identity)
|
||||||
.eq(WatchProgress::getDramaId, dramaId));
|
.eq(WatchProgress::getDramaId, dramaId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void clear(Long userId) {
|
public void clear(Long userId) {
|
||||||
progressMapper.delete(Wrappers.<WatchProgress>lambdaQuery()
|
clear(new InteractionIdentity(userId, ""));
|
||||||
.eq(WatchProgress::getUserId, userId));
|
}
|
||||||
|
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void clear(InteractionIdentity identity) {
|
||||||
|
progressMapper.delete(identityQuery(identity));
|
||||||
}
|
}
|
||||||
|
|
||||||
private ProgressRespVO toResp(WatchProgress progress) {
|
private ProgressRespVO toResp(WatchProgress progress) {
|
||||||
@@ -248,19 +280,169 @@ public class WatchProgressService {
|
|||||||
* 追加一条观看流水。只在「首次观看一部剧」和「切换剧集」时调用,
|
* 追加一条观看流水。只在「首次观看一部剧」和「切换剧集」时调用,
|
||||||
* 每 10 秒的进度上报不写这里 —— 否则单集会产出几十条噪音。
|
* 每 10 秒的进度上报不写这里 —— 否则单集会产出几十条噪音。
|
||||||
*/
|
*/
|
||||||
private void appendHistory(Long userId, Long dramaId, Long episodeId, long positionMs,
|
private void appendHistory(InteractionIdentity identity, Long dramaId, Long episodeId,
|
||||||
|
long positionMs,
|
||||||
long durationMs, String source, String deviceId) {
|
long durationMs, String source, String deviceId) {
|
||||||
WatchHistory history = new WatchHistory();
|
WatchHistory history = new WatchHistory();
|
||||||
history.setUserId(userId);
|
history.setUserId(identity.userId());
|
||||||
history.setDramaId(dramaId);
|
history.setDramaId(dramaId);
|
||||||
history.setEpisodeId(episodeId);
|
history.setEpisodeId(episodeId);
|
||||||
history.setPositionMs(positionMs);
|
history.setPositionMs(positionMs);
|
||||||
history.setDurationMs(durationMs);
|
history.setDurationMs(durationMs);
|
||||||
history.setSource(source == null ? "" : source);
|
history.setSource(source == null ? "" : source);
|
||||||
history.setDeviceId(deviceId == null ? "" : deviceId);
|
history.setDeviceId(identity.authenticated()
|
||||||
|
? (deviceId == null ? "" : deviceId)
|
||||||
|
: identity.deviceId());
|
||||||
historyMapper.insert(history);
|
historyMapper.insert(history);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录时合并设备进度和观看流水。
|
||||||
|
*
|
||||||
|
* <p>同一部剧只保留进度更靠前的账号记录:先比较集号,同集再比较播放位置,
|
||||||
|
* 最后用更新时间兜底。playCount 只取保留记录,不把两条记录的计数相加。
|
||||||
|
*/
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public MergeResult mergeDeviceIntoUser(Long userId, String deviceId) {
|
||||||
|
if (userId == null || !StringUtils.hasText(deviceId)) {
|
||||||
|
return new MergeResult(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
List<WatchProgress> deviceProgress = progressMapper.selectList(
|
||||||
|
Wrappers.<WatchProgress>lambdaQuery()
|
||||||
|
.isNull(WatchProgress::getUserId)
|
||||||
|
.eq(WatchProgress::getDeviceId, deviceId));
|
||||||
|
Map<Long, Integer> episodeNumbers = episodeNumbers(deviceProgress);
|
||||||
|
int progressMigrated = 0;
|
||||||
|
int progressIgnored = 0;
|
||||||
|
for (WatchProgress device : deviceProgress) {
|
||||||
|
WatchProgress account = progressMapper.selectAnyForIdentity(
|
||||||
|
userId, "", device.getDramaId());
|
||||||
|
if (account == null) {
|
||||||
|
device.setUserId(userId);
|
||||||
|
progressMapper.updateById(device);
|
||||||
|
progressMigrated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (progressAhead(device, account, episodeNumbers)) {
|
||||||
|
copyProgress(device, account);
|
||||||
|
account.setDeleted(0);
|
||||||
|
progressMapper.updateAny(account);
|
||||||
|
progressMigrated++;
|
||||||
|
} else {
|
||||||
|
progressIgnored++;
|
||||||
|
}
|
||||||
|
progressMapper.deleteById(device.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<WatchHistory> deviceHistory = historyMapper.selectList(
|
||||||
|
Wrappers.<WatchHistory>lambdaQuery()
|
||||||
|
.isNull(WatchHistory::getUserId)
|
||||||
|
.eq(WatchHistory::getDeviceId, deviceId));
|
||||||
|
int historyMigrated = 0;
|
||||||
|
int historyIgnored = 0;
|
||||||
|
for (WatchHistory device : deviceHistory) {
|
||||||
|
WatchHistory account = historyMapper.selectOne(
|
||||||
|
Wrappers.<WatchHistory>lambdaQuery()
|
||||||
|
.eq(WatchHistory::getUserId, userId)
|
||||||
|
.eq(WatchHistory::getDramaId, device.getDramaId())
|
||||||
|
.eq(WatchHistory::getEpisodeId, device.getEpisodeId())
|
||||||
|
.orderByDesc(WatchHistory::getWatchedAt)
|
||||||
|
.last("limit 1"));
|
||||||
|
if (account == null) {
|
||||||
|
device.setUserId(userId);
|
||||||
|
historyMapper.updateById(device);
|
||||||
|
historyMigrated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (historyAhead(device, account)) {
|
||||||
|
account.setPositionMs(device.getPositionMs());
|
||||||
|
account.setDurationMs(device.getDurationMs());
|
||||||
|
account.setSource(device.getSource());
|
||||||
|
account.setWatchedAt(device.getWatchedAt());
|
||||||
|
historyMapper.updateById(account);
|
||||||
|
historyMigrated++;
|
||||||
|
} else {
|
||||||
|
historyIgnored++;
|
||||||
|
}
|
||||||
|
historyMapper.deleteById(device.getId());
|
||||||
|
}
|
||||||
|
return new MergeResult(
|
||||||
|
progressMigrated, progressIgnored, historyMigrated, historyIgnored);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MergeResult(
|
||||||
|
int progressMigrated,
|
||||||
|
int progressIgnored,
|
||||||
|
int historyMigrated,
|
||||||
|
int historyIgnored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, Integer> episodeNumbers(List<WatchProgress> progress) {
|
||||||
|
List<Long> episodeIds = progress.stream()
|
||||||
|
.map(WatchProgress::getEpisodeId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
Map<Long, Integer> result = new HashMap<>();
|
||||||
|
if (episodeIds.isEmpty()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (DramaEpisode episode : episodeService.listByIds(episodeIds)) {
|
||||||
|
result.put(episode.getId(), episode.getEpNo());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean progressAhead(
|
||||||
|
WatchProgress candidate,
|
||||||
|
WatchProgress current,
|
||||||
|
Map<Long, Integer> episodeNumbers) {
|
||||||
|
Integer candidateNo = episodeNumbers.get(candidate.getEpisodeId());
|
||||||
|
Integer currentNo = episodeNumbers.get(current.getEpisodeId());
|
||||||
|
if (candidateNo != null && currentNo != null && !candidateNo.equals(currentNo)) {
|
||||||
|
return candidateNo > currentNo;
|
||||||
|
}
|
||||||
|
long candidatePosition = candidate.getPositionMs() == null ? 0L : candidate.getPositionMs();
|
||||||
|
long currentPosition = current.getPositionMs() == null ? 0L : current.getPositionMs();
|
||||||
|
if (candidatePosition != currentPosition) {
|
||||||
|
return candidatePosition > currentPosition;
|
||||||
|
}
|
||||||
|
return candidate.getUpdatedAt() != null
|
||||||
|
&& (current.getUpdatedAt() == null
|
||||||
|
|| candidate.getUpdatedAt().isAfter(current.getUpdatedAt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void copyProgress(WatchProgress source, WatchProgress target) {
|
||||||
|
target.setEpisodeId(source.getEpisodeId());
|
||||||
|
target.setPositionMs(source.getPositionMs());
|
||||||
|
target.setDurationMs(source.getDurationMs());
|
||||||
|
target.setFinished(source.getFinished());
|
||||||
|
target.setPlayCount(source.getPlayCount());
|
||||||
|
target.setDeviceId(source.getDeviceId());
|
||||||
|
target.setSource(source.getSource());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean historyAhead(WatchHistory candidate, WatchHistory current) {
|
||||||
|
long candidatePosition = candidate.getPositionMs() == null ? 0L : candidate.getPositionMs();
|
||||||
|
long currentPosition = current.getPositionMs() == null ? 0L : current.getPositionMs();
|
||||||
|
if (candidatePosition != currentPosition) {
|
||||||
|
return candidatePosition > currentPosition;
|
||||||
|
}
|
||||||
|
return candidate.getWatchedAt() != null
|
||||||
|
&& (current.getWatchedAt() == null
|
||||||
|
|| candidate.getWatchedAt().isAfter(current.getWatchedAt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private LambdaQueryWrapper<WatchProgress> identityQuery(InteractionIdentity identity) {
|
||||||
|
if (identity.authenticated()) {
|
||||||
|
return Wrappers.<WatchProgress>lambdaQuery()
|
||||||
|
.eq(WatchProgress::getUserId, identity.userId());
|
||||||
|
}
|
||||||
|
return Wrappers.<WatchProgress>lambdaQuery()
|
||||||
|
.isNull(WatchProgress::getUserId)
|
||||||
|
.eq(WatchProgress::getDeviceId, identity.deviceId());
|
||||||
|
}
|
||||||
|
|
||||||
/** 供后续批量合并任务使用:只更新进度字段,避免整行覆盖。 */
|
/** 供后续批量合并任务使用:只更新进度字段,避免整行覆盖。 */
|
||||||
public void mergeInto(Long userId, Long dramaId, Long episodeId, long positionMs) {
|
public void mergeInto(Long userId, Long dramaId, Long episodeId, long positionMs) {
|
||||||
progressMapper.update(null, Wrappers.<WatchProgress>lambdaUpdate()
|
progressMapper.update(null, Wrappers.<WatchProgress>lambdaUpdate()
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- 观看进度与观看流水支持 deviceId 归属;登录时由账号事务合并。
|
||||||
|
|
||||||
|
ALTER TABLE watch_progress
|
||||||
|
ADD COLUMN IF NOT EXISTS device_id VARCHAR(128) NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE watch_progress
|
||||||
|
ALTER COLUMN user_id DROP NOT NULL;
|
||||||
|
DROP INDEX IF EXISTS uk_watch_progress;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_watch_progress_user
|
||||||
|
ON watch_progress (user_id, drama_id)
|
||||||
|
WHERE user_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_watch_progress_device
|
||||||
|
ON watch_progress (device_id, drama_id)
|
||||||
|
WHERE user_id IS NULL AND device_id <> '';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_watch_progress_device
|
||||||
|
ON watch_progress (device_id, updated_at DESC)
|
||||||
|
WHERE device_id <> '';
|
||||||
|
|
||||||
|
ALTER TABLE watch_history
|
||||||
|
ADD COLUMN IF NOT EXISTS device_id VARCHAR(128) NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE watch_history
|
||||||
|
ALTER COLUMN user_id DROP NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_watch_history_device
|
||||||
|
ON watch_history (device_id, watched_at DESC)
|
||||||
|
WHERE user_id IS NULL AND device_id <> '';
|
||||||
Reference in new issue
Block a user