diff --git a/docs/api-phase1.md b/docs/api-phase1.md index ac2114f..4395593 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -286,7 +286,7 @@ app.register.max-per-device-per-day | 方法 | 路径 | 鉴权 | 说明 | | --- | --- | --- | --- | -| GET | `/app/episodes/{id}/play` | 可选 | 签发播放票据与签名地址 | +| GET | `/app/episodes/{id}/play` | 免费集可选;锁定集必须登录 | 签发播放票据与签名地址 | | GET | `/app/episodes/{id}/stats` | 可选 | 播放页轻量计数快照,不签发地址、不写票据缓存 | ```json @@ -311,8 +311,10 @@ app.register.max-per-device-per-day } ``` -`accessType` 取值:`FREE`(免费集)、`ENTITLED`(已购权益)、`COIN_REQUIRED`(需解锁, -此时 `playUrl` 为空、`unlockCoin` 有值)、`NO_MEDIA`(还没上传片源或未转码完成)。 +`accessType` 取值:`FREE`(免费集)、`ENTITLED`(已购权益)、`COIN_REQUIRED`(已登录但 +尚未解锁,此时 `playUrl` 为空、`unlockCoin` 有值)、`NO_MEDIA`(已登录用户访问的剧集还 +没上传片源或未转码完成)。未登录请求锁定集在查询片源前直接返回 `UNAUTHORIZED`,不会 +按设备身份取票或降级为匿名播放。 **判定顺序由服务端决定,客户端不得自行推断可否播放。** 签名默认 15 分钟有效; 开启 `fruitshorts.media.signature-required` 后,`/files/episode/**` 没带有效签名会返回 403。 @@ -324,7 +326,8 @@ app.register.max-per-device-per-day **`GET /app/episodes/{id}/stats`(播放页计数刷新)** 只读返回本集点赞数、本集未删除评论数、所属剧收藏数,以及当前用户对 -本集/所属剧的互动状态。匿名请求时 `liked`、`favorited` 均为 `false`。 +本集/所属剧的互动状态。已登录时按账号读取;匿名请求时按 `X-Device-Id` +读取设备互动状态,未带设备号时 `liked`、`favorited` 均为 `false`。 接口不生成播放签名地址、不写 `playback_ticket` 缓存,计数变化后立即可读。 ```json @@ -354,9 +357,9 @@ app.register.max-per-device-per-day | GET | `/app/watch/recent` | 最近观看分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 | | DELETE | `/app/watch/progress/{dramaId}` | 删除某剧记录 | | DELETE | `/app/watch/progress` | 清空记录 | -| POST | `/app/favorites/toggle` | 收藏/取消收藏,返回切换后状态 | +| POST | `/app/favorites/toggle` | 可选登录 | 收藏/取消收藏,返回切换后状态;匿名按 `X-Device-Id` | | GET | `/app/favorites` | 收藏分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 | -| DELETE | `/app/favorites/{dramaId}` | 取消收藏 | +| DELETE | `/app/favorites/{dramaId}` | 可选登录 | 取消收藏;匿名按 `X-Device-Id` | | POST | `/app/favorites/remove` | 批量取消收藏,请求体 `{ "dramaIds": ["12", "15"] }` | 进度合并规则:同一集内只前进不后退;换集直接采纳新集;`finished` 只在接近片尾或客户端显式上报时置位。 @@ -471,13 +474,22 @@ app.register.max-per-device-per-day | 方法 | 路径 | 鉴权 | 说明 | | --- | --- | --- | --- | -| POST | `/app/comments` | 登录 | 发表评论/回复:过敏感词后按策略发布或转待审 | +| POST | `/app/comments` | 可选登录 | 发表评论/回复;匿名按 `X-Device-Id`,过敏感词后按策略发布或转待审 | | GET | `/app/comments?dramaId=&cursor=&size=` | 匿名 | 游标翻页;不传 `rootId` 返回顶层评论,传了返回该楼层回复 | | GET | `/app/comments/count?dramaId=&episodeId=` | 匿名 | 传 `episodeId` 返回该集评论数;仅传 `dramaId` 返回全剧兼容汇总 | -| DELETE | `/app/comments/{id}` | 登录 | 删除自己的评论(逻辑置为 `deleted`) | -| POST | `/app/likes/toggle` | 登录 | 点赞/取消点赞,`targetType` 支持 `DRAMA` / `EPISODE` / `COMMENT`;新客户端点赞按集 | +| DELETE | `/app/comments/{id}` | 可选登录 | 删除自己的评论(匿名按设备);逻辑置为 `deleted` | +| POST | `/app/likes/toggle` | 可选登录 | 点赞/取消点赞,`targetType` 支持 `DRAMA` / `EPISODE` / `COMMENT`;匿名按 `X-Device-Id` | | POST | `/app/reports` | 登录 | 举报评论/用户/短剧;同一目标只保留一条待处理工单 | +**非付费互动身份与合并**:点赞、收藏、评论的身份解析顺序为登录 `userId` → +`X-Device-Id` → 请求体 `deviceId`。三者都没有时返回业务码 `200007`。匿名写入按设备号 +每分钟限流;评论同时做长度、空白、短时间重复内容和频率校验。登录或注册成功后,服务端 +在同一登录事务内把该设备未归属的点赞、收藏、评论迁移到账号;账号已有同内容时保留账号 +记录并删除设备重复记录,计数同步回退,不重复累加。 + +匿名评论展示为「游客」,头像为空占位。解锁、金币/VIP 权益、锁定集正片票据不在本规则内, +仍必须登录。 + **敏感词三档处置**:`BLOCK` 直接拒绝并返回原因、`REPLACE` 替换后放行、`REVIEW` 转人工待审。 审核策略由 `fruitshorts.interaction.comment-audit-mode` 控制: `MACHINE_FIRST`(机审通过即发布,当前默认)或 `PRE_REVIEW`(全部先审后发,生产建议)。 @@ -1026,8 +1038,9 @@ GET /app/notices?pageIndex=1&pageSize=20 只接受 `bizType=EPISODE` 且状态为 `ready` / `uploaded` 的产物。 **收费配置**:`previewSeconds` 落在 `drama_episode.preview_seconds`(0 = 不限制), -与 `is_free` / `unlock_coin` 一起就是播放与下载付费判定读的字段,**改完立即生效**(实测:未登录请求付费集返回 -`{"accessType":"COIN_REQUIRED","unlockCoin":50,"previewSeconds":15}`;免费集直接返回签名播放地址)。 +与 `is_free` / `unlock_coin` 一起就是播放与下载付费判定读的字段,**改完立即生效**(实测:未登录请求锁定集直接 +返回 `UNAUTHORIZED`;已登录未解锁用户返回 `{"accessType":"COIN_REQUIRED","unlockCoin":50,"previewSeconds":15}`; +免费集直接返回签名播放地址)。 定价沿用「可编辑状态」口径:`draft` / `offline` 可改,已上线要改价需先申请下线(避免创作者悄悄改已上线内容的价格); `dramaId` 以路径为准,body 里传别人的剧 id 无效,列表里带别人的 `episodeId` 会被忽略(只作用于自己剧的分集)。 diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java index 5a381fd..fce723a 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java @@ -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 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 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 google(@Valid @RequestBody GoogleLoginReqVO req) { - return Result.ok(authService.googleLogin(req.getIdToken())); + public Result 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")); + } + } } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommentController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommentController.java index 284ea12..64a3e75 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommentController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommentController.java @@ -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; * *

列表走游标翻页:`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 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 delete(@PathVariable Long id) { - commentService.delete(SecurityUtils.getAppUserId(), id); + public Result 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(); } } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppDramaController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppDramaController.java index 79c5c1c..68fab8d 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppDramaController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppDramaController.java @@ -66,7 +66,11 @@ public class AppDramaController { /** 轻量计数快照:只读互动状态,不签发播放地址、不写票据缓存。 */ @Operation(summary = "播放页轻量计数与当前用户互动状态") @GetMapping("/episodes/{id}/stats") - public Result stats(@PathVariable Long id) { - return Result.ok(appEpisodeStatsService.get(id, SecurityUtils.getUserIdOrNull())); + public Result stats( + @PathVariable Long id, HttpServletRequest request) { + return Result.ok(appEpisodeStatsService.get( + id, + SecurityUtils.getUserIdOrNull(), + request.getHeader("X-Device-Id"))); } } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppFavoriteController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppFavoriteController.java index 7bc4183..a8aa734 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppFavoriteController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppFavoriteController.java @@ -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 toggle(@Valid @RequestBody FavoriteToggleReqVO req) { - return Result.ok(favoriteService.toggle(SecurityUtils.getAppUserId(), req.getDramaId())); + public Result 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 remove(@PathVariable Long dramaId) { - favoriteService.remove(SecurityUtils.getAppUserId(), dramaId); + public Result 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(); } } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppInteractionController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppInteractionController.java index 19e0e59..0033ab6 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppInteractionController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppInteractionController.java @@ -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 toggleLike(@Valid @RequestBody LikeToggleReqVO req) { - return Result.ok(likeService.toggle(SecurityUtils.getAppUserId(), req)); + public Result 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)); } /** 提交举报:同一用户对同一目标只保留一条待处理工单。 */ diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AnonymousInteractionRateLimiter.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AnonymousInteractionRateLimiter.java new file mode 100644 index 0000000..17bbdb6 --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AnonymousInteractionRateLimiter.java @@ -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, "操作过于频繁,请稍后再试"); + } + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppEpisodeStatsService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppEpisodeStatsService.java index 877e29f..54cd8b1 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppEpisodeStatsService.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppEpisodeStatsService.java @@ -32,8 +32,8 @@ public class AppEpisodeStatsService { private final LikeService likeService; private final FavoriteService favoriteService; - public AppEpisodeStatsVO get(Long episodeId, Long userId) { - Map stats = batch(List.of(episodeId), userId); + public AppEpisodeStatsVO get(Long episodeId, Long userId, String deviceId) { + Map 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 batch(Collection episodeIds, Long userId) { + return batch(episodeIds, userId, ""); + } + + public Map batch( + Collection 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 likedIds = likeService.likedTargetIds( - userId, LikeService.TYPE_EPISODE, normalizedIds); - Set favoriteDramaIds = favoriteService.favoriteDramaIds(userId, dramaIds); + userId, deviceId, LikeService.TYPE_EPISODE, normalizedIds); + Set favoriteDramaIds = favoriteService.favoriteDramaIds( + userId, deviceId, dramaIds); Map result = new HashMap<>(); for (DramaEpisode episode : episodes) { diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/InteractionAccountMergeListener.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/InteractionAccountMergeListener.java new file mode 100644 index 0000000..19ecbee --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/InteractionAccountMergeListener.java @@ -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()); + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/InteractionAccountMergeService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/InteractionAccountMergeService.java new file mode 100644 index 0000000..4a9df41 --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/InteractionAccountMergeService.java @@ -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()); + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/GoogleLoginReqVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/GoogleLoginReqVO.java index 69f77dc..b30d4f6 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/GoogleLoginReqVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/GoogleLoginReqVO.java @@ -11,4 +11,7 @@ public class GoogleLoginReqVO { @NotBlank(message = "缺少 Google idToken") private String idToken; + + /** 旧客户端未统一发送请求头时,仍可用于合并设备互动。 */ + private String deviceId; } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentControllerContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentControllerContractTest.java index f96319c..358c10d 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentControllerContractTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentControllerContractTest.java @@ -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 diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java index e3120d4..9dd353e 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java @@ -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(); } } + } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppLibraryPagingContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppLibraryPagingContractTest.java index 525b9d3..39e228d 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppLibraryPagingContractTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppLibraryPagingContractTest.java @@ -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( diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java index f1c0cb2..3999a4f 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java @@ -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()); } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AnonymousInteractionRateLimiterTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AnonymousInteractionRateLimiterTest.java new file mode 100644 index 0000000..9a5c912 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AnonymousInteractionRateLimiterTest.java @@ -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()); + } +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java index 403a685..1976835 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java @@ -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 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) { diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppEpisodeStatsServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppEpisodeStatsServiceTest.java index 665ed91..4cef1d1 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppEpisodeStatsServiceTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppEpisodeStatsServiceTest.java @@ -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)); } } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/PlaybackServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/PlaybackServiceTest.java index 08681d4..f91a470 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/PlaybackServiceTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/PlaybackServiceTest.java @@ -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")); diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/interaction/service/AnonymousCommentServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/interaction/service/AnonymousCommentServiceTest.java new file mode 100644 index 0000000..b086b7b --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/interaction/service/AnonymousCommentServiceTest.java @@ -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 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; + } +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/interaction/service/LikeServiceEpisodeTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/interaction/service/LikeServiceEpisodeTest.java index 10d5940..cee3d6a 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/interaction/service/LikeServiceEpisodeTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/interaction/service/LikeServiceEpisodeTest.java @@ -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 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; + } } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/FavoriteServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/FavoriteServiceTest.java index d886ae1..ef0eb35 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/FavoriteServiceTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/FavoriteServiceTest.java @@ -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 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()); diff --git a/fruitshorts-common/src/main/java/com/fruitshorts/common/exception/ErrorCode.java b/fruitshorts-common/src/main/java/com/fruitshorts/common/exception/ErrorCode.java index b7fc80b..ecb3b31 100644 --- a/fruitshorts-common/src/main/java/com/fruitshorts/common/exception/ErrorCode.java +++ b/fruitshorts-common/src/main/java/com/fruitshorts/common/exception/ErrorCode.java @@ -37,7 +37,9 @@ public enum ErrorCode { */ CREATOR_NOT_BOUND(200004, "当前账号未绑定创作者主体"), CREATOR_TARGET_REQUIRED(200005, "请先选择要代看的创作者"), - CREATOR_IMPERSONATE_READONLY(200006, "代看模式下只读,不能修改创作者数据"); + CREATOR_IMPERSONATE_READONLY(200006, "代看模式下只读,不能修改创作者数据"), + + INTERACTION_IDENTITY_REQUIRED(200007, "缺少登录身份或设备标识,请重新登录"); private final int code; private final String message; diff --git a/fruitshorts-common/src/main/java/com/fruitshorts/common/interaction/InteractionIdentity.java b/fruitshorts-common/src/main/java/com/fruitshorts/common/interaction/InteractionIdentity.java new file mode 100644 index 0000000..604b42b --- /dev/null +++ b/fruitshorts-common/src/main/java/com/fruitshorts/common/interaction/InteractionIdentity.java @@ -0,0 +1,11 @@ +// Author: fruit.com.cn + +package com.fruitshorts.common.interaction; + +/** 非付费互动的归属身份:登录用户优先,否则使用稳定设备号。 */ +public record InteractionIdentity(Long userId, String deviceId) { + + public boolean authenticated() { + return userId != null; + } +} diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/interaction/InteractionIdentityResolver.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/interaction/InteractionIdentityResolver.java new file mode 100644 index 0000000..b1cbc7b --- /dev/null +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/interaction/InteractionIdentityResolver.java @@ -0,0 +1,33 @@ +// Author: fruit.com.cn + +package com.fruitshorts.framework.interaction; + +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.common.interaction.InteractionIdentity; +import org.springframework.stereotype.Component; + +/** 点赞、收藏、评论共用的身份解析入口。 */ +@Component +public class InteractionIdentityResolver { + + public InteractionIdentity resolve(Long userId, String headerDeviceId, String bodyDeviceId) { + if (userId != null) { + return new InteractionIdentity(userId, ""); + } + String deviceId = normalize(headerDeviceId); + if (deviceId.isEmpty()) { + deviceId = normalize(bodyDeviceId); + } + if (deviceId.isEmpty()) { + throw new ServiceException( + ErrorCode.INTERACTION_IDENTITY_REQUIRED, + ErrorCode.INTERACTION_IDENTITY_REQUIRED.getMessage()); + } + return new InteractionIdentity(null, deviceId); + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java index 3c8f04f..1bb6691 100644 --- a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java @@ -45,6 +45,7 @@ public class SecurityConfig { "/app/dramas/**", "/app/search", "/app/episodes/*/play", + "/app/episodes/*/stats", "/app/config/**", /* 启动配置与公告:客户端登录前就要拿(POST /app/creator/apply 等仍要登录) */ "/app/config", @@ -80,7 +81,12 @@ public class SecurityConfig { .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(registry -> registry .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - /* 评论列表允许匿名浏览;发布/删除仍需登录(未列入白名单即需鉴权) */ + /* 非付费互动允许设备身份;解锁、钱包与权益接口仍只允许登录用户。 */ + .requestMatchers(HttpMethod.POST, "/app/likes/toggle").permitAll() + .requestMatchers(HttpMethod.POST, "/app/favorites/toggle").permitAll() + .requestMatchers(HttpMethod.DELETE, "/app/favorites/*").permitAll() + .requestMatchers(HttpMethod.POST, "/app/comments").permitAll() + .requestMatchers(HttpMethod.DELETE, "/app/comments/*").permitAll() .requestMatchers(HttpMethod.GET, "/app/comments").permitAll() .requestMatchers(HttpMethod.GET, "/app/comments/count").permitAll() .requestMatchers(PUBLIC_PATHS).permitAll() diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentLike.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentLike.java index 93fa8b7..671fa96 100644 --- a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentLike.java +++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/entity/ContentLike.java @@ -18,6 +18,7 @@ public class ContentLike { @TableId(type = IdType.ASSIGN_ID) private Long id; private Long userId; + private String deviceId; /** DRAMA/EPISODE/COMMENT */ private String targetType; private Long targetId; diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentCommentMapper.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentCommentMapper.java index 730194b..4dc4441 100644 --- a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentCommentMapper.java +++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentCommentMapper.java @@ -4,6 +4,7 @@ package com.fruitshorts.interaction.dal.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.fruitshorts.interaction.dal.entity.ContentComment; +import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @@ -12,6 +13,9 @@ import java.util.List; public interface ContentCommentMapper extends BaseMapper { + @Delete("DELETE FROM content_comment WHERE id = #{id}") + int physicalDeleteById(@Param("id") Long id); + /** * 后台举报工单按评论 id 批量读取,包含逻辑删除记录。 * diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentLikeMapper.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentLikeMapper.java index eaf6dad..f0787b1 100644 --- a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentLikeMapper.java +++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/dal/mapper/ContentLikeMapper.java @@ -20,4 +20,13 @@ public interface ContentLikeMapper extends BaseMapper { + "AND target_id = #{targetId}") int physicalDelete(@Param("userId") Long userId, @Param("targetType") String targetType, @Param("targetId") Long targetId); + + @Delete("DELETE FROM content_like WHERE user_id IS NULL AND device_id = #{deviceId} " + + "AND target_type = #{targetType} AND target_id = #{targetId}") + int physicalDeleteDevice(@Param("deviceId") String deviceId, + @Param("targetType") String targetType, + @Param("targetId") Long targetId); + + @Delete("DELETE FROM content_like WHERE id = #{id}") + int physicalDeleteById(@Param("id") Long id); } diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/CommentService.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/CommentService.java index 7a247d0..4af1666 100644 --- a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/CommentService.java +++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/CommentService.java @@ -9,6 +9,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.fruitshorts.common.exception.ErrorCode; import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.common.interaction.InteractionIdentity; import com.fruitshorts.common.pojo.CursorResult; import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.content.dal.entity.Drama; @@ -78,6 +79,11 @@ public class CommentService { @Transactional(rollbackFor = Exception.class) public CommentRespVO publish(Long userId, CommentPublishReqVO req, String ip, String deviceId) { + return publish(new InteractionIdentity(userId, deviceId), req, ip); + } + + @Transactional(rollbackFor = Exception.class) + public CommentRespVO publish(InteractionIdentity identity, CommentPublishReqVO req, String ip) { dramaService.getPublished(req.getDramaId()); if (req.getEpisodeId() != null) { episodeService.getPublished(req.getEpisodeId()); @@ -90,7 +96,8 @@ public class CommentService { throw new ServiceException(ErrorCode.BAD_REQUEST, "评论最多 " + properties.getCommentMaxLength() + " 字"); } - checkRate(userId); + checkRate(identity); + checkDuplicate(identity, req, text); SensitiveWordCheck check = sensitiveWordService.check(text); if (check.blocked()) { @@ -110,7 +117,7 @@ public class CommentService { ContentComment comment = new ContentComment(); comment.setDramaId(req.getDramaId()); comment.setEpisodeId(req.getEpisodeId() == null ? 0L : req.getEpisodeId()); - comment.setUserId(userId); + comment.setUserId(identity.userId()); comment.setParentId(parent == null ? 0L : parent.getId()); comment.setRootId(resolveRootId(parent)); comment.setContent(check.sanitized()); @@ -121,7 +128,7 @@ public class CommentService { comment.setReplyCount(0); comment.setReportCount(0); comment.setIp(ip == null ? "" : ip); - comment.setDeviceId(deviceId == null ? "" : deviceId); + comment.setDeviceId(identity.authenticated() ? "" : identity.deviceId()); comment.setCountryCode(""); commentMapper.insert(comment); @@ -134,13 +141,22 @@ public class CommentService { .eq(ContentComment::getId, parent.getId()) .setSql("reply_count = reply_count + 1")); } - return toResp(comment, appUserMapper.selectById(userId), false, userId); + return toResp( + comment, + identity.authenticated() ? appUserMapper.selectById(identity.userId()) : null, + false, + identity); } /** * 游标翻页。unsetRootId 表示只看顶层评论;传 rootId 则看该楼层的回复。 */ public CursorResult page(Long meUserId, CommentQueryReqVO req) { + return page(meUserId, "", req); + } + + public CursorResult page( + Long meUserId, String meDeviceId, CommentQueryReqVO req) { if (req.getDramaId() == null) { throw new ServiceException(ErrorCode.BAD_REQUEST, "缺少 dramaId"); } @@ -151,8 +167,13 @@ public class CommentService { .eq(req.getEpisodeId() != null, ContentComment::getEpisodeId, req.getEpisodeId()) .eq(ContentComment::getRootId, rootFilter) .lt(req.getCursor() != null, ContentComment::getId, req.getCursor()); - if (meUserId == null) { + if (meUserId == null && (meDeviceId == null || meDeviceId.isBlank())) { wrapper.eq(ContentComment::getStatus, STATUS_PUBLISHED); + } else if (meUserId == null) { + wrapper.and(w -> w.eq(ContentComment::getStatus, STATUS_PUBLISHED) + .or(x -> x.eq(ContentComment::getStatus, STATUS_PENDING) + .isNull(ContentComment::getUserId) + .eq(ContentComment::getDeviceId, meDeviceId))); } else { // 已发布 + 自己待审核的 wrapper.and(w -> w.eq(ContentComment::getStatus, STATUS_PUBLISHED) @@ -166,13 +187,25 @@ public class CommentService { rows = rows.subList(0, size); } Long nextCursor = rows.isEmpty() ? null : rows.get(rows.size() - 1).getId(); - return new CursorResult<>(toRespList(rows, meUserId), hasMore ? nextCursor : null, hasMore); + return new CursorResult<>( + toRespList(rows, new InteractionIdentity(meUserId, meDeviceId)), + hasMore ? nextCursor : null, + hasMore); } @Transactional(rollbackFor = Exception.class) public void delete(Long userId, Long commentId) { + delete(new InteractionIdentity(userId, ""), commentId); + } + + @Transactional(rollbackFor = Exception.class) + public void delete(InteractionIdentity identity, Long commentId) { ContentComment comment = getExisting(commentId); - if (!comment.getUserId().equals(userId)) { + boolean mine = identity.authenticated() + ? identity.userId().equals(comment.getUserId()) + : comment.getUserId() == null + && identity.deviceId().equals(comment.getDeviceId()); + if (!mine) { throw new ServiceException(ErrorCode.FORBIDDEN, "只能删除自己的评论"); } softDelete(comment); @@ -278,7 +311,7 @@ public class CommentService { req.getKeyword()) .orderByDesc(ContentComment::getId)); List rows = result.getRecords(); - List list = toRespList(rows, null); + List list = toRespList(rows, new InteractionIdentity(null, "")); fillAdminContentInfo(rows, list); return new PageResult<>(result.getTotal(), list); } @@ -349,15 +382,87 @@ public class CommentService { commentMapper.updateById(comment); } - private void checkRate(Long userId) { - Long recent = commentMapper.selectCount(Wrappers.lambdaQuery() - .eq(ContentComment::getUserId, userId) - .ge(ContentComment::getCreatedAt, LocalDateTime.now().minusMinutes(1))); + private void checkRate(InteractionIdentity identity) { + var query = Wrappers.lambdaQuery() + .ge(ContentComment::getCreatedAt, LocalDateTime.now().minusMinutes(1)); + if (identity.authenticated()) { + query.eq(ContentComment::getUserId, identity.userId()); + } else { + query.isNull(ContentComment::getUserId) + .eq(ContentComment::getDeviceId, identity.deviceId()); + } + Long recent = commentMapper.selectCount(query); if (recent != null && recent >= properties.getCommentPerMinute()) { throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "发言过于频繁,请稍后再试"); } } + private void checkDuplicate( + InteractionIdentity identity, CommentPublishReqVO req, String text) { + var query = Wrappers.lambdaQuery() + .eq(ContentComment::getDramaId, req.getDramaId()) + .eq(ContentComment::getEpisodeId, + req.getEpisodeId() == null ? 0L : req.getEpisodeId()) + .eq(ContentComment::getParentId, + req.getParentId() == null ? 0L : req.getParentId()) + .eq(ContentComment::getContent, text) + .ne(ContentComment::getStatus, STATUS_DELETED) + .ge(ContentComment::getCreatedAt, LocalDateTime.now().minusMinutes(1)); + if (identity.authenticated()) { + query.eq(ContentComment::getUserId, identity.userId()); + } else { + query.isNull(ContentComment::getUserId) + .eq(ContentComment::getDeviceId, identity.deviceId()); + } + Long count = commentMapper.selectCount(query); + if (count != null && count > 0) { + throw new ServiceException(ErrorCode.DATA_EXISTS, "请勿重复发送相同评论"); + } + } + + /** 登录时把设备评论归并到账号;重复内容删除设备侧记录。 */ + @Transactional(rollbackFor = Exception.class) + public MergeResult mergeDeviceIntoUser(Long userId, String deviceId) { + if (userId == null || !StringUtils.hasText(deviceId)) { + return new MergeResult(0, 0); + } + List deviceComments = commentMapper.selectList( + Wrappers.lambdaQuery() + .isNull(ContentComment::getUserId) + .eq(ContentComment::getDeviceId, deviceId)); + int migrated = 0; + int duplicates = 0; + for (ContentComment deviceComment : deviceComments) { + ContentComment existing = commentMapper.selectOne( + Wrappers.lambdaQuery() + .eq(ContentComment::getUserId, userId) + .eq(ContentComment::getDramaId, deviceComment.getDramaId()) + .eq(ContentComment::getEpisodeId, deviceComment.getEpisodeId()) + .eq(ContentComment::getParentId, deviceComment.getParentId()) + .eq(ContentComment::getContent, deviceComment.getContent()) + .ne(ContentComment::getStatus, STATUS_DELETED) + .last("limit 1")); + if (existing != null) { + if (commentMapper.physicalDeleteById(deviceComment.getId()) > 0 + && deviceComment.getParentId() != null + && deviceComment.getParentId() > 0) { + commentMapper.update(null, Wrappers.lambdaUpdate() + .eq(ContentComment::getId, deviceComment.getParentId()) + .setSql("reply_count = GREATEST(reply_count - 1, 0)")); + } + duplicates++; + continue; + } + deviceComment.setUserId(userId); + commentMapper.updateById(deviceComment); + migrated++; + } + return new MergeResult(migrated, duplicates); + } + + public record MergeResult(int migrated, int duplicates) { + } + private Long resolveRootId(ContentComment parent) { if (parent == null) { return 0L; @@ -371,27 +476,45 @@ public class CommentService { return Math.min(value, properties.getPageMaxSize()); } - private List toRespList(List rows, Long meUserId) { + private List toRespList( + List rows, InteractionIdentity identity) { if (rows.isEmpty()) { return List.of(); } Map users = new HashMap<>(); - List userIds = rows.stream().map(ContentComment::getUserId).distinct().toList(); - for (AppUser user : appUserMapper.selectBatchIds(userIds)) { - users.put(user.getId(), user); + List userIds = rows.stream() + .map(ContentComment::getUserId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (!userIds.isEmpty()) { + for (AppUser user : appUserMapper.selectBatchIds(userIds)) { + users.put(user.getId(), user); + } } Set likedIds = new HashSet<>(); - if (meUserId != null) { - for (ContentLike like : likeMapper.selectList(Wrappers.lambdaQuery() - .eq(ContentLike::getUserId, meUserId) + if (identity.userId() != null || StringUtils.hasText(identity.deviceId())) { + var query = Wrappers.lambdaQuery() .eq(ContentLike::getTargetType, "COMMENT") - .in(ContentLike::getTargetId, rows.stream().map(ContentComment::getId).toList()))) { + .in(ContentLike::getTargetId, + rows.stream().map(ContentComment::getId).toList()); + if (identity.authenticated()) { + query.eq(ContentLike::getUserId, identity.userId()); + } else { + query.isNull(ContentLike::getUserId) + .eq(ContentLike::getDeviceId, identity.deviceId()); + } + for (ContentLike like : likeMapper.selectList(query)) { likedIds.add(like.getTargetId()); } } List list = new ArrayList<>(rows.size()); for (ContentComment row : rows) { - list.add(toResp(row, users.get(row.getUserId()), likedIds.contains(row.getId()), meUserId)); + list.add(toResp( + row, + users.get(row.getUserId()), + likedIds.contains(row.getId()), + identity)); } return list; } @@ -429,7 +552,11 @@ public class CommentService { } } - private CommentRespVO toResp(ContentComment comment, AppUser author, boolean liked, Long meUserId) { + private CommentRespVO toResp( + ContentComment comment, + AppUser author, + boolean liked, + InteractionIdentity identity) { CommentRespVO vo = new CommentRespVO(); vo.setId(comment.getId()); vo.setDramaId(comment.getDramaId()); @@ -444,12 +571,20 @@ public class CommentService { vo.setReplyCount(comment.getReplyCount()); vo.setReportCount(comment.getReportCount()); vo.setCreatedAt(comment.getCreatedAt()); - vo.setMine(meUserId != null && meUserId.equals(comment.getUserId())); + boolean mine = identity.authenticated() + ? identity.userId().equals(comment.getUserId()) + : comment.getUserId() == null + && identity.deviceId().equals(comment.getDeviceId()); + vo.setMine(mine); vo.setLiked(liked); if (author != null) { vo.setUid(author.getUid()); vo.setNickname(author.getNickname()); vo.setAvatarUrl(author.getAvatarUrl()); + } else { + vo.setUid(""); + vo.setNickname("游客"); + vo.setAvatarUrl(""); } return vo; } diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/LikeService.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/LikeService.java index 7f19c8b..dce0863 100644 --- a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/LikeService.java +++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/LikeService.java @@ -5,6 +5,7 @@ package com.fruitshorts.interaction.service; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.fruitshorts.common.exception.ErrorCode; import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.common.interaction.InteractionIdentity; import com.fruitshorts.content.dal.entity.DramaEpisode; import com.fruitshorts.content.service.DramaService; import com.fruitshorts.content.service.EpisodeService; @@ -20,6 +21,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.Collection; import java.util.HashSet; +import java.util.List; import java.util.Locale; import java.util.Set; @@ -44,6 +46,11 @@ public class LikeService { @Transactional(rollbackFor = Exception.class) public LikeToggleRespVO toggle(Long userId, LikeToggleReqVO req) { + return toggle(new InteractionIdentity(userId, ""), req); + } + + @Transactional(rollbackFor = Exception.class) + public LikeToggleRespVO toggle(InteractionIdentity identity, LikeToggleReqVO req) { String type = req.getTargetType().trim().toUpperCase(Locale.ROOT); if (!TYPE_DRAMA.equals(type) && !TYPE_EPISODE.equals(type) && !TYPE_COMMENT.equals(type)) { throw new ServiceException(ErrorCode.BAD_REQUEST, "只支持对短剧、剧集或评论点赞"); @@ -51,22 +58,26 @@ public class LikeService { Long targetId = req.getTargetId(); validateTarget(type, targetId); - ContentLike existing = likeMapper.selectOne(Wrappers.lambdaQuery() - .eq(ContentLike::getUserId, userId) + ContentLike existing = likeMapper.selectOne(identityQuery(identity) .eq(ContentLike::getTargetType, type) .eq(ContentLike::getTargetId, targetId) .last("limit 1")); boolean liked; if (existing == null) { ContentLike like = new ContentLike(); - like.setUserId(userId); + like.setUserId(identity.userId()); + like.setDeviceId(identity.authenticated() ? "" : identity.deviceId()); like.setTargetType(type); like.setTargetId(targetId); likeMapper.insert(like); liked = true; adjustCount(type, targetId, 1); } else { - likeMapper.physicalDelete(userId, type, targetId); + if (identity.authenticated()) { + likeMapper.physicalDelete(identity.userId(), type, targetId); + } else { + likeMapper.physicalDeleteDevice(identity.deviceId(), type, targetId); + } liked = false; adjustCount(type, targetId, -1); } @@ -91,7 +102,13 @@ public class LikeService { /** 批量判断用户是否已点赞,一次查询覆盖多个目标。 */ public Set likedTargetIds(Long userId, String type, Collection targetIds) { - if (userId == null || targetIds == null || targetIds.isEmpty()) { + return likedTargetIds(userId, "", type, targetIds); + } + + /** 登录用户优先;匿名状态按设备号查询。 */ + public Set likedTargetIds(Long userId, String deviceId, String type, + Collection targetIds) { + if (targetIds == null || targetIds.isEmpty()) { return Set.of(); } Set normalized = new HashSet<>(); @@ -103,8 +120,10 @@ public class LikeService { if (normalized.isEmpty()) { return Set.of(); } - return likeMapper.selectList(Wrappers.lambdaQuery() - .eq(ContentLike::getUserId, userId) + if (userId == null && (deviceId == null || deviceId.isBlank())) { + return Set.of(); + } + return likeMapper.selectList(identityQuery(new InteractionIdentity(userId, deviceId)) .eq(ContentLike::getTargetType, type) .in(ContentLike::getTargetId, normalized)) .stream() @@ -112,6 +131,52 @@ public class LikeService { .collect(java.util.stream.Collectors.toSet()); } + /** 登录时把设备点赞归并到账号;重复关系删除并回退目标计数。 */ + @Transactional(rollbackFor = Exception.class) + public MergeResult mergeDeviceIntoUser(Long userId, String deviceId) { + if (userId == null || deviceId == null || deviceId.isBlank()) { + return new MergeResult(0, 0); + } + List deviceLikes = likeMapper.selectList( + Wrappers.lambdaQuery() + .isNull(ContentLike::getUserId) + .eq(ContentLike::getDeviceId, deviceId)); + int migrated = 0; + int duplicates = 0; + for (ContentLike deviceLike : deviceLikes) { + ContentLike existing = likeMapper.selectOne(Wrappers.lambdaQuery() + .eq(ContentLike::getUserId, userId) + .eq(ContentLike::getTargetType, deviceLike.getTargetType()) + .eq(ContentLike::getTargetId, deviceLike.getTargetId()) + .last("limit 1")); + if (existing != null) { + if (likeMapper.physicalDeleteById(deviceLike.getId()) > 0) { + adjustCount(deviceLike.getTargetType(), deviceLike.getTargetId(), -1); + } + duplicates++; + continue; + } + deviceLike.setUserId(userId); + likeMapper.updateById(deviceLike); + migrated++; + } + return new MergeResult(migrated, duplicates); + } + + private com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper + identityQuery(InteractionIdentity identity) { + if (identity.authenticated()) { + return Wrappers.lambdaQuery() + .eq(ContentLike::getUserId, identity.userId()); + } + return Wrappers.lambdaQuery() + .isNull(ContentLike::getUserId) + .eq(ContentLike::getDeviceId, identity.deviceId()); + } + + public record MergeResult(int migrated, int duplicates) { + } + private void adjustCount(String type, Long targetId, int delta) { if (TYPE_DRAMA.equals(type)) { dramaService.adjustLikeCount(targetId, delta); diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentPublishReqVO.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentPublishReqVO.java index 4d4b0a7..b144c20 100644 --- a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentPublishReqVO.java +++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/CommentPublishReqVO.java @@ -20,4 +20,7 @@ public class CommentPublishReqVO { /** 回复某条评论时传它的 id;顶层评论不传 */ private Long parentId; + + /** 兼容旧调用;正常客户端通过 X-Device-Id 下发。 */ + private String deviceId; } diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/LikeToggleReqVO.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/LikeToggleReqVO.java index 67e4db3..6bc834c 100644 --- a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/LikeToggleReqVO.java +++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/LikeToggleReqVO.java @@ -16,4 +16,7 @@ public class LikeToggleReqVO { @NotNull(message = "缺少 targetId") private Long targetId; + + /** 兼容旧调用;正常客户端通过 X-Device-Id 下发。 */ + private String deviceId; } diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/UserFavorite.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/UserFavorite.java index 6bc37a4..e4568be 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/UserFavorite.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/UserFavorite.java @@ -20,6 +20,7 @@ public class UserFavorite { @TableId(type = IdType.ASSIGN_ID) private Long id; private Long userId; + private String deviceId; private Long dramaId; private Integer sort; private LocalDateTime createdAt; diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/UserFavoriteMapper.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/UserFavoriteMapper.java index 89cc9da..c5e68d7 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/UserFavoriteMapper.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/UserFavoriteMapper.java @@ -17,4 +17,11 @@ public interface UserFavoriteMapper extends BaseMapper { */ @Delete("DELETE FROM user_favorite WHERE user_id = #{userId} AND drama_id = #{dramaId}") int physicalDelete(@Param("userId") Long userId, @Param("dramaId") Long dramaId); + + @Delete("DELETE FROM user_favorite WHERE user_id IS NULL AND device_id = #{deviceId} " + + "AND drama_id = #{dramaId}") + int physicalDeleteDevice(@Param("deviceId") String deviceId, @Param("dramaId") Long dramaId); + + @Delete("DELETE FROM user_favorite WHERE id = #{id}") + int physicalDeleteById(@Param("id") Long id); } diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java index 9fde4d2..7a23d1b 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; 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.enums.ContentStatus; import com.fruitshorts.content.service.DramaService; @@ -37,21 +38,28 @@ public class FavoriteService { /** 返回切换后的状态:true 表示已收藏。 */ @Transactional(rollbackFor = Exception.class) public boolean toggle(Long userId, Long dramaId) { + return toggle(new InteractionIdentity(userId, ""), dramaId); + } + + @Transactional(rollbackFor = Exception.class) + public boolean toggle(InteractionIdentity identity, Long dramaId) { dramaService.getPublished(dramaId); - UserFavorite existing = favoriteMapper.selectOne(Wrappers.lambdaQuery() - .eq(UserFavorite::getUserId, userId) + UserFavorite existing = favoriteMapper.selectOne(identityQuery(identity) .eq(UserFavorite::getDramaId, dramaId) .last("limit 1")); if (existing != null) { // 必须物理删除:唯一索引 (user_id, drama_id) 会挡住"取消后再收藏" - int removed = favoriteMapper.physicalDelete(userId, dramaId); + int removed = identity.authenticated() + ? favoriteMapper.physicalDelete(identity.userId(), dramaId) + : favoriteMapper.physicalDeleteDevice(identity.deviceId(), dramaId); if (removed > 0) { dramaService.adjustFavoriteCount(dramaId, -1); } return false; } UserFavorite favorite = new UserFavorite(); - favorite.setUserId(userId); + favorite.setUserId(identity.userId()); + favorite.setDeviceId(identity.authenticated() ? "" : identity.deviceId()); favorite.setDramaId(dramaId); favorite.setSort(0); favoriteMapper.insert(favorite); @@ -68,7 +76,13 @@ public class FavoriteService { /** 批量判断用户是否已收藏,一次查询覆盖多部剧。 */ public Set favoriteDramaIds(Long userId, Collection dramaIds) { - if (userId == null || dramaIds == null || dramaIds.isEmpty()) { + return favoriteDramaIds(userId, "", dramaIds); + } + + /** 登录用户优先;匿名状态按设备号查询。 */ + public Set favoriteDramaIds(Long userId, String deviceId, + Collection dramaIds) { + if (dramaIds == null || dramaIds.isEmpty()) { return Set.of(); } Set normalized = new HashSet<>(); @@ -80,14 +94,62 @@ public class FavoriteService { if (normalized.isEmpty()) { return Set.of(); } - return favoriteMapper.selectList(Wrappers.lambdaQuery() - .eq(UserFavorite::getUserId, userId) + if (userId == null && (deviceId == null || deviceId.isBlank())) { + return Set.of(); + } + return favoriteMapper.selectList(identityQuery(new InteractionIdentity(userId, deviceId)) .in(UserFavorite::getDramaId, normalized)) .stream() .map(UserFavorite::getDramaId) .collect(java.util.stream.Collectors.toSet()); } + /** 登录时把设备收藏归并到账号;重复收藏删除并回退剧级计数。 */ + @Transactional(rollbackFor = Exception.class) + public MergeResult mergeDeviceIntoUser(Long userId, String deviceId) { + if (userId == null || deviceId == null || deviceId.isBlank()) { + return new MergeResult(0, 0); + } + List deviceFavorites = favoriteMapper.selectList( + Wrappers.lambdaQuery() + .isNull(UserFavorite::getUserId) + .eq(UserFavorite::getDeviceId, deviceId)); + int migrated = 0; + int duplicates = 0; + for (UserFavorite deviceFavorite : deviceFavorites) { + UserFavorite existing = favoriteMapper.selectOne( + Wrappers.lambdaQuery() + .eq(UserFavorite::getUserId, userId) + .eq(UserFavorite::getDramaId, deviceFavorite.getDramaId()) + .last("limit 1")); + if (existing != null) { + if (favoriteMapper.physicalDeleteById(deviceFavorite.getId()) > 0) { + dramaService.adjustFavoriteCount(deviceFavorite.getDramaId(), -1); + } + duplicates++; + continue; + } + deviceFavorite.setUserId(userId); + favoriteMapper.updateById(deviceFavorite); + migrated++; + } + return new MergeResult(migrated, duplicates); + } + + private com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper + identityQuery(InteractionIdentity identity) { + if (identity.authenticated()) { + return Wrappers.lambdaQuery() + .eq(UserFavorite::getUserId, identity.userId()); + } + return Wrappers.lambdaQuery() + .isNull(UserFavorite::getUserId) + .eq(UserFavorite::getDeviceId, identity.deviceId()); + } + + public record MergeResult(int migrated, int duplicates) { + } + /** 分页查询收藏短剧,保持收藏时间与 dramaId 稳定排序。 */ public PageResult page(Long userId, int pageIndex, int pageSize) { int safeIndex = Math.max(pageIndex, 1); @@ -130,6 +192,14 @@ public class FavoriteService { } } + @Transactional(rollbackFor = Exception.class) + public void removeDevice(String deviceId, Long dramaId) { + int removed = favoriteMapper.physicalDeleteDevice(deviceId, dramaId); + if (removed > 0) { + dramaService.adjustFavoriteCount(dramaId, -1); + } + } + @Transactional(rollbackFor = Exception.class) public void removeBatch(Long userId, List dramaIds) { if (dramaIds == null || dramaIds.isEmpty()) { diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/PlaybackService.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/PlaybackService.java index 8dff5f3..6374f15 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/PlaybackService.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/PlaybackService.java @@ -2,6 +2,8 @@ package com.fruitshorts.playback.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; @@ -57,6 +59,10 @@ public class PlaybackService { boolean free = episode.getIsFree() != null && episode.getIsFree() == 1; boolean entitled = userId != null && entitlementPort.isUnlocked(userId, drama.getId(), episode.getId()); + if (!free && !entitled && userId == null) { + // 锁定集正片必须登录;即使暂时没有片源,也不能把游客引到匿名取票分支。 + throw new ServiceException(ErrorCode.UNAUTHORIZED, "登录后才能解锁并观看该集"); + } String source = playableSource(episode); PlayRespVO vo = new PlayRespVO(); diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/FavoriteToggleReqVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/FavoriteToggleReqVO.java index 8eb3bd7..305b13b 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/FavoriteToggleReqVO.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/FavoriteToggleReqVO.java @@ -11,4 +11,7 @@ public class FavoriteToggleReqVO { @NotNull(message = "缺少 dramaId") private Long dramaId; + + /** 兼容旧调用;正常客户端通过 X-Device-Id 下发。 */ + private String deviceId; } diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/event/AppInteractionMergeRequested.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/event/AppInteractionMergeRequested.java new file mode 100644 index 0000000..6a5b3ca --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/event/AppInteractionMergeRequested.java @@ -0,0 +1,7 @@ +// Author: fruit.com.cn + +package com.fruitshorts.user.event; + +/** 登录/注册成功后,在同一事务内触发设备互动合并。 */ +public record AppInteractionMergeRequested(Long userId, String deviceId) { +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java index ac845dd..aab0c1a 100644 --- a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java @@ -25,8 +25,10 @@ import com.fruitshorts.user.vo.LoginReqVO; import com.fruitshorts.user.vo.LogoutReqVO; import com.fruitshorts.user.vo.RegisterReqVO; import com.fruitshorts.user.vo.TokenRespVO; +import com.fruitshorts.user.event.AppInteractionMergeRequested; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DuplicateKeyException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -63,6 +65,7 @@ public class AppAuthService { private final PasswordEncoder passwordEncoder; private final ConfigService configService; private final GoogleIdTokenVerifier googleVerifier; + private final ApplicationEventPublisher eventPublisher; /** 后台 sys_config 缺失或非法时使用;小于等于 0 表示不限制。 */ @Value("${fruitshorts.auth.register-max-per-ip-per-day:5}") @@ -130,6 +133,7 @@ public class AppAuthService { TokenRespVO resp = issueTokens(user, credential, req.getDeviceId(), req.getDeviceModel(), req.getOsVersion(), req.getAppVersion(), req.getPlatform(), clientIp); resp.setNewUser(true); + requestInteractionMerge(user.getId(), req.getDeviceId()); return resp; } @@ -177,8 +181,10 @@ public class AppAuthService { appUserMapper.updateById(user); touchDevice(user.getId(), req.getDeviceId(), req.getPlatform(), req.getDeviceModel()); - return issueTokens(user, credential, req.getDeviceId(), req.getDeviceModel(), req.getOsVersion(), - req.getAppVersion(), req.getPlatform(), clientIp); + TokenRespVO resp = issueTokens(user, credential, req.getDeviceId(), req.getDeviceModel(), + req.getOsVersion(), req.getAppVersion(), req.getPlatform(), clientIp); + requestInteractionMerge(user.getId(), req.getDeviceId()); + return resp; } /** @@ -188,7 +194,7 @@ public class AppAuthService { * 再落一条 GOOGLE 凭证用于下次直接登录;已有邮箱账号时只补 GOOGLE 凭证,不重复建号。 */ @Transactional(rollbackFor = Exception.class) - public TokenRespVO googleLogin(String idToken) { + public TokenRespVO googleLogin(String idToken, String deviceId) { GoogleUser googleUser = googleVerifier.verify(idToken); String email = requireEmail(googleUser); AppUserCredential googleCredential = findCredential(CredentialType.GOOGLE.name(), email); @@ -215,8 +221,10 @@ public class AppAuthService { user.setLastLoginAt(LocalDateTime.now()); user.setLastLoginIp(""); appUserMapper.updateById(user); - TokenRespVO resp = issueTokens(user, googleCredential, null, null, null, null, null, null); + touchDevice(user.getId(), deviceId, null, null); + TokenRespVO resp = issueTokens(user, googleCredential, deviceId, null, null, null, null, null); resp.setNewUser(newUser); + requestInteractionMerge(user.getId(), deviceId); return resp; } @@ -436,6 +444,13 @@ public class AppAuthService { return resp; } + private void requestInteractionMerge(Long userId, String deviceId) { + if (StringUtils.hasText(deviceId)) { + eventPublisher.publishEvent( + new AppInteractionMergeRequested(userId, deviceId.trim())); + } + } + private static final String REGISTER_IP_LIMIT_KEY = "app.register.max-per-ip-per-day"; private static final String REGISTER_DEVICE_LIMIT_KEY = "app.register.max-per-device-per-day"; diff --git a/sql/migrate-k105-anonymous-interactions.sql b/sql/migrate-k105-anonymous-interactions.sql new file mode 100644 index 0000000..2f90c2a --- /dev/null +++ b/sql/migrate-k105-anonymous-interactions.sql @@ -0,0 +1,57 @@ +-- 非付费互动支持 deviceId 归属;登录时由应用事务合并到账号。 + +ALTER TABLE content_like + ADD COLUMN IF NOT EXISTS device_id VARCHAR(128) NOT NULL DEFAULT ''; +ALTER TABLE content_like + ALTER COLUMN user_id DROP NOT NULL; +DROP INDEX IF EXISTS uk_content_like; +CREATE UNIQUE INDEX IF NOT EXISTS uk_content_like_user + ON content_like (user_id, target_type, target_id) + WHERE user_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uk_content_like_device + ON content_like (device_id, target_type, target_id) + WHERE user_id IS NULL AND device_id <> ''; +CREATE INDEX IF NOT EXISTS idx_content_like_device + ON content_like (device_id, created_at DESC) + WHERE device_id <> ''; + +ALTER TABLE user_favorite + ADD COLUMN IF NOT EXISTS device_id VARCHAR(128) NOT NULL DEFAULT ''; +ALTER TABLE user_favorite + ALTER COLUMN user_id DROP NOT NULL; +DROP INDEX IF EXISTS uk_user_favorite; +CREATE UNIQUE INDEX IF NOT EXISTS uk_user_favorite_user + ON user_favorite (user_id, drama_id) + WHERE user_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uk_user_favorite_device + ON user_favorite (device_id, drama_id) + WHERE user_id IS NULL AND device_id <> ''; +CREATE INDEX IF NOT EXISTS idx_user_favorite_device + ON user_favorite (device_id, created_at DESC) + WHERE device_id <> ''; + +ALTER TABLE content_comment + ADD COLUMN IF NOT EXISTS device_id VARCHAR(128) NOT NULL DEFAULT ''; +ALTER TABLE content_comment + ALTER COLUMN user_id DROP NOT NULL; +CREATE INDEX IF NOT EXISTS idx_comment_device + ON content_comment (device_id, created_at DESC) + WHERE user_id IS NULL AND device_id <> ''; +CREATE UNIQUE INDEX IF NOT EXISTS uk_content_comment_user_content + ON content_comment ( + user_id, + drama_id, + episode_id, + parent_id, + md5(content) + ) + WHERE user_id IS NOT NULL AND deleted = 0 AND status <> 'deleted'; +CREATE UNIQUE INDEX IF NOT EXISTS uk_content_comment_device_content + ON content_comment ( + device_id, + drama_id, + episode_id, + parent_id, + md5(content) + ) + WHERE user_id IS NULL AND device_id <> '' AND deleted = 0 AND status <> 'deleted';