feat(interaction): 支持游客互动与登录合并

This commit is contained in:
Zhou Weidong committed 2026-10-26 22:34:14 +08:00
1 parent 2fe24b9db7
commit 4b0fbd73d3
41 files changed
+1207 -90

No files matched your search

@@ -24,6 +24,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.util.StringUtils;
/** 客户端账号:验证码、注册、登录、刷新、登出。 */
@Tag(name = "客户端-账号")
@@ -48,6 +49,7 @@ public class AppAuthController {
@PostMapping("/register")
public Result<TokenRespVO> register(@Valid @RequestBody RegisterReqVO req,
HttpServletRequest request) {
applyHeaderDeviceId(req, request);
return Result.ok(authService.register(req, WebUtils.getClientIp(request)));
}
@@ -56,13 +58,18 @@ public class AppAuthController {
@PostMapping("/login")
public Result<TokenRespVO> login(@Valid @RequestBody LoginReqVO req,
HttpServletRequest request) {
applyHeaderDeviceId(req, request);
return Result.ok(authService.login(req, WebUtils.getClientIp(request)));
}
@Operation(summary = "Google 登录(未配置 clientId 时返回明确错误)")
@PostMapping("/google")
public Result<TokenRespVO> google(@Valid @RequestBody GoogleLoginReqVO req) {
return Result.ok(authService.googleLogin(req.getIdToken()));
public Result<TokenRespVO> google(@Valid @RequestBody GoogleLoginReqVO req,
HttpServletRequest request) {
String headerDeviceId = request.getHeader("X-Device-Id");
String deviceId = StringUtils.hasText(headerDeviceId)
? headerDeviceId : req.getDeviceId();
return Result.ok(authService.googleLogin(req.getIdToken(), deviceId));
}
@Operation(summary = "刷新令牌(旧 refresh token 立即失效)")
@@ -78,4 +85,16 @@ public class AppAuthController {
authService.logout(SecurityUtils.getUserIdOrNull(), req == null ? new LogoutReqVO() : req);
return Result.ok();
}
private void applyHeaderDeviceId(RegisterReqVO req, HttpServletRequest request) {
if (!StringUtils.hasText(req.getDeviceId())) {
req.setDeviceId(request.getHeader("X-Device-Id"));
}
}
private void applyHeaderDeviceId(LoginReqVO req, HttpServletRequest request) {
if (!StringUtils.hasText(req.getDeviceId())) {
req.setDeviceId(request.getHeader("X-Device-Id"));
}
}
}
@@ -6,6 +6,9 @@ import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.common.interaction.InteractionIdentity;
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.framework.web.WebUtils;
import com.fruitshorts.interaction.service.CommentService;
@@ -33,7 +36,7 @@ import java.util.List;
*
* <p>列表走游标翻页:`cursor` 传上一页最后一条的 id,返回 `nextCursor` 与 `hasMore`。
* 不传 `rootId` 时返回顶层评论,传了则返回该楼层的回复。
* 列表允许匿名浏览;发布与删除需要登录。
* 列表允许匿名浏览;发布与删除支持登录用户或设备身份。
*/
@Tag(name = "客户端-评论")
@RestController
@@ -43,13 +46,20 @@ public class AppCommentController {
private final CommentService commentService;
private final EpisodeService episodeService;
private final InteractionIdentityResolver identityResolver;
private final AnonymousInteractionRateLimiter anonymousRateLimiter;
@Operation(summary = "发布评论")
@PostMapping
public Result<CommentRespVO> publish(@Valid @RequestBody CommentPublishReqVO req,
HttpServletRequest request) {
return Result.ok(commentService.publish(SecurityUtils.getAppUserId(), req,
WebUtils.getClientIp(request), request.getHeader("X-Device-Id")));
InteractionIdentity identity = identityResolver.resolve(
SecurityUtils.getUserIdOrNull(),
request.getHeader("X-Device-Id"),
req.getDeviceId());
anonymousRateLimiter.check(identity, "comment");
return Result.ok(commentService.publish(
identity, req, WebUtils.getClientIp(request)));
}
@Operation(summary = "评论列表(游标翻页,可匿名浏览)")
@@ -58,14 +68,18 @@ public class AppCommentController {
@RequestParam(required = false) Long episodeId,
@RequestParam(required = false) Long rootId,
@RequestParam(required = false) Long cursor,
@RequestParam(required = false) Integer size) {
@RequestParam(required = false) Integer size,
HttpServletRequest request) {
CommentQueryReqVO query = new CommentQueryReqVO();
query.setDramaId(dramaId);
query.setEpisodeId(episodeId);
query.setRootId(rootId);
query.setCursor(cursor);
query.setSize(size);
return Result.ok(commentService.page(SecurityUtils.getUserIdOrNull(), query));
return Result.ok(commentService.page(
SecurityUtils.getUserIdOrNull(),
request.getHeader("X-Device-Id"),
query));
}
@Operation(summary = "评论总数:传 episodeId 按集统计,仅 dramaId 时全剧统计(可匿名访问)")
@@ -85,8 +99,13 @@ public class AppCommentController {
@Operation(summary = "删除自己的评论")
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
commentService.delete(SecurityUtils.getAppUserId(), id);
public Result<Void> delete(@PathVariable Long id, HttpServletRequest request) {
InteractionIdentity identity = identityResolver.resolve(
SecurityUtils.getUserIdOrNull(),
request.getHeader("X-Device-Id"),
"");
anonymousRateLimiter.check(identity, "comment");
commentService.delete(identity, id);
return Result.ok();
}
}
@@ -66,7 +66,11 @@ public class AppDramaController {
/** 轻量计数快照:只读互动状态,不签发播放地址、不写票据缓存。 */
@Operation(summary = "播放页轻量计数与当前用户互动状态")
@GetMapping("/episodes/{id}/stats")
public Result<AppEpisodeStatsVO> stats(@PathVariable Long id) {
return Result.ok(appEpisodeStatsService.get(id, SecurityUtils.getUserIdOrNull()));
public Result<AppEpisodeStatsVO> stats(
@PathVariable Long id, HttpServletRequest request) {
return Result.ok(appEpisodeStatsService.get(
id,
SecurityUtils.getUserIdOrNull(),
request.getHeader("X-Device-Id")));
}
}
@@ -6,6 +6,9 @@ import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.common.interaction.InteractionIdentity;
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.playback.service.FavoriteService;
@@ -14,6 +17,7 @@ import com.fruitshorts.playback.vo.FavoriteToggleReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -33,12 +37,21 @@ public class AppFavoriteController {
private final FavoriteService favoriteService;
private final AppCatalogService appCatalogService;
private final InteractionIdentityResolver identityResolver;
private final AnonymousInteractionRateLimiter anonymousRateLimiter;
/** 切换收藏,返回切换后的状态:true 为已收藏。 */
@Operation(summary = "切换收藏(返回切换后的状态)")
@PostMapping("/toggle")
public Result<Boolean> toggle(@Valid @RequestBody FavoriteToggleReqVO req) {
return Result.ok(favoriteService.toggle(SecurityUtils.getAppUserId(), req.getDramaId()));
public Result<Boolean> toggle(
@Valid @RequestBody FavoriteToggleReqVO req,
HttpServletRequest request) {
InteractionIdentity identity = identityResolver.resolve(
SecurityUtils.getUserIdOrNull(),
request.getHeader("X-Device-Id"),
req.getDeviceId());
anonymousRateLimiter.check(identity, "favorite");
return Result.ok(favoriteService.toggle(identity, req.getDramaId()));
}
/** 收藏列表,卡片结构与首页一致。 */
@@ -55,8 +68,17 @@ public class AppFavoriteController {
@Operation(summary = "取消收藏")
@DeleteMapping("/{dramaId}")
public Result<Void> remove(@PathVariable Long dramaId) {
favoriteService.remove(SecurityUtils.getAppUserId(), dramaId);
public Result<Void> remove(@PathVariable Long dramaId, HttpServletRequest request) {
InteractionIdentity identity = identityResolver.resolve(
SecurityUtils.getUserIdOrNull(),
request.getHeader("X-Device-Id"),
"");
anonymousRateLimiter.check(identity, "favorite");
if (identity.authenticated()) {
favoriteService.remove(identity.userId(), dramaId);
} else {
favoriteService.removeDevice(identity.deviceId(), dramaId);
}
return Result.ok();
}
}
@@ -3,7 +3,10 @@
package com.fruitshorts.app.controller;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.common.interaction.InteractionIdentity;
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
import com.fruitshorts.interaction.service.LikeService;
import com.fruitshorts.interaction.service.ReportService;
import com.fruitshorts.interaction.vo.LikeToggleReqVO;
@@ -12,6 +15,7 @@ import com.fruitshorts.interaction.vo.ReportCreateReqVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -27,12 +31,21 @@ public class AppInteractionController {
private final LikeService likeService;
private final ReportService reportService;
private final InteractionIdentityResolver identityResolver;
private final AnonymousInteractionRateLimiter anonymousRateLimiter;
/** 点赞/取消点赞,返回切换后的状态与最新计数。 */
@Operation(summary = "点赞 / 取消点赞")
@PostMapping("/likes/toggle")
public Result<LikeToggleRespVO> toggleLike(@Valid @RequestBody LikeToggleReqVO req) {
return Result.ok(likeService.toggle(SecurityUtils.getAppUserId(), req));
public Result<LikeToggleRespVO> toggleLike(
@Valid @RequestBody LikeToggleReqVO req,
HttpServletRequest request) {
InteractionIdentity identity = identityResolver.resolve(
SecurityUtils.getUserIdOrNull(),
request.getHeader("X-Device-Id"),
req.getDeviceId());
anonymousRateLimiter.check(identity, "like");
return Result.ok(likeService.toggle(identity, req));
}
/** 提交举报:同一用户对同一目标只保留一条待处理工单。 */
@@ -0,0 +1,36 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.interaction.InteractionIdentity;
import com.fruitshorts.framework.redis.RedisService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Duration;
/** 未登录非付费互动的设备级固定窗口限流。 */
@Component
@RequiredArgsConstructor
public class AnonymousInteractionRateLimiter {
private final RedisService redisService;
@Value("${fruitshorts.interaction.anonymous-write-per-minute:30}")
private int writesPerMinute = 30;
public void check(InteractionIdentity identity, String action) {
if (identity.authenticated()) {
return;
}
long count = redisService.increment(
"app:anonymous-interaction:" + action + ":" + identity.deviceId(),
Duration.ofMinutes(1));
if (count > writesPerMinute) {
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "操作过于频繁,请稍后再试");
}
}
}
@@ -32,8 +32,8 @@ public class AppEpisodeStatsService {
private final LikeService likeService;
private final FavoriteService favoriteService;
public AppEpisodeStatsVO get(Long episodeId, Long userId) {
Map<Long, AppEpisodeStatsVO> stats = batch(List.of(episodeId), userId);
public AppEpisodeStatsVO get(Long episodeId, Long userId, String deviceId) {
Map<Long, AppEpisodeStatsVO> stats = batch(List.of(episodeId), userId, deviceId);
AppEpisodeStatsVO result = stats.get(episodeId);
if (result == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "剧集不存在或未上线");
@@ -41,8 +41,17 @@ public class AppEpisodeStatsService {
return result;
}
public AppEpisodeStatsVO get(Long episodeId, Long userId) {
return get(episodeId, userId, "");
}
/** 每类数据只查一次,供后续多集统计复用。 */
public Map<Long, AppEpisodeStatsVO> batch(Collection<Long> episodeIds, Long userId) {
return batch(episodeIds, userId, "");
}
public Map<Long, AppEpisodeStatsVO> batch(
Collection<Long> episodeIds, Long userId, String deviceId) {
if (episodeIds == null || episodeIds.isEmpty()) {
return Map.of();
}
@@ -58,8 +67,9 @@ public class AppEpisodeStatsService {
dramas.put(drama.getId(), drama);
}
Set<Long> likedIds = likeService.likedTargetIds(
userId, LikeService.TYPE_EPISODE, normalizedIds);
Set<Long> favoriteDramaIds = favoriteService.favoriteDramaIds(userId, dramaIds);
userId, deviceId, LikeService.TYPE_EPISODE, normalizedIds);
Set<Long> favoriteDramaIds = favoriteService.favoriteDramaIds(
userId, deviceId, dramaIds);
Map<Long, AppEpisodeStatsVO> result = new HashMap<>();
for (DramaEpisode episode : episodes) {
@@ -0,0 +1,24 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.user.event.AppInteractionMergeRequested;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
/** 在登录事务提交前执行合并,任一步失败都会整体回滚登录。 */
@Component
@RequiredArgsConstructor
public class InteractionAccountMergeListener {
private final InteractionAccountMergeService mergeService;
@TransactionalEventListener(
phase = TransactionPhase.BEFORE_COMMIT,
fallbackExecution = false)
public void onMergeRequested(AppInteractionMergeRequested event) {
mergeService.merge(event.userId(), event.deviceId());
}
}
@@ -0,0 +1,44 @@
// 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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** 登录后的设备互动归并;三项迁移共享一个数据库事务。 */
@Slf4j
@Service
@RequiredArgsConstructor
public class InteractionAccountMergeService {
private final LikeService likeService;
private final FavoriteService favoriteService;
private final CommentService commentService;
@Transactional(rollbackFor = Exception.class)
public void merge(Long userId, String deviceId) {
LikeService.MergeResult likes = likeService.mergeDeviceIntoUser(userId, deviceId);
FavoriteService.MergeResult favorites =
favoriteService.mergeDeviceIntoUser(userId, deviceId);
CommentService.MergeResult comments =
commentService.mergeDeviceIntoUser(userId, deviceId);
log.info(
"interaction-merge userId={} deviceId={} "
+ "likeMigrated={} likeDuplicates={} "
+ "favoriteMigrated={} favoriteDuplicates={} "
+ "commentMigrated={} commentDuplicates={}",
userId,
deviceId,
likes.migrated(),
likes.duplicates(),
favorites.migrated(),
favorites.duplicates(),
comments.migrated(),
comments.duplicates());
}
}
@@ -11,4 +11,7 @@ public class GoogleLoginReqVO {
@NotBlank(message = "缺少 Google idToken")
private String idToken;
/** 旧客户端未统一发送请求头时,仍可用于合并设备互动。 */
private String deviceId;
}
@@ -6,6 +6,8 @@ import com.fruitshorts.app.support.ControllerContractSupport;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.service.CommentService;
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
@@ -32,7 +34,11 @@ class AppCommentControllerContractTest {
commentService = mock(CommentService.class);
episodeService = mock(EpisodeService.class);
mockMvc = ControllerContractSupport.mockMvcOf(
new AppCommentController(commentService, episodeService));
new AppCommentController(
commentService,
episodeService,
mock(InteractionIdentityResolver.class),
mock(AnonymousInteractionRateLimiter.class)));
}
@Test
@@ -3,9 +3,17 @@
package com.fruitshorts.app.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fruitshorts.app.service.AppEpisodeStatsService;
import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.vo.AppEpisodeStatsVO;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.interaction.service.LikeService;
import com.fruitshorts.playback.service.FavoriteService;
import com.fruitshorts.playback.service.PlaybackService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
import com.fruitshorts.framework.log.AccessDeniedRecorder;
import com.fruitshorts.framework.security.JwtAuthenticationFilter;
import com.fruitshorts.framework.security.JwtTokenProvider;
@@ -36,6 +44,7 @@ import java.util.List;
import static org.mockito.BDDMockito.given;
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;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -52,12 +61,23 @@ class AppCommentCountAnonymousSecurityTest {
private CommentService commentService;
@Autowired
private AppCatalogService catalogService;
@Autowired
private AppEpisodeStatsService episodeStatsService;
@BeforeEach
void setUp() {
given(commentService.countByDrama(7L)).willReturn(9L);
given(catalogService.feed("ANIME", null, null, 20))
.willReturn(new CursorResult<>(List.of(), null, false));
AppEpisodeStatsVO stats = new AppEpisodeStatsVO();
stats.setEpisodeId(11L);
stats.setDramaId(7L);
stats.setLikeCount(12L);
stats.setCommentCount(3L);
stats.setFavoriteCount(8L);
stats.setLiked(true);
stats.setFavorited(false);
given(episodeStatsService.get(11L, null, "device-a")).willReturn(stats);
}
@Test
@@ -78,6 +98,52 @@ class AppCommentCountAnonymousSecurityTest {
.andExpect(jsonPath("$.data.list").isArray());
}
@Test
void should_allow_anonymous_non_paid_interactions_through_security_chain() throws Exception {
mockMvc.perform(post("/app/likes/toggle")
.header("X-Device-Id", "device-a")
.contentType("application/json")
.content("{\"targetType\":\"EPISODE\",\"targetId\":\"11\"}"))
.andExpect(status().isOk());
mockMvc.perform(post("/app/favorites/toggle")
.header("X-Device-Id", "device-a")
.contentType("application/json")
.content("{\"dramaId\":\"7\"}"))
.andExpect(status().isOk());
mockMvc.perform(post("/app/comments")
.header("X-Device-Id", "device-a")
.contentType("application/json")
.content("{\"dramaId\":\"7\",\"content\":\"hello\"}"))
.andExpect(status().isOk());
}
@Test
void should_allow_anonymous_episode_stats_through_security_chain() throws Exception {
mockMvc.perform(get("/app/episodes/11/stats")
.header("X-Device-Id", "device-a"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.episodeId").value(11))
.andExpect(jsonPath("$.data.liked").value(true))
.andExpect(jsonPath("$.data.favoriteCount").value(8));
}
@Test
void should_keep_paid_and_entitlement_operations_login_required() throws Exception {
mockMvc.perform(post("/app/episodes/11/unlock"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.UNAUTHORIZED.getCode()));
mockMvc.perform(get("/app/wallet"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.UNAUTHORIZED.getCode()));
mockMvc.perform(post("/app/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.UNAUTHORIZED.getCode()));
mockMvc.perform(get("/app/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.UNAUTHORIZED.getCode()));
}
@Configuration
@EnableWebMvc
@EnableWebSecurity
@@ -90,8 +156,87 @@ class AppCommentCountAnonymousSecurityTest {
}
@Bean
AppCommentController appCommentController(CommentService commentService) {
return new AppCommentController(commentService, mock(EpisodeService.class));
EpisodeService episodeService() {
return mock(EpisodeService.class);
}
@Bean
AppCommentController appCommentController(
CommentService commentService,
EpisodeService episodeService,
InteractionIdentityResolver identityResolver,
AnonymousInteractionRateLimiter rateLimiter) {
return new AppCommentController(
commentService,
episodeService,
identityResolver,
rateLimiter);
}
@Bean
LikeService likeService() {
return mock(LikeService.class);
}
@Bean
FavoriteService favoriteService() {
return mock(FavoriteService.class);
}
@Bean
AppInteractionController appInteractionController(
LikeService likeService,
InteractionIdentityResolver identityResolver,
AnonymousInteractionRateLimiter rateLimiter) {
return new AppInteractionController(
likeService,
mock(com.fruitshorts.interaction.service.ReportService.class),
identityResolver,
rateLimiter);
}
@Bean
AppFavoriteController appFavoriteController(
FavoriteService favoriteService,
InteractionIdentityResolver identityResolver,
AnonymousInteractionRateLimiter rateLimiter) {
return new AppFavoriteController(
favoriteService,
mock(AppCatalogService.class),
identityResolver,
rateLimiter);
}
@Bean
AppEpisodeStatsService appEpisodeStatsService() {
return mock(AppEpisodeStatsService.class);
}
@Bean
AppDramaController appDramaController(
AppCatalogService appCatalogService,
EpisodeService episodeService,
CommentService commentService,
AppEpisodeStatsService appEpisodeStatsService) {
return new AppDramaController(
appCatalogService,
mock(PlaybackService.class),
episodeService,
commentService,
appEpisodeStatsService);
}
@Bean
InteractionIdentityResolver identityResolver() {
return new InteractionIdentityResolver();
}
@Bean
AnonymousInteractionRateLimiter anonymousInteractionRateLimiter() {
AnonymousInteractionRateLimiter limiter =
mock(AnonymousInteractionRateLimiter.class);
Mockito.doNothing().when(limiter).check(Mockito.any(), Mockito.anyString());
return limiter;
}
@Bean
@@ -132,4 +277,5 @@ class AppCommentCountAnonymousSecurityTest {
.build();
}
}
}
@@ -8,6 +8,8 @@ import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.framework.security.LoginUser;
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
import com.fruitshorts.playback.service.FavoriteService;
import com.fruitshorts.playback.service.WatchProgressService;
import com.fruitshorts.playback.vo.ProgressRespVO;
@@ -45,7 +47,11 @@ class AppLibraryPagingContractTest {
catalogService = mock(AppCatalogService.class);
progressService = mock(WatchProgressService.class);
favoriteMvc = ControllerContractSupport.mockMvcOf(
new AppFavoriteController(favoriteService, catalogService));
new AppFavoriteController(
favoriteService,
catalogService,
mock(InteractionIdentityResolver.class),
mock(AnonymousInteractionRateLimiter.class)));
watchMvc = ControllerContractSupport.mockMvcOf(
new AppWatchController(progressService));
SecurityContextHolder.getContext().setAuthentication(
@@ -116,7 +116,7 @@ class AppPlaybackContractTest {
stats.setFavoriteCount(1800L);
stats.setLiked(true);
stats.setFavorited(false);
given(episodeStatsService.get(11L, null)).willReturn(stats);
given(episodeStatsService.get(11L, null, null)).willReturn(stats);
mockMvc.perform(get("/app/episodes/11/stats"))
.andExpect(status().isOk())
@@ -128,7 +128,7 @@ class AppPlaybackContractTest {
.andExpect(jsonPath("$.data.liked").value(true))
.andExpect(jsonPath("$.data.favorited").value(false));
verify(episodeStatsService).get(11L, null);
verify(episodeStatsService).get(11L, null, null);
verify(playbackService, org.mockito.Mockito.never()).play(any(), any(), any(), any());
}
@@ -0,0 +1,48 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.interaction.InteractionIdentity;
import com.fruitshorts.framework.redis.RedisService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
class AnonymousInteractionRateLimiterTest {
@Test
void should_reject_anonymous_device_after_per_minute_limit() {
RedisService redisService = mock(RedisService.class);
given(redisService.increment(anyString(), any())).willReturn(31L);
AnonymousInteractionRateLimiter limiter =
new AnonymousInteractionRateLimiter(redisService);
ReflectionTestUtils.setField(limiter, "writesPerMinute", 30);
assertThatThrownBy(() -> limiter.check(
new InteractionIdentity(null, "device-a"),
"like"))
.isInstanceOfSatisfying(ServiceException.class, error ->
org.assertj.core.api.Assertions.assertThat(error.getCode())
.isEqualTo(ErrorCode.TOO_MANY_REQUESTS.getCode()));
}
@Test
void should_not_rate_limit_authenticated_user() {
RedisService redisService = mock(RedisService.class);
AnonymousInteractionRateLimiter limiter =
new AnonymousInteractionRateLimiter(redisService);
limiter.check(new InteractionIdentity(9L, ""), "comment");
verify(redisService, never()).increment(anyString(), any());
}
}
@@ -16,6 +16,7 @@ import com.fruitshorts.user.dal.mapper.AppUserMapper;
import com.fruitshorts.user.dal.mapper.AppUserOtpMapper;
import com.fruitshorts.user.dal.mapper.AppUserSessionMapper;
import com.fruitshorts.user.enums.AppUserStatus;
import com.fruitshorts.user.event.AppInteractionMergeRequested;
import com.fruitshorts.user.service.AppAuthService;
import com.fruitshorts.user.service.AppOtpService;
import com.fruitshorts.user.service.CredentialHasher;
@@ -27,7 +28,9 @@ import com.fruitshorts.user.vo.RegisterReqVO;
import com.fruitshorts.user.vo.TokenRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Duration;
@@ -63,6 +66,7 @@ class AppAuthServiceTest {
private PasswordEncoder passwordEncoder;
private ConfigService configService;
private GoogleIdTokenVerifier googleVerifier;
private ApplicationEventPublisher eventPublisher;
private AppAuthService authService;
@BeforeEach
@@ -78,11 +82,12 @@ class AppAuthServiceTest {
passwordEncoder = mock(PasswordEncoder.class);
configService = mock(ConfigService.class);
googleVerifier = mock(GoogleIdTokenVerifier.class);
eventPublisher = mock(ApplicationEventPublisher.class);
given(configService.get(anyString(), anyString()))
.willAnswer(invocation -> invocation.getArgument(1));
authService = new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
deviceMapper, otpService, hasher, tokenProvider, passwordEncoder, configService,
googleVerifier);
googleVerifier, eventPublisher);
}
@Test
@@ -98,6 +103,9 @@ class AppAuthServiceTest {
// 免码注册不等于"已验证":邮箱可达性没验证过,所以先记 0,等日后点验证链接再置 1
assertThat(saved.get().getVerified()).isZero();
verify(otpService, never()).consume(anyString(), anyString(), anyString(), any());
AppInteractionMergeRequested event = capturedMergeEvent();
assertThat(event.userId()).isEqualTo(1001L);
assertThat(event.deviceId()).isEqualTo("device-9");
}
@Test
@@ -251,6 +259,9 @@ class AppAuthServiceTest {
assertThat(resp.getAccessToken()).isEqualTo("access-1");
assertThat(resp.getUser().getUid()).isEqualTo("UAB12CD");
verify(sessionMapper).insert(any(AppUserSession.class));
AppInteractionMergeRequested event = capturedMergeEvent();
assertThat(event.userId()).isEqualTo(1001L);
assertThat(event.deviceId()).isEqualTo("device-9");
}
@Test
@@ -307,11 +318,14 @@ class AppAuthServiceTest {
});
given(userMapper.selectById(any())).willAnswer(invocation -> inserted.get());
TokenRespVO resp = authService.googleLogin("google-id-token");
TokenRespVO resp = authService.googleLogin("google-id-token", "device-9");
assertThat(resp.getAccessToken()).isEqualTo("access-1");
assertThat(resp.isNewUser()).isTrue();
verify(sessionMapper).insert(any(AppUserSession.class));
AppInteractionMergeRequested event = capturedMergeEvent();
assertThat(event.userId()).isEqualTo(1001L);
assertThat(event.deviceId()).isEqualTo("device-9");
}
@Test
@@ -319,7 +333,7 @@ class AppAuthServiceTest {
given(googleVerifier.verify(anyString()))
.willReturn(new GoogleUser("sub-1", "", "Alice", ""));
assertThatThrownBy(() -> authService.googleLogin("google-id-token"))
assertThatThrownBy(() -> authService.googleLogin("google-id-token", "device-9"))
.isInstanceOf(ServiceException.class)
.hasMessage("Google 账号未返回邮箱,无法登录");
}
@@ -353,6 +367,13 @@ class AppAuthServiceTest {
return saved;
}
private AppInteractionMergeRequested capturedMergeEvent() {
ArgumentCaptor<AppInteractionMergeRequested> event =
ArgumentCaptor.forClass(AppInteractionMergeRequested.class);
verify(eventPublisher).publishEvent(event.capture());
return event.getValue();
}
private void stubLogin(AppUserCredential credential, AppUser user) {
given(credentialMapper.selectOne(any())).willReturn(credential);
given(userMapper.selectById(any())).willReturn(user);
@@ -371,7 +392,7 @@ class AppAuthServiceTest {
AppOtpService realOtpService = new AppOtpService(otpMapper, mock(OtpSender.class), otpHasher);
return new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
deviceMapper, realOtpService, hasher, tokenProvider, passwordEncoder, configService,
googleVerifier);
googleVerifier, eventPublisher);
}
private void setRegisterLimits(int perIp, int perDevice) {
@@ -42,12 +42,14 @@ class AppEpisodeStatsServiceTest {
given(dramaService.listPublishedByIds(List.of(7L))).willReturn(List.of(drama));
given(commentService.countByEpisodes(List.of(11L))).willReturn(Map.of(11L, 8L));
given(likeService.likedTargetIds(
100L, LikeService.TYPE_EPISODE, List.of(11L))).willReturn(Set.of(11L));
given(favoriteService.favoriteDramaIds(100L, List.of(7L))).willReturn(Set.of());
100L, "device-1", LikeService.TYPE_EPISODE, List.of(11L)))
.willReturn(Set.of(11L));
given(favoriteService.favoriteDramaIds(100L, "device-1", List.of(7L)))
.willReturn(Set.of());
AppEpisodeStatsService service = new AppEpisodeStatsService(
episodeService, dramaService, commentService, likeService, favoriteService);
AppEpisodeStatsVO result = service.get(11L, 100L);
AppEpisodeStatsVO result = service.get(11L, 100L, "device-1");
assertThat(result.getEpisodeId()).isEqualTo(11L);
assertThat(result.getDramaId()).isEqualTo(7L);
@@ -59,7 +61,7 @@ class AppEpisodeStatsServiceTest {
verify(episodeService).listPublishedByIds(List.of(11L));
verify(commentService).countByEpisodes(List.of(11L));
verify(likeService).likedTargetIds(
100L, LikeService.TYPE_EPISODE, List.of(11L));
verify(favoriteService).favoriteDramaIds(100L, List.of(7L));
100L, "device-1", LikeService.TYPE_EPISODE, List.of(11L));
verify(favoriteService).favoriteDramaIds(100L, "device-1", List.of(7L));
}
}
@@ -1,6 +1,8 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.DramaService;
@@ -20,6 +22,7 @@ import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
@@ -112,6 +115,30 @@ class PlaybackServiceTest {
verify(ticketMapper, never()).insert(any(PlaybackTicket.class));
}
@Test
void should_reject_locked_episode_ticket_for_anonymous_device() {
given(episodeService.getPublished(12L)).willReturn(episode(12L, 0, 60, "/files/episode/2.mp4"));
assertThatThrownBy(() ->
playbackService.play(null, 12L, "device-9", "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.extracting(error -> ((ServiceException) error).getCode())
.isEqualTo(ErrorCode.UNAUTHORIZED.getCode());
verify(signer, never()).sign(anyString());
}
@Test
void should_reject_locked_episode_ticket_before_no_media_for_anonymous_device() {
given(episodeService.getPublished(17L)).willReturn(episode(17L, 0, 60, ""));
assertThatThrownBy(() ->
playbackService.play(null, 17L, "device-9", "203.0.113.8"))
.isInstanceOf(ServiceException.class)
.extracting(error -> ((ServiceException) error).getCode())
.isEqualTo(ErrorCode.UNAUTHORIZED.getCode());
verify(signer, never()).sign(anyString());
}
@Test
void should_return_entitled_access_when_entitlement_port_confirms_unlock() {
given(episodeService.getPublished(13L)).willReturn(episode(13L, 0, 60, "/files/episode/3.mp4"));
@@ -0,0 +1,133 @@
// Author: fruit.com.cn
package com.fruitshorts.interaction.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.common.interaction.InteractionIdentity;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.config.InteractionProperties;
import com.fruitshorts.interaction.dal.entity.ContentComment;
import com.fruitshorts.interaction.dal.mapper.ContentAuditRecordMapper;
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
import com.fruitshorts.interaction.dal.mapper.ContentLikeMapper;
import com.fruitshorts.interaction.vo.CommentPublishReqVO;
import com.fruitshorts.interaction.vo.CommentRespVO;
import com.fruitshorts.user.dal.mapper.AppUserMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class AnonymousCommentServiceTest {
private ContentCommentMapper commentMapper;
private SensitiveWordService sensitiveWordService;
private CommentService commentService;
@BeforeAll
static void initTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
ContentComment.class);
}
@BeforeEach
void setUp() {
commentMapper = mock(ContentCommentMapper.class);
sensitiveWordService = mock(SensitiveWordService.class);
AuditRecorder auditRecorder = mock(AuditRecorder.class);
commentService = new CommentService(
commentMapper,
mock(ContentLikeMapper.class),
mock(ContentAuditRecordMapper.class),
mock(AppUserMapper.class),
mock(DramaService.class),
mock(EpisodeService.class),
sensitiveWordService,
auditRecorder,
new InteractionProperties());
}
@Test
void should_publish_anonymous_comment_with_guest_name_and_device_owner() {
given(commentMapper.selectCount(any())).willReturn(0L);
given(commentMapper.insert(any(ContentComment.class))).willReturn(1);
given(sensitiveWordService.check("hello")).willReturn(
SensitiveWordCheck.pass("hello"));
CommentRespVO result = commentService.publish(
new InteractionIdentity(null, "device-a"),
request("hello"),
"127.0.0.1");
ArgumentCaptor<ContentComment> captor =
ArgumentCaptor.forClass(ContentComment.class);
verify(commentMapper).insert(captor.capture());
assertThat(captor.getValue().getUserId()).isNull();
assertThat(captor.getValue().getDeviceId()).isEqualTo("device-a");
assertThat(result.getNickname()).isEqualTo("游客");
assertThat(result.getMine()).isTrue();
}
@Test
void should_reject_duplicate_anonymous_content_within_one_minute() {
given(commentMapper.selectCount(any())).willReturn(0L, 1L);
given(sensitiveWordService.check("hello")).willReturn(
SensitiveWordCheck.pass("hello"));
assertThatThrownBy(() -> commentService.publish(
new InteractionIdentity(null, "device-a"),
request("hello"),
"127.0.0.1"))
.hasMessageContaining("重复");
}
@Test
void should_merge_device_comments_and_remove_duplicate() {
ContentComment duplicate = comment(1L, "same");
ContentComment unique = comment(2L, "new");
given(commentMapper.selectList(any())).willReturn(List.of(duplicate, unique));
given(commentMapper.selectOne(any())).willReturn(comment(3L, "same"), null);
given(commentMapper.physicalDeleteById(1L)).willReturn(1);
CommentService.MergeResult result =
commentService.mergeDeviceIntoUser(9L, "device-a");
assertThat(result.migrated()).isEqualTo(1);
assertThat(result.duplicates()).isEqualTo(1);
verify(commentMapper).physicalDeleteById(1L);
verify(commentMapper).updateById(unique);
assertThat(unique.getUserId()).isEqualTo(9L);
}
private CommentPublishReqVO request(String content) {
CommentPublishReqVO req = new CommentPublishReqVO();
req.setDramaId(7L);
req.setContent(content);
return req;
}
private ContentComment comment(Long id, String content) {
ContentComment comment = new ContentComment();
comment.setId(id);
comment.setDramaId(7L);
comment.setEpisodeId(0L);
comment.setParentId(0L);
comment.setContent(content);
comment.setStatus(CommentService.STATUS_PUBLISHED);
comment.setDeviceId("device-a");
return comment;
}
}
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.interaction.InteractionIdentity;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.DramaService;
@@ -19,6 +20,9 @@ import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -95,6 +99,44 @@ class LikeServiceEpisodeTest {
verify(likeMapper, never()).insert(any(ContentLike.class));
}
@Test
void should_store_anonymous_episode_like_by_device() {
DramaEpisode published = episode(1L);
given(episodeService.getPublished(11L)).willReturn(published);
given(dramaService.getPublished(5L)).willReturn(new Drama());
given(likeMapper.selectOne(any())).willReturn(null);
given(episodeService.getExisting(11L)).willReturn(published);
likeService.toggle(new InteractionIdentity(null, "device-a"), request(11L));
ArgumentCaptor<ContentLike> captor = ArgumentCaptor.forClass(ContentLike.class);
verify(likeMapper).insert(captor.capture());
assertThat(captor.getValue().getUserId()).isNull();
assertThat(captor.getValue().getDeviceId()).isEqualTo("device-a");
}
@Test
void should_merge_device_likes_and_decrement_duplicate_count_once() {
DramaEpisode published = episode(2L);
ContentLike duplicate = like(1L, LikeService.TYPE_EPISODE, 11L);
ContentLike unique = like(2L, LikeService.TYPE_EPISODE, 12L);
given(likeMapper.selectList(any())).willReturn(List.of(duplicate, unique));
given(likeMapper.selectOne(any())).willReturn(
like(3L, LikeService.TYPE_EPISODE, 11L),
null);
given(likeMapper.physicalDeleteById(1L)).willReturn(1);
given(episodeService.getExisting(11L)).willReturn(published);
given(episodeService.getExisting(12L)).willReturn(published);
LikeService.MergeResult result = likeService.mergeDeviceIntoUser(9L, "device-a");
assertThat(result.migrated()).isEqualTo(1);
assertThat(result.duplicates()).isEqualTo(1);
verify(episodeService).adjustLikeCount(11L, -1);
verify(likeMapper).updateById(unique);
assertThat(unique.getUserId()).isEqualTo(9L);
}
private DramaEpisode episode(Long likeCount) {
DramaEpisode episode = new DramaEpisode();
episode.setId(11L);
@@ -109,4 +151,12 @@ class LikeServiceEpisodeTest {
req.setTargetId(episodeId);
return req;
}
private ContentLike like(Long id, String type, Long targetId) {
ContentLike like = new ContentLike();
like.setId(id);
like.setTargetType(type);
like.setTargetId(targetId);
return like;
}
}
@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.interaction.InteractionIdentity;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.playback.dal.entity.UserFavorite;
@@ -58,6 +59,38 @@ class FavoriteServiceTest {
verify(dramaService).adjustFavoriteCount(7L, 1);
}
@Test
void should_store_anonymous_favorite_by_device() {
given(favoriteMapper.selectOne(any())).willReturn(null);
given(favoriteMapper.insert(any(UserFavorite.class))).willReturn(1);
favoriteService.toggle(new InteractionIdentity(null, "device-a"), 7L);
ArgumentCaptor<UserFavorite> captor = ArgumentCaptor.forClass(UserFavorite.class);
verify(favoriteMapper).insert(captor.capture());
assertThat(captor.getValue().getUserId()).isNull();
assertThat(captor.getValue().getDeviceId()).isEqualTo("device-a");
verify(dramaService).adjustFavoriteCount(7L, 1);
}
@Test
void should_merge_device_favorites_and_decrement_duplicate_count_once() {
UserFavorite duplicate = favorite(7L);
UserFavorite unique = favorite(8L);
given(favoriteMapper.selectList(any())).willReturn(List.of(duplicate, unique));
given(favoriteMapper.selectOne(any())).willReturn(favorite(9L), null);
given(favoriteMapper.physicalDeleteById(duplicate.getId())).willReturn(1);
FavoriteService.MergeResult result =
favoriteService.mergeDeviceIntoUser(1L, "device-a");
assertThat(result.migrated()).isEqualTo(1);
assertThat(result.duplicates()).isEqualTo(1);
verify(dramaService).adjustFavoriteCount(7L, -1);
verify(favoriteMapper).updateById(unique);
assertThat(unique.getUserId()).isEqualTo(1L);
}
@Test
void should_decrement_favorite_count_when_toggle_removes_relation() {
given(favoriteMapper.selectOne(any())).willReturn(new UserFavorite());