feat(interaction): 支持游客互动与登录合并
This commit is contained in:
1 parent
2fe24b9db7
commit
4b0fbd73d3
41 files changed
+1207
-90
No files matched your search
+21
-2
@@ -24,6 +24,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/** 客户端账号:验证码、注册、登录、刷新、登出。 */
|
||||
@Tag(name = "客户端-账号")
|
||||
@@ -48,6 +49,7 @@ public class AppAuthController {
|
||||
@PostMapping("/register")
|
||||
public Result<TokenRespVO> register(@Valid @RequestBody RegisterReqVO req,
|
||||
HttpServletRequest request) {
|
||||
applyHeaderDeviceId(req, request);
|
||||
return Result.ok(authService.register(req, WebUtils.getClientIp(request)));
|
||||
}
|
||||
|
||||
@@ -56,13 +58,18 @@ public class AppAuthController {
|
||||
@PostMapping("/login")
|
||||
public Result<TokenRespVO> login(@Valid @RequestBody LoginReqVO req,
|
||||
HttpServletRequest request) {
|
||||
applyHeaderDeviceId(req, request);
|
||||
return Result.ok(authService.login(req, WebUtils.getClientIp(request)));
|
||||
}
|
||||
|
||||
@Operation(summary = "Google 登录(未配置 clientId 时返回明确错误)")
|
||||
@PostMapping("/google")
|
||||
public Result<TokenRespVO> google(@Valid @RequestBody GoogleLoginReqVO req) {
|
||||
return Result.ok(authService.googleLogin(req.getIdToken()));
|
||||
public Result<TokenRespVO> google(@Valid @RequestBody GoogleLoginReqVO req,
|
||||
HttpServletRequest request) {
|
||||
String headerDeviceId = request.getHeader("X-Device-Id");
|
||||
String deviceId = StringUtils.hasText(headerDeviceId)
|
||||
? headerDeviceId : req.getDeviceId();
|
||||
return Result.ok(authService.googleLogin(req.getIdToken(), deviceId));
|
||||
}
|
||||
|
||||
@Operation(summary = "刷新令牌(旧 refresh token 立即失效)")
|
||||
@@ -78,4 +85,16 @@ public class AppAuthController {
|
||||
authService.logout(SecurityUtils.getUserIdOrNull(), req == null ? new LogoutReqVO() : req);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
private void applyHeaderDeviceId(RegisterReqVO req, HttpServletRequest request) {
|
||||
if (!StringUtils.hasText(req.getDeviceId())) {
|
||||
req.setDeviceId(request.getHeader("X-Device-Id"));
|
||||
}
|
||||
}
|
||||
|
||||
private void applyHeaderDeviceId(LoginReqVO req, HttpServletRequest request) {
|
||||
if (!StringUtils.hasText(req.getDeviceId())) {
|
||||
req.setDeviceId(request.getHeader("X-Device-Id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-7
@@ -6,6 +6,9 @@ import com.fruitshorts.common.pojo.CursorResult;
|
||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||
import com.fruitshorts.content.service.EpisodeService;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.common.interaction.InteractionIdentity;
|
||||
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
|
||||
import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.framework.web.WebUtils;
|
||||
import com.fruitshorts.interaction.service.CommentService;
|
||||
@@ -33,7 +36,7 @@ import java.util.List;
|
||||
*
|
||||
* <p>列表走游标翻页:`cursor` 传上一页最后一条的 id,返回 `nextCursor` 与 `hasMore`。
|
||||
* 不传 `rootId` 时返回顶层评论,传了则返回该楼层的回复。
|
||||
* 列表允许匿名浏览;发布与删除需要登录。
|
||||
* 列表允许匿名浏览;发布与删除支持登录用户或设备身份。
|
||||
*/
|
||||
@Tag(name = "客户端-评论")
|
||||
@RestController
|
||||
@@ -43,13 +46,20 @@ public class AppCommentController {
|
||||
|
||||
private final CommentService commentService;
|
||||
private final EpisodeService episodeService;
|
||||
private final InteractionIdentityResolver identityResolver;
|
||||
private final AnonymousInteractionRateLimiter anonymousRateLimiter;
|
||||
|
||||
@Operation(summary = "发布评论")
|
||||
@PostMapping
|
||||
public Result<CommentRespVO> publish(@Valid @RequestBody CommentPublishReqVO req,
|
||||
HttpServletRequest request) {
|
||||
return Result.ok(commentService.publish(SecurityUtils.getAppUserId(), req,
|
||||
WebUtils.getClientIp(request), request.getHeader("X-Device-Id")));
|
||||
InteractionIdentity identity = identityResolver.resolve(
|
||||
SecurityUtils.getUserIdOrNull(),
|
||||
request.getHeader("X-Device-Id"),
|
||||
req.getDeviceId());
|
||||
anonymousRateLimiter.check(identity, "comment");
|
||||
return Result.ok(commentService.publish(
|
||||
identity, req, WebUtils.getClientIp(request)));
|
||||
}
|
||||
|
||||
@Operation(summary = "评论列表(游标翻页,可匿名浏览)")
|
||||
@@ -58,14 +68,18 @@ public class AppCommentController {
|
||||
@RequestParam(required = false) Long episodeId,
|
||||
@RequestParam(required = false) Long rootId,
|
||||
@RequestParam(required = false) Long cursor,
|
||||
@RequestParam(required = false) Integer size) {
|
||||
@RequestParam(required = false) Integer size,
|
||||
HttpServletRequest request) {
|
||||
CommentQueryReqVO query = new CommentQueryReqVO();
|
||||
query.setDramaId(dramaId);
|
||||
query.setEpisodeId(episodeId);
|
||||
query.setRootId(rootId);
|
||||
query.setCursor(cursor);
|
||||
query.setSize(size);
|
||||
return Result.ok(commentService.page(SecurityUtils.getUserIdOrNull(), query));
|
||||
return Result.ok(commentService.page(
|
||||
SecurityUtils.getUserIdOrNull(),
|
||||
request.getHeader("X-Device-Id"),
|
||||
query));
|
||||
}
|
||||
|
||||
@Operation(summary = "评论总数:传 episodeId 按集统计,仅 dramaId 时全剧统计(可匿名访问)")
|
||||
@@ -85,8 +99,13 @@ public class AppCommentController {
|
||||
|
||||
@Operation(summary = "删除自己的评论")
|
||||
@DeleteMapping("/{id}")
|
||||
public Result<Void> delete(@PathVariable Long id) {
|
||||
commentService.delete(SecurityUtils.getAppUserId(), id);
|
||||
public Result<Void> delete(@PathVariable Long id, HttpServletRequest request) {
|
||||
InteractionIdentity identity = identityResolver.resolve(
|
||||
SecurityUtils.getUserIdOrNull(),
|
||||
request.getHeader("X-Device-Id"),
|
||||
"");
|
||||
anonymousRateLimiter.check(identity, "comment");
|
||||
commentService.delete(identity, id);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -66,7 +66,11 @@ public class AppDramaController {
|
||||
/** 轻量计数快照:只读互动状态,不签发播放地址、不写票据缓存。 */
|
||||
@Operation(summary = "播放页轻量计数与当前用户互动状态")
|
||||
@GetMapping("/episodes/{id}/stats")
|
||||
public Result<AppEpisodeStatsVO> stats(@PathVariable Long id) {
|
||||
return Result.ok(appEpisodeStatsService.get(id, SecurityUtils.getUserIdOrNull()));
|
||||
public Result<AppEpisodeStatsVO> stats(
|
||||
@PathVariable Long id, HttpServletRequest request) {
|
||||
return Result.ok(appEpisodeStatsService.get(
|
||||
id,
|
||||
SecurityUtils.getUserIdOrNull(),
|
||||
request.getHeader("X-Device-Id")));
|
||||
}
|
||||
}
|
||||
+26
-4
@@ -6,6 +6,9 @@ import com.fruitshorts.app.service.AppCatalogService;
|
||||
import com.fruitshorts.app.vo.AppDramaCardVO;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.common.interaction.InteractionIdentity;
|
||||
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
|
||||
import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
|
||||
import com.fruitshorts.content.dal.entity.Drama;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.playback.service.FavoriteService;
|
||||
@@ -14,6 +17,7 @@ import com.fruitshorts.playback.vo.FavoriteToggleReqVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -33,12 +37,21 @@ public class AppFavoriteController {
|
||||
|
||||
private final FavoriteService favoriteService;
|
||||
private final AppCatalogService appCatalogService;
|
||||
private final InteractionIdentityResolver identityResolver;
|
||||
private final AnonymousInteractionRateLimiter anonymousRateLimiter;
|
||||
|
||||
/** 切换收藏,返回切换后的状态:true 为已收藏。 */
|
||||
@Operation(summary = "切换收藏(返回切换后的状态)")
|
||||
@PostMapping("/toggle")
|
||||
public Result<Boolean> toggle(@Valid @RequestBody FavoriteToggleReqVO req) {
|
||||
return Result.ok(favoriteService.toggle(SecurityUtils.getAppUserId(), req.getDramaId()));
|
||||
public Result<Boolean> toggle(
|
||||
@Valid @RequestBody FavoriteToggleReqVO req,
|
||||
HttpServletRequest request) {
|
||||
InteractionIdentity identity = identityResolver.resolve(
|
||||
SecurityUtils.getUserIdOrNull(),
|
||||
request.getHeader("X-Device-Id"),
|
||||
req.getDeviceId());
|
||||
anonymousRateLimiter.check(identity, "favorite");
|
||||
return Result.ok(favoriteService.toggle(identity, req.getDramaId()));
|
||||
}
|
||||
|
||||
/** 收藏列表,卡片结构与首页一致。 */
|
||||
@@ -55,8 +68,17 @@ public class AppFavoriteController {
|
||||
|
||||
@Operation(summary = "取消收藏")
|
||||
@DeleteMapping("/{dramaId}")
|
||||
public Result<Void> remove(@PathVariable Long dramaId) {
|
||||
favoriteService.remove(SecurityUtils.getAppUserId(), dramaId);
|
||||
public Result<Void> remove(@PathVariable Long dramaId, HttpServletRequest request) {
|
||||
InteractionIdentity identity = identityResolver.resolve(
|
||||
SecurityUtils.getUserIdOrNull(),
|
||||
request.getHeader("X-Device-Id"),
|
||||
"");
|
||||
anonymousRateLimiter.check(identity, "favorite");
|
||||
if (identity.authenticated()) {
|
||||
favoriteService.remove(identity.userId(), dramaId);
|
||||
} else {
|
||||
favoriteService.removeDevice(identity.deviceId(), dramaId);
|
||||
}
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -3,7 +3,10 @@
|
||||
package com.fruitshorts.app.controller;
|
||||
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.common.interaction.InteractionIdentity;
|
||||
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
|
||||
import com.fruitshorts.interaction.service.LikeService;
|
||||
import com.fruitshorts.interaction.service.ReportService;
|
||||
import com.fruitshorts.interaction.vo.LikeToggleReqVO;
|
||||
@@ -12,6 +15,7 @@ import com.fruitshorts.interaction.vo.ReportCreateReqVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
@@ -27,12 +31,21 @@ public class AppInteractionController {
|
||||
|
||||
private final LikeService likeService;
|
||||
private final ReportService reportService;
|
||||
private final InteractionIdentityResolver identityResolver;
|
||||
private final AnonymousInteractionRateLimiter anonymousRateLimiter;
|
||||
|
||||
/** 点赞/取消点赞,返回切换后的状态与最新计数。 */
|
||||
@Operation(summary = "点赞 / 取消点赞")
|
||||
@PostMapping("/likes/toggle")
|
||||
public Result<LikeToggleRespVO> toggleLike(@Valid @RequestBody LikeToggleReqVO req) {
|
||||
return Result.ok(likeService.toggle(SecurityUtils.getAppUserId(), req));
|
||||
public Result<LikeToggleRespVO> toggleLike(
|
||||
@Valid @RequestBody LikeToggleReqVO req,
|
||||
HttpServletRequest request) {
|
||||
InteractionIdentity identity = identityResolver.resolve(
|
||||
SecurityUtils.getUserIdOrNull(),
|
||||
request.getHeader("X-Device-Id"),
|
||||
req.getDeviceId());
|
||||
anonymousRateLimiter.check(identity, "like");
|
||||
return Result.ok(likeService.toggle(identity, req));
|
||||
}
|
||||
|
||||
/** 提交举报:同一用户对同一目标只保留一条待处理工单。 */
|
||||
|
||||
+36
@@ -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, "操作过于频繁,请稍后再试");
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-4
@@ -32,8 +32,8 @@ public class AppEpisodeStatsService {
|
||||
private final LikeService likeService;
|
||||
private final FavoriteService favoriteService;
|
||||
|
||||
public AppEpisodeStatsVO get(Long episodeId, Long userId) {
|
||||
Map<Long, AppEpisodeStatsVO> stats = batch(List.of(episodeId), userId);
|
||||
public AppEpisodeStatsVO get(Long episodeId, Long userId, String deviceId) {
|
||||
Map<Long, AppEpisodeStatsVO> stats = batch(List.of(episodeId), userId, deviceId);
|
||||
AppEpisodeStatsVO result = stats.get(episodeId);
|
||||
if (result == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "剧集不存在或未上线");
|
||||
@@ -41,8 +41,17 @@ public class AppEpisodeStatsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
public AppEpisodeStatsVO get(Long episodeId, Long userId) {
|
||||
return get(episodeId, userId, "");
|
||||
}
|
||||
|
||||
/** 每类数据只查一次,供后续多集统计复用。 */
|
||||
public Map<Long, AppEpisodeStatsVO> batch(Collection<Long> episodeIds, Long userId) {
|
||||
return batch(episodeIds, userId, "");
|
||||
}
|
||||
|
||||
public Map<Long, AppEpisodeStatsVO> batch(
|
||||
Collection<Long> episodeIds, Long userId, String deviceId) {
|
||||
if (episodeIds == null || episodeIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
@@ -58,8 +67,9 @@ public class AppEpisodeStatsService {
|
||||
dramas.put(drama.getId(), drama);
|
||||
}
|
||||
Set<Long> likedIds = likeService.likedTargetIds(
|
||||
userId, LikeService.TYPE_EPISODE, normalizedIds);
|
||||
Set<Long> favoriteDramaIds = favoriteService.favoriteDramaIds(userId, dramaIds);
|
||||
userId, deviceId, LikeService.TYPE_EPISODE, normalizedIds);
|
||||
Set<Long> favoriteDramaIds = favoriteService.favoriteDramaIds(
|
||||
userId, deviceId, dramaIds);
|
||||
|
||||
Map<Long, AppEpisodeStatsVO> result = new HashMap<>();
|
||||
for (DramaEpisode episode : episodes) {
|
||||
|
||||
+24
@@ -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());
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.app.service;
|
||||
|
||||
import com.fruitshorts.interaction.service.CommentService;
|
||||
import com.fruitshorts.interaction.service.LikeService;
|
||||
import com.fruitshorts.playback.service.FavoriteService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** 登录后的设备互动归并;三项迁移共享一个数据库事务。 */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InteractionAccountMergeService {
|
||||
|
||||
private final LikeService likeService;
|
||||
private final FavoriteService favoriteService;
|
||||
private final CommentService commentService;
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void merge(Long userId, String deviceId) {
|
||||
LikeService.MergeResult likes = likeService.mergeDeviceIntoUser(userId, deviceId);
|
||||
FavoriteService.MergeResult favorites =
|
||||
favoriteService.mergeDeviceIntoUser(userId, deviceId);
|
||||
CommentService.MergeResult comments =
|
||||
commentService.mergeDeviceIntoUser(userId, deviceId);
|
||||
log.info(
|
||||
"interaction-merge userId={} deviceId={} "
|
||||
+ "likeMigrated={} likeDuplicates={} "
|
||||
+ "favoriteMigrated={} favoriteDuplicates={} "
|
||||
+ "commentMigrated={} commentDuplicates={}",
|
||||
userId,
|
||||
deviceId,
|
||||
likes.migrated(),
|
||||
likes.duplicates(),
|
||||
favorites.migrated(),
|
||||
favorites.duplicates(),
|
||||
comments.migrated(),
|
||||
comments.duplicates());
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,7 @@ public class GoogleLoginReqVO {
|
||||
|
||||
@NotBlank(message = "缺少 Google idToken")
|
||||
private String idToken;
|
||||
|
||||
/** 旧客户端未统一发送请求头时,仍可用于合并设备互动。 */
|
||||
private String deviceId;
|
||||
}
|
||||
Reference in new issue
Block a user