feat(interaction): 支持游客互动与登录合并
This commit is contained in:
1 parent
2fe24b9db7
commit
4b0fbd73d3
41 files changed
+1207
-90
No files matched your search
+24
-11
@@ -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` | 可选 | 播放页轻量计数快照,不签发地址、不写票据缓存 |
|
| GET | `/app/episodes/{id}/stats` | 可选 | 播放页轻量计数快照,不签发地址、不写票据缓存 |
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -311,8 +311,10 @@ app.register.max-per-device-per-day
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`accessType` 取值:`FREE`(免费集)、`ENTITLED`(已购权益)、`COIN_REQUIRED`(需解锁,
|
`accessType` 取值:`FREE`(免费集)、`ENTITLED`(已购权益)、`COIN_REQUIRED`(已登录但
|
||||||
此时 `playUrl` 为空、`unlockCoin` 有值)、`NO_MEDIA`(还没上传片源或未转码完成)。
|
尚未解锁,此时 `playUrl` 为空、`unlockCoin` 有值)、`NO_MEDIA`(已登录用户访问的剧集还
|
||||||
|
没上传片源或未转码完成)。未登录请求锁定集在查询片源前直接返回 `UNAUTHORIZED`,不会
|
||||||
|
按设备身份取票或降级为匿名播放。
|
||||||
|
|
||||||
**判定顺序由服务端决定,客户端不得自行推断可否播放。** 签名默认 15 分钟有效;
|
**判定顺序由服务端决定,客户端不得自行推断可否播放。** 签名默认 15 分钟有效;
|
||||||
开启 `fruitshorts.media.signature-required` 后,`/files/episode/**` 没带有效签名会返回 403。
|
开启 `fruitshorts.media.signature-required` 后,`/files/episode/**` 没带有效签名会返回 403。
|
||||||
@@ -324,7 +326,8 @@ app.register.max-per-device-per-day
|
|||||||
**`GET /app/episodes/{id}/stats`(播放页计数刷新)**
|
**`GET /app/episodes/{id}/stats`(播放页计数刷新)**
|
||||||
|
|
||||||
只读返回本集点赞数、本集未删除评论数、所属剧收藏数,以及当前用户对
|
只读返回本集点赞数、本集未删除评论数、所属剧收藏数,以及当前用户对
|
||||||
本集/所属剧的互动状态。匿名请求时 `liked`、`favorited` 均为 `false`。
|
本集/所属剧的互动状态。已登录时按账号读取;匿名请求时按 `X-Device-Id`
|
||||||
|
读取设备互动状态,未带设备号时 `liked`、`favorited` 均为 `false`。
|
||||||
接口不生成播放签名地址、不写 `playback_ticket` 缓存,计数变化后立即可读。
|
接口不生成播放签名地址、不写 `playback_ticket` 缓存,计数变化后立即可读。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -354,9 +357,9 @@ app.register.max-per-device-per-day
|
|||||||
| GET | `/app/watch/recent` | 最近观看分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 |
|
| GET | `/app/watch/recent` | 最近观看分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 |
|
||||||
| DELETE | `/app/watch/progress/{dramaId}` | 删除某剧记录 |
|
| DELETE | `/app/watch/progress/{dramaId}` | 删除某剧记录 |
|
||||||
| DELETE | `/app/watch/progress` | 清空记录 |
|
| DELETE | `/app/watch/progress` | 清空记录 |
|
||||||
| POST | `/app/favorites/toggle` | 收藏/取消收藏,返回切换后状态 |
|
| POST | `/app/favorites/toggle` | 可选登录 | 收藏/取消收藏,返回切换后状态;匿名按 `X-Device-Id` |
|
||||||
| GET | `/app/favorites` | 收藏分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 |
|
| GET | `/app/favorites` | 收藏分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 |
|
||||||
| DELETE | `/app/favorites/{dramaId}` | 取消收藏 |
|
| DELETE | `/app/favorites/{dramaId}` | 可选登录 | 取消收藏;匿名按 `X-Device-Id` |
|
||||||
| POST | `/app/favorites/remove` | 批量取消收藏,请求体 `{ "dramaIds": ["12", "15"] }` |
|
| POST | `/app/favorites/remove` | 批量取消收藏,请求体 `{ "dramaIds": ["12", "15"] }` |
|
||||||
|
|
||||||
进度合并规则:同一集内只前进不后退;换集直接采纳新集;`finished` 只在接近片尾或客户端显式上报时置位。
|
进度合并规则:同一集内只前进不后退;换集直接采纳新集;`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?dramaId=&cursor=&size=` | 匿名 | 游标翻页;不传 `rootId` 返回顶层评论,传了返回该楼层回复 |
|
||||||
| GET | `/app/comments/count?dramaId=&episodeId=` | 匿名 | 传 `episodeId` 返回该集评论数;仅传 `dramaId` 返回全剧兼容汇总 |
|
| GET | `/app/comments/count?dramaId=&episodeId=` | 匿名 | 传 `episodeId` 返回该集评论数;仅传 `dramaId` 返回全剧兼容汇总 |
|
||||||
| DELETE | `/app/comments/{id}` | 登录 | 删除自己的评论(逻辑置为 `deleted`) |
|
| DELETE | `/app/comments/{id}` | 可选登录 | 删除自己的评论(匿名按设备);逻辑置为 `deleted` |
|
||||||
| POST | `/app/likes/toggle` | 登录 | 点赞/取消点赞,`targetType` 支持 `DRAMA` / `EPISODE` / `COMMENT`;新客户端点赞按集 |
|
| POST | `/app/likes/toggle` | 可选登录 | 点赞/取消点赞,`targetType` 支持 `DRAMA` / `EPISODE` / `COMMENT`;匿名按 `X-Device-Id` |
|
||||||
| POST | `/app/reports` | 登录 | 举报评论/用户/短剧;同一目标只保留一条待处理工单 |
|
| POST | `/app/reports` | 登录 | 举报评论/用户/短剧;同一目标只保留一条待处理工单 |
|
||||||
|
|
||||||
|
**非付费互动身份与合并**:点赞、收藏、评论的身份解析顺序为登录 `userId` →
|
||||||
|
`X-Device-Id` → 请求体 `deviceId`。三者都没有时返回业务码 `200007`。匿名写入按设备号
|
||||||
|
每分钟限流;评论同时做长度、空白、短时间重复内容和频率校验。登录或注册成功后,服务端
|
||||||
|
在同一登录事务内把该设备未归属的点赞、收藏、评论迁移到账号;账号已有同内容时保留账号
|
||||||
|
记录并删除设备重复记录,计数同步回退,不重复累加。
|
||||||
|
|
||||||
|
匿名评论展示为「游客」,头像为空占位。解锁、金币/VIP 权益、锁定集正片票据不在本规则内,
|
||||||
|
仍必须登录。
|
||||||
|
|
||||||
**敏感词三档处置**:`BLOCK` 直接拒绝并返回原因、`REPLACE` 替换后放行、`REVIEW` 转人工待审。
|
**敏感词三档处置**:`BLOCK` 直接拒绝并返回原因、`REPLACE` 替换后放行、`REVIEW` 转人工待审。
|
||||||
审核策略由 `fruitshorts.interaction.comment-audit-mode` 控制:
|
审核策略由 `fruitshorts.interaction.comment-audit-mode` 控制:
|
||||||
`MACHINE_FIRST`(机审通过即发布,当前默认)或 `PRE_REVIEW`(全部先审后发,生产建议)。
|
`MACHINE_FIRST`(机审通过即发布,当前默认)或 `PRE_REVIEW`(全部先审后发,生产建议)。
|
||||||
@@ -1026,8 +1038,9 @@ GET /app/notices?pageIndex=1&pageSize=20
|
|||||||
只接受 `bizType=EPISODE` 且状态为 `ready` / `uploaded` 的产物。
|
只接受 `bizType=EPISODE` 且状态为 `ready` / `uploaded` 的产物。
|
||||||
|
|
||||||
**收费配置**:`previewSeconds` 落在 `drama_episode.preview_seconds`(0 = 不限制),
|
**收费配置**:`previewSeconds` 落在 `drama_episode.preview_seconds`(0 = 不限制),
|
||||||
与 `is_free` / `unlock_coin` 一起就是播放与下载付费判定读的字段,**改完立即生效**(实测:未登录请求付费集返回
|
与 `is_free` / `unlock_coin` 一起就是播放与下载付费判定读的字段,**改完立即生效**(实测:未登录请求锁定集直接
|
||||||
`{"accessType":"COIN_REQUIRED","unlockCoin":50,"previewSeconds":15}`;免费集直接返回签名播放地址)。
|
返回 `UNAUTHORIZED`;已登录未解锁用户返回 `{"accessType":"COIN_REQUIRED","unlockCoin":50,"previewSeconds":15}`;
|
||||||
|
免费集直接返回签名播放地址)。
|
||||||
定价沿用「可编辑状态」口径:`draft` / `offline` 可改,已上线要改价需先申请下线(避免创作者悄悄改已上线内容的价格);
|
定价沿用「可编辑状态」口径:`draft` / `offline` 可改,已上线要改价需先申请下线(避免创作者悄悄改已上线内容的价格);
|
||||||
`dramaId` 以路径为准,body 里传别人的剧 id 无效,列表里带别人的 `episodeId` 会被忽略(只作用于自己剧的分集)。
|
`dramaId` 以路径为准,body 里传别人的剧 id 无效,列表里带别人的 `episodeId` 会被忽略(只作用于自己剧的分集)。
|
||||||
|
|
||||||
|
|||||||
+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.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
/** 客户端账号:验证码、注册、登录、刷新、登出。 */
|
/** 客户端账号:验证码、注册、登录、刷新、登出。 */
|
||||||
@Tag(name = "客户端-账号")
|
@Tag(name = "客户端-账号")
|
||||||
@@ -48,6 +49,7 @@ public class AppAuthController {
|
|||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public Result<TokenRespVO> register(@Valid @RequestBody RegisterReqVO req,
|
public Result<TokenRespVO> register(@Valid @RequestBody RegisterReqVO req,
|
||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
|
applyHeaderDeviceId(req, request);
|
||||||
return Result.ok(authService.register(req, WebUtils.getClientIp(request)));
|
return Result.ok(authService.register(req, WebUtils.getClientIp(request)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,13 +58,18 @@ public class AppAuthController {
|
|||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public Result<TokenRespVO> login(@Valid @RequestBody LoginReqVO req,
|
public Result<TokenRespVO> login(@Valid @RequestBody LoginReqVO req,
|
||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
|
applyHeaderDeviceId(req, request);
|
||||||
return Result.ok(authService.login(req, WebUtils.getClientIp(request)));
|
return Result.ok(authService.login(req, WebUtils.getClientIp(request)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "Google 登录(未配置 clientId 时返回明确错误)")
|
@Operation(summary = "Google 登录(未配置 clientId 时返回明确错误)")
|
||||||
@PostMapping("/google")
|
@PostMapping("/google")
|
||||||
public Result<TokenRespVO> google(@Valid @RequestBody GoogleLoginReqVO req) {
|
public Result<TokenRespVO> google(@Valid @RequestBody GoogleLoginReqVO req,
|
||||||
return Result.ok(authService.googleLogin(req.getIdToken()));
|
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 立即失效)")
|
@Operation(summary = "刷新令牌(旧 refresh token 立即失效)")
|
||||||
@@ -78,4 +85,16 @@ public class AppAuthController {
|
|||||||
authService.logout(SecurityUtils.getUserIdOrNull(), req == null ? new LogoutReqVO() : req);
|
authService.logout(SecurityUtils.getUserIdOrNull(), req == null ? new LogoutReqVO() : req);
|
||||||
return Result.ok();
|
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.dal.entity.DramaEpisode;
|
||||||
import com.fruitshorts.content.service.EpisodeService;
|
import com.fruitshorts.content.service.EpisodeService;
|
||||||
import com.fruitshorts.common.pojo.Result;
|
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.security.SecurityUtils;
|
||||||
import com.fruitshorts.framework.web.WebUtils;
|
import com.fruitshorts.framework.web.WebUtils;
|
||||||
import com.fruitshorts.interaction.service.CommentService;
|
import com.fruitshorts.interaction.service.CommentService;
|
||||||
@@ -33,7 +36,7 @@ import java.util.List;
|
|||||||
*
|
*
|
||||||
* <p>列表走游标翻页:`cursor` 传上一页最后一条的 id,返回 `nextCursor` 与 `hasMore`。
|
* <p>列表走游标翻页:`cursor` 传上一页最后一条的 id,返回 `nextCursor` 与 `hasMore`。
|
||||||
* 不传 `rootId` 时返回顶层评论,传了则返回该楼层的回复。
|
* 不传 `rootId` 时返回顶层评论,传了则返回该楼层的回复。
|
||||||
* 列表允许匿名浏览;发布与删除需要登录。
|
* 列表允许匿名浏览;发布与删除支持登录用户或设备身份。
|
||||||
*/
|
*/
|
||||||
@Tag(name = "客户端-评论")
|
@Tag(name = "客户端-评论")
|
||||||
@RestController
|
@RestController
|
||||||
@@ -43,13 +46,20 @@ public class AppCommentController {
|
|||||||
|
|
||||||
private final CommentService commentService;
|
private final CommentService commentService;
|
||||||
private final EpisodeService episodeService;
|
private final EpisodeService episodeService;
|
||||||
|
private final InteractionIdentityResolver identityResolver;
|
||||||
|
private final AnonymousInteractionRateLimiter anonymousRateLimiter;
|
||||||
|
|
||||||
@Operation(summary = "发布评论")
|
@Operation(summary = "发布评论")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public Result<CommentRespVO> publish(@Valid @RequestBody CommentPublishReqVO req,
|
public Result<CommentRespVO> publish(@Valid @RequestBody CommentPublishReqVO req,
|
||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
return Result.ok(commentService.publish(SecurityUtils.getAppUserId(), req,
|
InteractionIdentity identity = identityResolver.resolve(
|
||||||
WebUtils.getClientIp(request), request.getHeader("X-Device-Id")));
|
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 = "评论列表(游标翻页,可匿名浏览)")
|
@Operation(summary = "评论列表(游标翻页,可匿名浏览)")
|
||||||
@@ -58,14 +68,18 @@ public class AppCommentController {
|
|||||||
@RequestParam(required = false) Long episodeId,
|
@RequestParam(required = false) Long episodeId,
|
||||||
@RequestParam(required = false) Long rootId,
|
@RequestParam(required = false) Long rootId,
|
||||||
@RequestParam(required = false) Long cursor,
|
@RequestParam(required = false) Long cursor,
|
||||||
@RequestParam(required = false) Integer size) {
|
@RequestParam(required = false) Integer size,
|
||||||
|
HttpServletRequest request) {
|
||||||
CommentQueryReqVO query = new CommentQueryReqVO();
|
CommentQueryReqVO query = new CommentQueryReqVO();
|
||||||
query.setDramaId(dramaId);
|
query.setDramaId(dramaId);
|
||||||
query.setEpisodeId(episodeId);
|
query.setEpisodeId(episodeId);
|
||||||
query.setRootId(rootId);
|
query.setRootId(rootId);
|
||||||
query.setCursor(cursor);
|
query.setCursor(cursor);
|
||||||
query.setSize(size);
|
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 时全剧统计(可匿名访问)")
|
@Operation(summary = "评论总数:传 episodeId 按集统计,仅 dramaId 时全剧统计(可匿名访问)")
|
||||||
@@ -85,8 +99,13 @@ public class AppCommentController {
|
|||||||
|
|
||||||
@Operation(summary = "删除自己的评论")
|
@Operation(summary = "删除自己的评论")
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public Result<Void> delete(@PathVariable Long id) {
|
public Result<Void> delete(@PathVariable Long id, HttpServletRequest request) {
|
||||||
commentService.delete(SecurityUtils.getAppUserId(), id);
|
InteractionIdentity identity = identityResolver.resolve(
|
||||||
|
SecurityUtils.getUserIdOrNull(),
|
||||||
|
request.getHeader("X-Device-Id"),
|
||||||
|
"");
|
||||||
|
anonymousRateLimiter.check(identity, "comment");
|
||||||
|
commentService.delete(identity, id);
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+6
-2
@@ -66,7 +66,11 @@ public class AppDramaController {
|
|||||||
/** 轻量计数快照:只读互动状态,不签发播放地址、不写票据缓存。 */
|
/** 轻量计数快照:只读互动状态,不签发播放地址、不写票据缓存。 */
|
||||||
@Operation(summary = "播放页轻量计数与当前用户互动状态")
|
@Operation(summary = "播放页轻量计数与当前用户互动状态")
|
||||||
@GetMapping("/episodes/{id}/stats")
|
@GetMapping("/episodes/{id}/stats")
|
||||||
public Result<AppEpisodeStatsVO> stats(@PathVariable Long id) {
|
public Result<AppEpisodeStatsVO> stats(
|
||||||
return Result.ok(appEpisodeStatsService.get(id, SecurityUtils.getUserIdOrNull()));
|
@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.app.vo.AppDramaCardVO;
|
||||||
import com.fruitshorts.common.pojo.PageResult;
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
import com.fruitshorts.common.pojo.Result;
|
import com.fruitshorts.common.pojo.Result;
|
||||||
|
import com.fruitshorts.common.interaction.InteractionIdentity;
|
||||||
|
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
|
||||||
|
import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
|
||||||
import com.fruitshorts.content.dal.entity.Drama;
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
import com.fruitshorts.framework.security.SecurityUtils;
|
import com.fruitshorts.framework.security.SecurityUtils;
|
||||||
import com.fruitshorts.playback.service.FavoriteService;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -33,12 +37,21 @@ public class AppFavoriteController {
|
|||||||
|
|
||||||
private final FavoriteService favoriteService;
|
private final FavoriteService favoriteService;
|
||||||
private final AppCatalogService appCatalogService;
|
private final AppCatalogService appCatalogService;
|
||||||
|
private final InteractionIdentityResolver identityResolver;
|
||||||
|
private final AnonymousInteractionRateLimiter anonymousRateLimiter;
|
||||||
|
|
||||||
/** 切换收藏,返回切换后的状态:true 为已收藏。 */
|
/** 切换收藏,返回切换后的状态:true 为已收藏。 */
|
||||||
@Operation(summary = "切换收藏(返回切换后的状态)")
|
@Operation(summary = "切换收藏(返回切换后的状态)")
|
||||||
@PostMapping("/toggle")
|
@PostMapping("/toggle")
|
||||||
public Result<Boolean> toggle(@Valid @RequestBody FavoriteToggleReqVO req) {
|
public Result<Boolean> toggle(
|
||||||
return Result.ok(favoriteService.toggle(SecurityUtils.getAppUserId(), req.getDramaId()));
|
@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 = "取消收藏")
|
@Operation(summary = "取消收藏")
|
||||||
@DeleteMapping("/{dramaId}")
|
@DeleteMapping("/{dramaId}")
|
||||||
public Result<Void> remove(@PathVariable Long dramaId) {
|
public Result<Void> remove(@PathVariable Long dramaId, HttpServletRequest request) {
|
||||||
favoriteService.remove(SecurityUtils.getAppUserId(), dramaId);
|
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();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+15
-2
@@ -3,7 +3,10 @@
|
|||||||
package com.fruitshorts.app.controller;
|
package com.fruitshorts.app.controller;
|
||||||
|
|
||||||
import com.fruitshorts.common.pojo.Result;
|
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.framework.security.SecurityUtils;
|
||||||
|
import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
|
||||||
import com.fruitshorts.interaction.service.LikeService;
|
import com.fruitshorts.interaction.service.LikeService;
|
||||||
import com.fruitshorts.interaction.service.ReportService;
|
import com.fruitshorts.interaction.service.ReportService;
|
||||||
import com.fruitshorts.interaction.vo.LikeToggleReqVO;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
@@ -27,12 +31,21 @@ public class AppInteractionController {
|
|||||||
|
|
||||||
private final LikeService likeService;
|
private final LikeService likeService;
|
||||||
private final ReportService reportService;
|
private final ReportService reportService;
|
||||||
|
private final InteractionIdentityResolver identityResolver;
|
||||||
|
private final AnonymousInteractionRateLimiter anonymousRateLimiter;
|
||||||
|
|
||||||
/** 点赞/取消点赞,返回切换后的状态与最新计数。 */
|
/** 点赞/取消点赞,返回切换后的状态与最新计数。 */
|
||||||
@Operation(summary = "点赞 / 取消点赞")
|
@Operation(summary = "点赞 / 取消点赞")
|
||||||
@PostMapping("/likes/toggle")
|
@PostMapping("/likes/toggle")
|
||||||
public Result<LikeToggleRespVO> toggleLike(@Valid @RequestBody LikeToggleReqVO req) {
|
public Result<LikeToggleRespVO> toggleLike(
|
||||||
return Result.ok(likeService.toggle(SecurityUtils.getAppUserId(), req));
|
@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 LikeService likeService;
|
||||||
private final FavoriteService favoriteService;
|
private final FavoriteService favoriteService;
|
||||||
|
|
||||||
public AppEpisodeStatsVO get(Long episodeId, Long userId) {
|
public AppEpisodeStatsVO get(Long episodeId, Long userId, String deviceId) {
|
||||||
Map<Long, AppEpisodeStatsVO> stats = batch(List.of(episodeId), userId);
|
Map<Long, AppEpisodeStatsVO> stats = batch(List.of(episodeId), userId, deviceId);
|
||||||
AppEpisodeStatsVO result = stats.get(episodeId);
|
AppEpisodeStatsVO result = stats.get(episodeId);
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "剧集不存在或未上线");
|
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "剧集不存在或未上线");
|
||||||
@@ -41,8 +41,17 @@ public class AppEpisodeStatsService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public AppEpisodeStatsVO get(Long episodeId, Long userId) {
|
||||||
|
return get(episodeId, userId, "");
|
||||||
|
}
|
||||||
|
|
||||||
/** 每类数据只查一次,供后续多集统计复用。 */
|
/** 每类数据只查一次,供后续多集统计复用。 */
|
||||||
public Map<Long, AppEpisodeStatsVO> batch(Collection<Long> episodeIds, Long 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()) {
|
if (episodeIds == null || episodeIds.isEmpty()) {
|
||||||
return Map.of();
|
return Map.of();
|
||||||
}
|
}
|
||||||
@@ -58,8 +67,9 @@ public class AppEpisodeStatsService {
|
|||||||
dramas.put(drama.getId(), drama);
|
dramas.put(drama.getId(), drama);
|
||||||
}
|
}
|
||||||
Set<Long> likedIds = likeService.likedTargetIds(
|
Set<Long> likedIds = likeService.likedTargetIds(
|
||||||
userId, LikeService.TYPE_EPISODE, normalizedIds);
|
userId, deviceId, LikeService.TYPE_EPISODE, normalizedIds);
|
||||||
Set<Long> favoriteDramaIds = favoriteService.favoriteDramaIds(userId, dramaIds);
|
Set<Long> favoriteDramaIds = favoriteService.favoriteDramaIds(
|
||||||
|
userId, deviceId, dramaIds);
|
||||||
|
|
||||||
Map<Long, AppEpisodeStatsVO> result = new HashMap<>();
|
Map<Long, AppEpisodeStatsVO> result = new HashMap<>();
|
||||||
for (DramaEpisode episode : episodes) {
|
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")
|
@NotBlank(message = "缺少 Google idToken")
|
||||||
private String idToken;
|
private String idToken;
|
||||||
|
|
||||||
|
/** 旧客户端未统一发送请求头时,仍可用于合并设备互动。 */
|
||||||
|
private String deviceId;
|
||||||
}
|
}
|
||||||
+7
-1
@@ -6,6 +6,8 @@ import com.fruitshorts.app.support.ControllerContractSupport;
|
|||||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||||
import com.fruitshorts.content.service.EpisodeService;
|
import com.fruitshorts.content.service.EpisodeService;
|
||||||
import com.fruitshorts.interaction.service.CommentService;
|
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.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
@@ -32,7 +34,11 @@ class AppCommentControllerContractTest {
|
|||||||
commentService = mock(CommentService.class);
|
commentService = mock(CommentService.class);
|
||||||
episodeService = mock(EpisodeService.class);
|
episodeService = mock(EpisodeService.class);
|
||||||
mockMvc = ControllerContractSupport.mockMvcOf(
|
mockMvc = ControllerContractSupport.mockMvcOf(
|
||||||
new AppCommentController(commentService, episodeService));
|
new AppCommentController(
|
||||||
|
commentService,
|
||||||
|
episodeService,
|
||||||
|
mock(InteractionIdentityResolver.class),
|
||||||
|
mock(AnonymousInteractionRateLimiter.class)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+148
-2
@@ -3,9 +3,17 @@
|
|||||||
package com.fruitshorts.app.controller;
|
package com.fruitshorts.app.controller;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fruitshorts.app.service.AppEpisodeStatsService;
|
||||||
import com.fruitshorts.app.service.AppCatalogService;
|
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.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.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.log.AccessDeniedRecorder;
|
||||||
import com.fruitshorts.framework.security.JwtAuthenticationFilter;
|
import com.fruitshorts.framework.security.JwtAuthenticationFilter;
|
||||||
import com.fruitshorts.framework.security.JwtTokenProvider;
|
import com.fruitshorts.framework.security.JwtTokenProvider;
|
||||||
@@ -36,6 +44,7 @@ import java.util.List;
|
|||||||
import static org.mockito.BDDMockito.given;
|
import static org.mockito.BDDMockito.given;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@@ -52,12 +61,23 @@ class AppCommentCountAnonymousSecurityTest {
|
|||||||
private CommentService commentService;
|
private CommentService commentService;
|
||||||
@Autowired
|
@Autowired
|
||||||
private AppCatalogService catalogService;
|
private AppCatalogService catalogService;
|
||||||
|
@Autowired
|
||||||
|
private AppEpisodeStatsService episodeStatsService;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
given(commentService.countByDrama(7L)).willReturn(9L);
|
given(commentService.countByDrama(7L)).willReturn(9L);
|
||||||
given(catalogService.feed("ANIME", null, null, 20))
|
given(catalogService.feed("ANIME", null, null, 20))
|
||||||
.willReturn(new CursorResult<>(List.of(), null, false));
|
.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
|
@Test
|
||||||
@@ -78,6 +98,52 @@ class AppCommentCountAnonymousSecurityTest {
|
|||||||
.andExpect(jsonPath("$.data.list").isArray());
|
.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
|
@Configuration
|
||||||
@EnableWebMvc
|
@EnableWebMvc
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@@ -90,8 +156,87 @@ class AppCommentCountAnonymousSecurityTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
AppCommentController appCommentController(CommentService commentService) {
|
EpisodeService episodeService() {
|
||||||
return new AppCommentController(commentService, mock(EpisodeService.class));
|
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
|
@Bean
|
||||||
@@ -132,4 +277,5 @@ class AppCommentCountAnonymousSecurityTest {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
+7
-1
@@ -8,6 +8,8 @@ import com.fruitshorts.app.vo.AppDramaCardVO;
|
|||||||
import com.fruitshorts.common.pojo.PageResult;
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
import com.fruitshorts.content.dal.entity.Drama;
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
import com.fruitshorts.framework.security.LoginUser;
|
import com.fruitshorts.framework.security.LoginUser;
|
||||||
|
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
|
||||||
|
import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
|
||||||
import com.fruitshorts.playback.service.FavoriteService;
|
import com.fruitshorts.playback.service.FavoriteService;
|
||||||
import com.fruitshorts.playback.service.WatchProgressService;
|
import com.fruitshorts.playback.service.WatchProgressService;
|
||||||
import com.fruitshorts.playback.vo.ProgressRespVO;
|
import com.fruitshorts.playback.vo.ProgressRespVO;
|
||||||
@@ -45,7 +47,11 @@ class AppLibraryPagingContractTest {
|
|||||||
catalogService = mock(AppCatalogService.class);
|
catalogService = mock(AppCatalogService.class);
|
||||||
progressService = mock(WatchProgressService.class);
|
progressService = mock(WatchProgressService.class);
|
||||||
favoriteMvc = ControllerContractSupport.mockMvcOf(
|
favoriteMvc = ControllerContractSupport.mockMvcOf(
|
||||||
new AppFavoriteController(favoriteService, catalogService));
|
new AppFavoriteController(
|
||||||
|
favoriteService,
|
||||||
|
catalogService,
|
||||||
|
mock(InteractionIdentityResolver.class),
|
||||||
|
mock(AnonymousInteractionRateLimiter.class)));
|
||||||
watchMvc = ControllerContractSupport.mockMvcOf(
|
watchMvc = ControllerContractSupport.mockMvcOf(
|
||||||
new AppWatchController(progressService));
|
new AppWatchController(progressService));
|
||||||
SecurityContextHolder.getContext().setAuthentication(
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
|||||||
+2
-2
@@ -116,7 +116,7 @@ class AppPlaybackContractTest {
|
|||||||
stats.setFavoriteCount(1800L);
|
stats.setFavoriteCount(1800L);
|
||||||
stats.setLiked(true);
|
stats.setLiked(true);
|
||||||
stats.setFavorited(false);
|
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"))
|
mockMvc.perform(get("/app/episodes/11/stats"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -128,7 +128,7 @@ class AppPlaybackContractTest {
|
|||||||
.andExpect(jsonPath("$.data.liked").value(true))
|
.andExpect(jsonPath("$.data.liked").value(true))
|
||||||
.andExpect(jsonPath("$.data.favorited").value(false));
|
.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());
|
verify(playbackService, org.mockito.Mockito.never()).play(any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+48
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-4
@@ -16,6 +16,7 @@ import com.fruitshorts.user.dal.mapper.AppUserMapper;
|
|||||||
import com.fruitshorts.user.dal.mapper.AppUserOtpMapper;
|
import com.fruitshorts.user.dal.mapper.AppUserOtpMapper;
|
||||||
import com.fruitshorts.user.dal.mapper.AppUserSessionMapper;
|
import com.fruitshorts.user.dal.mapper.AppUserSessionMapper;
|
||||||
import com.fruitshorts.user.enums.AppUserStatus;
|
import com.fruitshorts.user.enums.AppUserStatus;
|
||||||
|
import com.fruitshorts.user.event.AppInteractionMergeRequested;
|
||||||
import com.fruitshorts.user.service.AppAuthService;
|
import com.fruitshorts.user.service.AppAuthService;
|
||||||
import com.fruitshorts.user.service.AppOtpService;
|
import com.fruitshorts.user.service.AppOtpService;
|
||||||
import com.fruitshorts.user.service.CredentialHasher;
|
import com.fruitshorts.user.service.CredentialHasher;
|
||||||
@@ -27,7 +28,9 @@ import com.fruitshorts.user.vo.RegisterReqVO;
|
|||||||
import com.fruitshorts.user.vo.TokenRespVO;
|
import com.fruitshorts.user.vo.TokenRespVO;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
@@ -63,6 +66,7 @@ class AppAuthServiceTest {
|
|||||||
private PasswordEncoder passwordEncoder;
|
private PasswordEncoder passwordEncoder;
|
||||||
private ConfigService configService;
|
private ConfigService configService;
|
||||||
private GoogleIdTokenVerifier googleVerifier;
|
private GoogleIdTokenVerifier googleVerifier;
|
||||||
|
private ApplicationEventPublisher eventPublisher;
|
||||||
private AppAuthService authService;
|
private AppAuthService authService;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -78,11 +82,12 @@ class AppAuthServiceTest {
|
|||||||
passwordEncoder = mock(PasswordEncoder.class);
|
passwordEncoder = mock(PasswordEncoder.class);
|
||||||
configService = mock(ConfigService.class);
|
configService = mock(ConfigService.class);
|
||||||
googleVerifier = mock(GoogleIdTokenVerifier.class);
|
googleVerifier = mock(GoogleIdTokenVerifier.class);
|
||||||
|
eventPublisher = mock(ApplicationEventPublisher.class);
|
||||||
given(configService.get(anyString(), anyString()))
|
given(configService.get(anyString(), anyString()))
|
||||||
.willAnswer(invocation -> invocation.getArgument(1));
|
.willAnswer(invocation -> invocation.getArgument(1));
|
||||||
authService = new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
|
authService = new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
|
||||||
deviceMapper, otpService, hasher, tokenProvider, passwordEncoder, configService,
|
deviceMapper, otpService, hasher, tokenProvider, passwordEncoder, configService,
|
||||||
googleVerifier);
|
googleVerifier, eventPublisher);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -98,6 +103,9 @@ class AppAuthServiceTest {
|
|||||||
// 免码注册不等于"已验证":邮箱可达性没验证过,所以先记 0,等日后点验证链接再置 1
|
// 免码注册不等于"已验证":邮箱可达性没验证过,所以先记 0,等日后点验证链接再置 1
|
||||||
assertThat(saved.get().getVerified()).isZero();
|
assertThat(saved.get().getVerified()).isZero();
|
||||||
verify(otpService, never()).consume(anyString(), anyString(), anyString(), any());
|
verify(otpService, never()).consume(anyString(), anyString(), anyString(), any());
|
||||||
|
AppInteractionMergeRequested event = capturedMergeEvent();
|
||||||
|
assertThat(event.userId()).isEqualTo(1001L);
|
||||||
|
assertThat(event.deviceId()).isEqualTo("device-9");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -251,6 +259,9 @@ class AppAuthServiceTest {
|
|||||||
assertThat(resp.getAccessToken()).isEqualTo("access-1");
|
assertThat(resp.getAccessToken()).isEqualTo("access-1");
|
||||||
assertThat(resp.getUser().getUid()).isEqualTo("UAB12CD");
|
assertThat(resp.getUser().getUid()).isEqualTo("UAB12CD");
|
||||||
verify(sessionMapper).insert(any(AppUserSession.class));
|
verify(sessionMapper).insert(any(AppUserSession.class));
|
||||||
|
AppInteractionMergeRequested event = capturedMergeEvent();
|
||||||
|
assertThat(event.userId()).isEqualTo(1001L);
|
||||||
|
assertThat(event.deviceId()).isEqualTo("device-9");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -307,11 +318,14 @@ class AppAuthServiceTest {
|
|||||||
});
|
});
|
||||||
given(userMapper.selectById(any())).willAnswer(invocation -> inserted.get());
|
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.getAccessToken()).isEqualTo("access-1");
|
||||||
assertThat(resp.isNewUser()).isTrue();
|
assertThat(resp.isNewUser()).isTrue();
|
||||||
verify(sessionMapper).insert(any(AppUserSession.class));
|
verify(sessionMapper).insert(any(AppUserSession.class));
|
||||||
|
AppInteractionMergeRequested event = capturedMergeEvent();
|
||||||
|
assertThat(event.userId()).isEqualTo(1001L);
|
||||||
|
assertThat(event.deviceId()).isEqualTo("device-9");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -319,7 +333,7 @@ class AppAuthServiceTest {
|
|||||||
given(googleVerifier.verify(anyString()))
|
given(googleVerifier.verify(anyString()))
|
||||||
.willReturn(new GoogleUser("sub-1", "", "Alice", ""));
|
.willReturn(new GoogleUser("sub-1", "", "Alice", ""));
|
||||||
|
|
||||||
assertThatThrownBy(() -> authService.googleLogin("google-id-token"))
|
assertThatThrownBy(() -> authService.googleLogin("google-id-token", "device-9"))
|
||||||
.isInstanceOf(ServiceException.class)
|
.isInstanceOf(ServiceException.class)
|
||||||
.hasMessage("Google 账号未返回邮箱,无法登录");
|
.hasMessage("Google 账号未返回邮箱,无法登录");
|
||||||
}
|
}
|
||||||
@@ -353,6 +367,13 @@ class AppAuthServiceTest {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private AppInteractionMergeRequested capturedMergeEvent() {
|
||||||
|
ArgumentCaptor<AppInteractionMergeRequested> event =
|
||||||
|
ArgumentCaptor.forClass(AppInteractionMergeRequested.class);
|
||||||
|
verify(eventPublisher).publishEvent(event.capture());
|
||||||
|
return event.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
private void stubLogin(AppUserCredential credential, AppUser user) {
|
private void stubLogin(AppUserCredential credential, AppUser user) {
|
||||||
given(credentialMapper.selectOne(any())).willReturn(credential);
|
given(credentialMapper.selectOne(any())).willReturn(credential);
|
||||||
given(userMapper.selectById(any())).willReturn(user);
|
given(userMapper.selectById(any())).willReturn(user);
|
||||||
@@ -371,7 +392,7 @@ class AppAuthServiceTest {
|
|||||||
AppOtpService realOtpService = new AppOtpService(otpMapper, mock(OtpSender.class), otpHasher);
|
AppOtpService realOtpService = new AppOtpService(otpMapper, mock(OtpSender.class), otpHasher);
|
||||||
return new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
|
return new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
|
||||||
deviceMapper, realOtpService, hasher, tokenProvider, passwordEncoder, configService,
|
deviceMapper, realOtpService, hasher, tokenProvider, passwordEncoder, configService,
|
||||||
googleVerifier);
|
googleVerifier, eventPublisher);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setRegisterLimits(int perIp, int perDevice) {
|
private void setRegisterLimits(int perIp, int perDevice) {
|
||||||
|
|||||||
+7
-5
@@ -42,12 +42,14 @@ class AppEpisodeStatsServiceTest {
|
|||||||
given(dramaService.listPublishedByIds(List.of(7L))).willReturn(List.of(drama));
|
given(dramaService.listPublishedByIds(List.of(7L))).willReturn(List.of(drama));
|
||||||
given(commentService.countByEpisodes(List.of(11L))).willReturn(Map.of(11L, 8L));
|
given(commentService.countByEpisodes(List.of(11L))).willReturn(Map.of(11L, 8L));
|
||||||
given(likeService.likedTargetIds(
|
given(likeService.likedTargetIds(
|
||||||
100L, LikeService.TYPE_EPISODE, List.of(11L))).willReturn(Set.of(11L));
|
100L, "device-1", LikeService.TYPE_EPISODE, List.of(11L)))
|
||||||
given(favoriteService.favoriteDramaIds(100L, List.of(7L))).willReturn(Set.of());
|
.willReturn(Set.of(11L));
|
||||||
|
given(favoriteService.favoriteDramaIds(100L, "device-1", List.of(7L)))
|
||||||
|
.willReturn(Set.of());
|
||||||
|
|
||||||
AppEpisodeStatsService service = new AppEpisodeStatsService(
|
AppEpisodeStatsService service = new AppEpisodeStatsService(
|
||||||
episodeService, dramaService, commentService, likeService, favoriteService);
|
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.getEpisodeId()).isEqualTo(11L);
|
||||||
assertThat(result.getDramaId()).isEqualTo(7L);
|
assertThat(result.getDramaId()).isEqualTo(7L);
|
||||||
@@ -59,7 +61,7 @@ class AppEpisodeStatsServiceTest {
|
|||||||
verify(episodeService).listPublishedByIds(List.of(11L));
|
verify(episodeService).listPublishedByIds(List.of(11L));
|
||||||
verify(commentService).countByEpisodes(List.of(11L));
|
verify(commentService).countByEpisodes(List.of(11L));
|
||||||
verify(likeService).likedTargetIds(
|
verify(likeService).likedTargetIds(
|
||||||
100L, LikeService.TYPE_EPISODE, List.of(11L));
|
100L, "device-1", LikeService.TYPE_EPISODE, List.of(11L));
|
||||||
verify(favoriteService).favoriteDramaIds(100L, List.of(7L));
|
verify(favoriteService).favoriteDramaIds(100L, "device-1", List.of(7L));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+27
@@ -1,6 +1,8 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
package com.fruitshorts.app.service;
|
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.Drama;
|
||||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
@@ -20,6 +22,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
@@ -112,6 +115,30 @@ class PlaybackServiceTest {
|
|||||||
verify(ticketMapper, never()).insert(any(PlaybackTicket.class));
|
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
|
@Test
|
||||||
void should_return_entitled_access_when_entitlement_port_confirms_unlock() {
|
void should_return_entitled_access_when_entitlement_port_confirms_unlock() {
|
||||||
given(episodeService.getPublished(13L)).willReturn(episode(13L, 0, 60, "/files/episode/3.mp4"));
|
given(episodeService.getPublished(13L)).willReturn(episode(13L, 0, 60, "/files/episode/3.mp4"));
|
||||||
|
|||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.interaction.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.fruitshorts.common.interaction.InteractionIdentity;
|
||||||
|
import com.fruitshorts.content.service.DramaService;
|
||||||
|
import com.fruitshorts.content.service.EpisodeService;
|
||||||
|
import com.fruitshorts.interaction.config.InteractionProperties;
|
||||||
|
import com.fruitshorts.interaction.dal.entity.ContentComment;
|
||||||
|
import com.fruitshorts.interaction.dal.mapper.ContentAuditRecordMapper;
|
||||||
|
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
|
||||||
|
import com.fruitshorts.interaction.dal.mapper.ContentLikeMapper;
|
||||||
|
import com.fruitshorts.interaction.vo.CommentPublishReqVO;
|
||||||
|
import com.fruitshorts.interaction.vo.CommentRespVO;
|
||||||
|
import com.fruitshorts.user.dal.mapper.AppUserMapper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.BDDMockito.given;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
class AnonymousCommentServiceTest {
|
||||||
|
|
||||||
|
private ContentCommentMapper commentMapper;
|
||||||
|
private SensitiveWordService sensitiveWordService;
|
||||||
|
private CommentService commentService;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initTableInfo() {
|
||||||
|
TableInfoHelper.initTableInfo(
|
||||||
|
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||||
|
ContentComment.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
commentMapper = mock(ContentCommentMapper.class);
|
||||||
|
sensitiveWordService = mock(SensitiveWordService.class);
|
||||||
|
AuditRecorder auditRecorder = mock(AuditRecorder.class);
|
||||||
|
commentService = new CommentService(
|
||||||
|
commentMapper,
|
||||||
|
mock(ContentLikeMapper.class),
|
||||||
|
mock(ContentAuditRecordMapper.class),
|
||||||
|
mock(AppUserMapper.class),
|
||||||
|
mock(DramaService.class),
|
||||||
|
mock(EpisodeService.class),
|
||||||
|
sensitiveWordService,
|
||||||
|
auditRecorder,
|
||||||
|
new InteractionProperties());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_publish_anonymous_comment_with_guest_name_and_device_owner() {
|
||||||
|
given(commentMapper.selectCount(any())).willReturn(0L);
|
||||||
|
given(commentMapper.insert(any(ContentComment.class))).willReturn(1);
|
||||||
|
given(sensitiveWordService.check("hello")).willReturn(
|
||||||
|
SensitiveWordCheck.pass("hello"));
|
||||||
|
|
||||||
|
CommentRespVO result = commentService.publish(
|
||||||
|
new InteractionIdentity(null, "device-a"),
|
||||||
|
request("hello"),
|
||||||
|
"127.0.0.1");
|
||||||
|
|
||||||
|
ArgumentCaptor<ContentComment> captor =
|
||||||
|
ArgumentCaptor.forClass(ContentComment.class);
|
||||||
|
verify(commentMapper).insert(captor.capture());
|
||||||
|
assertThat(captor.getValue().getUserId()).isNull();
|
||||||
|
assertThat(captor.getValue().getDeviceId()).isEqualTo("device-a");
|
||||||
|
assertThat(result.getNickname()).isEqualTo("游客");
|
||||||
|
assertThat(result.getMine()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_reject_duplicate_anonymous_content_within_one_minute() {
|
||||||
|
given(commentMapper.selectCount(any())).willReturn(0L, 1L);
|
||||||
|
given(sensitiveWordService.check("hello")).willReturn(
|
||||||
|
SensitiveWordCheck.pass("hello"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> commentService.publish(
|
||||||
|
new InteractionIdentity(null, "device-a"),
|
||||||
|
request("hello"),
|
||||||
|
"127.0.0.1"))
|
||||||
|
.hasMessageContaining("重复");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_merge_device_comments_and_remove_duplicate() {
|
||||||
|
ContentComment duplicate = comment(1L, "same");
|
||||||
|
ContentComment unique = comment(2L, "new");
|
||||||
|
given(commentMapper.selectList(any())).willReturn(List.of(duplicate, unique));
|
||||||
|
given(commentMapper.selectOne(any())).willReturn(comment(3L, "same"), null);
|
||||||
|
given(commentMapper.physicalDeleteById(1L)).willReturn(1);
|
||||||
|
|
||||||
|
CommentService.MergeResult result =
|
||||||
|
commentService.mergeDeviceIntoUser(9L, "device-a");
|
||||||
|
|
||||||
|
assertThat(result.migrated()).isEqualTo(1);
|
||||||
|
assertThat(result.duplicates()).isEqualTo(1);
|
||||||
|
verify(commentMapper).physicalDeleteById(1L);
|
||||||
|
verify(commentMapper).updateById(unique);
|
||||||
|
assertThat(unique.getUserId()).isEqualTo(9L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommentPublishReqVO request(String content) {
|
||||||
|
CommentPublishReqVO req = new CommentPublishReqVO();
|
||||||
|
req.setDramaId(7L);
|
||||||
|
req.setContent(content);
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ContentComment comment(Long id, String content) {
|
||||||
|
ContentComment comment = new ContentComment();
|
||||||
|
comment.setId(id);
|
||||||
|
comment.setDramaId(7L);
|
||||||
|
comment.setEpisodeId(0L);
|
||||||
|
comment.setParentId(0L);
|
||||||
|
comment.setContent(content);
|
||||||
|
comment.setStatus(CommentService.STATUS_PUBLISHED);
|
||||||
|
comment.setDeviceId("device-a");
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|||||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
import com.fruitshorts.common.exception.ErrorCode;
|
import com.fruitshorts.common.exception.ErrorCode;
|
||||||
import com.fruitshorts.common.exception.ServiceException;
|
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.Drama;
|
||||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
@@ -19,6 +20,9 @@ import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
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.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
@@ -95,6 +99,44 @@ class LikeServiceEpisodeTest {
|
|||||||
verify(likeMapper, never()).insert(any(ContentLike.class));
|
verify(likeMapper, never()).insert(any(ContentLike.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_store_anonymous_episode_like_by_device() {
|
||||||
|
DramaEpisode published = episode(1L);
|
||||||
|
given(episodeService.getPublished(11L)).willReturn(published);
|
||||||
|
given(dramaService.getPublished(5L)).willReturn(new Drama());
|
||||||
|
given(likeMapper.selectOne(any())).willReturn(null);
|
||||||
|
given(episodeService.getExisting(11L)).willReturn(published);
|
||||||
|
|
||||||
|
likeService.toggle(new InteractionIdentity(null, "device-a"), request(11L));
|
||||||
|
|
||||||
|
ArgumentCaptor<ContentLike> captor = ArgumentCaptor.forClass(ContentLike.class);
|
||||||
|
verify(likeMapper).insert(captor.capture());
|
||||||
|
assertThat(captor.getValue().getUserId()).isNull();
|
||||||
|
assertThat(captor.getValue().getDeviceId()).isEqualTo("device-a");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_merge_device_likes_and_decrement_duplicate_count_once() {
|
||||||
|
DramaEpisode published = episode(2L);
|
||||||
|
ContentLike duplicate = like(1L, LikeService.TYPE_EPISODE, 11L);
|
||||||
|
ContentLike unique = like(2L, LikeService.TYPE_EPISODE, 12L);
|
||||||
|
given(likeMapper.selectList(any())).willReturn(List.of(duplicate, unique));
|
||||||
|
given(likeMapper.selectOne(any())).willReturn(
|
||||||
|
like(3L, LikeService.TYPE_EPISODE, 11L),
|
||||||
|
null);
|
||||||
|
given(likeMapper.physicalDeleteById(1L)).willReturn(1);
|
||||||
|
given(episodeService.getExisting(11L)).willReturn(published);
|
||||||
|
given(episodeService.getExisting(12L)).willReturn(published);
|
||||||
|
|
||||||
|
LikeService.MergeResult result = likeService.mergeDeviceIntoUser(9L, "device-a");
|
||||||
|
|
||||||
|
assertThat(result.migrated()).isEqualTo(1);
|
||||||
|
assertThat(result.duplicates()).isEqualTo(1);
|
||||||
|
verify(episodeService).adjustLikeCount(11L, -1);
|
||||||
|
verify(likeMapper).updateById(unique);
|
||||||
|
assertThat(unique.getUserId()).isEqualTo(9L);
|
||||||
|
}
|
||||||
|
|
||||||
private DramaEpisode episode(Long likeCount) {
|
private DramaEpisode episode(Long likeCount) {
|
||||||
DramaEpisode episode = new DramaEpisode();
|
DramaEpisode episode = new DramaEpisode();
|
||||||
episode.setId(11L);
|
episode.setId(11L);
|
||||||
@@ -109,4 +151,12 @@ class LikeServiceEpisodeTest {
|
|||||||
req.setTargetId(episodeId);
|
req.setTargetId(episodeId);
|
||||||
return req;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+33
@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|||||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.fruitshorts.common.pojo.PageResult;
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
|
import com.fruitshorts.common.interaction.InteractionIdentity;
|
||||||
import com.fruitshorts.content.dal.entity.Drama;
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
import com.fruitshorts.playback.dal.entity.UserFavorite;
|
import com.fruitshorts.playback.dal.entity.UserFavorite;
|
||||||
@@ -58,6 +59,38 @@ class FavoriteServiceTest {
|
|||||||
verify(dramaService).adjustFavoriteCount(7L, 1);
|
verify(dramaService).adjustFavoriteCount(7L, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_store_anonymous_favorite_by_device() {
|
||||||
|
given(favoriteMapper.selectOne(any())).willReturn(null);
|
||||||
|
given(favoriteMapper.insert(any(UserFavorite.class))).willReturn(1);
|
||||||
|
|
||||||
|
favoriteService.toggle(new InteractionIdentity(null, "device-a"), 7L);
|
||||||
|
|
||||||
|
ArgumentCaptor<UserFavorite> captor = ArgumentCaptor.forClass(UserFavorite.class);
|
||||||
|
verify(favoriteMapper).insert(captor.capture());
|
||||||
|
assertThat(captor.getValue().getUserId()).isNull();
|
||||||
|
assertThat(captor.getValue().getDeviceId()).isEqualTo("device-a");
|
||||||
|
verify(dramaService).adjustFavoriteCount(7L, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_merge_device_favorites_and_decrement_duplicate_count_once() {
|
||||||
|
UserFavorite duplicate = favorite(7L);
|
||||||
|
UserFavorite unique = favorite(8L);
|
||||||
|
given(favoriteMapper.selectList(any())).willReturn(List.of(duplicate, unique));
|
||||||
|
given(favoriteMapper.selectOne(any())).willReturn(favorite(9L), null);
|
||||||
|
given(favoriteMapper.physicalDeleteById(duplicate.getId())).willReturn(1);
|
||||||
|
|
||||||
|
FavoriteService.MergeResult result =
|
||||||
|
favoriteService.mergeDeviceIntoUser(1L, "device-a");
|
||||||
|
|
||||||
|
assertThat(result.migrated()).isEqualTo(1);
|
||||||
|
assertThat(result.duplicates()).isEqualTo(1);
|
||||||
|
verify(dramaService).adjustFavoriteCount(7L, -1);
|
||||||
|
verify(favoriteMapper).updateById(unique);
|
||||||
|
assertThat(unique.getUserId()).isEqualTo(1L);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void should_decrement_favorite_count_when_toggle_removes_relation() {
|
void should_decrement_favorite_count_when_toggle_removes_relation() {
|
||||||
given(favoriteMapper.selectOne(any())).willReturn(new UserFavorite());
|
given(favoriteMapper.selectOne(any())).willReturn(new UserFavorite());
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ public enum ErrorCode {
|
|||||||
*/
|
*/
|
||||||
CREATOR_NOT_BOUND(200004, "当前账号未绑定创作者主体"),
|
CREATOR_NOT_BOUND(200004, "当前账号未绑定创作者主体"),
|
||||||
CREATOR_TARGET_REQUIRED(200005, "请先选择要代看的创作者"),
|
CREATOR_TARGET_REQUIRED(200005, "请先选择要代看的创作者"),
|
||||||
CREATOR_IMPERSONATE_READONLY(200006, "代看模式下只读,不能修改创作者数据");
|
CREATOR_IMPERSONATE_READONLY(200006, "代看模式下只读,不能修改创作者数据"),
|
||||||
|
|
||||||
|
INTERACTION_IDENTITY_REQUIRED(200007, "缺少登录身份或设备标识,请重新登录");
|
||||||
|
|
||||||
private final int code;
|
private final int code;
|
||||||
private final String message;
|
private final String message;
|
||||||
|
|||||||
+11
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -45,6 +45,7 @@ public class SecurityConfig {
|
|||||||
"/app/dramas/**",
|
"/app/dramas/**",
|
||||||
"/app/search",
|
"/app/search",
|
||||||
"/app/episodes/*/play",
|
"/app/episodes/*/play",
|
||||||
|
"/app/episodes/*/stats",
|
||||||
"/app/config/**",
|
"/app/config/**",
|
||||||
/* 启动配置与公告:客户端登录前就要拿(POST /app/creator/apply 等仍要登录) */
|
/* 启动配置与公告:客户端登录前就要拿(POST /app/creator/apply 等仍要登录) */
|
||||||
"/app/config",
|
"/app/config",
|
||||||
@@ -80,7 +81,12 @@ public class SecurityConfig {
|
|||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(registry -> registry
|
.authorizeHttpRequests(registry -> registry
|
||||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
.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").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/app/comments/count").permitAll()
|
.requestMatchers(HttpMethod.GET, "/app/comments/count").permitAll()
|
||||||
.requestMatchers(PUBLIC_PATHS).permitAll()
|
.requestMatchers(PUBLIC_PATHS).permitAll()
|
||||||
|
|||||||
+1
@@ -18,6 +18,7 @@ public class ContentLike {
|
|||||||
@TableId(type = IdType.ASSIGN_ID)
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
private Long id;
|
private Long id;
|
||||||
private Long userId;
|
private Long userId;
|
||||||
|
private String deviceId;
|
||||||
/** DRAMA/EPISODE/COMMENT */
|
/** DRAMA/EPISODE/COMMENT */
|
||||||
private String targetType;
|
private String targetType;
|
||||||
private Long targetId;
|
private Long targetId;
|
||||||
|
|||||||
+4
@@ -4,6 +4,7 @@ package com.fruitshorts.interaction.dal.mapper;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.fruitshorts.interaction.dal.entity.ContentComment;
|
import com.fruitshorts.interaction.dal.entity.ContentComment;
|
||||||
|
import org.apache.ibatis.annotations.Delete;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
@@ -12,6 +13,9 @@ import java.util.List;
|
|||||||
|
|
||||||
public interface ContentCommentMapper extends BaseMapper<ContentComment> {
|
public interface ContentCommentMapper extends BaseMapper<ContentComment> {
|
||||||
|
|
||||||
|
@Delete("DELETE FROM content_comment WHERE id = #{id}")
|
||||||
|
int physicalDeleteById(@Param("id") Long id);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台举报工单按评论 id 批量读取,包含逻辑删除记录。
|
* 后台举报工单按评论 id 批量读取,包含逻辑删除记录。
|
||||||
*
|
*
|
||||||
|
|||||||
+9
@@ -20,4 +20,13 @@ public interface ContentLikeMapper extends BaseMapper<ContentLike> {
|
|||||||
+ "AND target_id = #{targetId}")
|
+ "AND target_id = #{targetId}")
|
||||||
int physicalDelete(@Param("userId") Long userId, @Param("targetType") String targetType,
|
int physicalDelete(@Param("userId") Long userId, @Param("targetType") String targetType,
|
||||||
@Param("targetId") Long targetId);
|
@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);
|
||||||
}
|
}
|
||||||
+158
-23
@@ -9,6 +9,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
|||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.fruitshorts.common.exception.ErrorCode;
|
import com.fruitshorts.common.exception.ErrorCode;
|
||||||
import com.fruitshorts.common.exception.ServiceException;
|
import com.fruitshorts.common.exception.ServiceException;
|
||||||
|
import com.fruitshorts.common.interaction.InteractionIdentity;
|
||||||
import com.fruitshorts.common.pojo.CursorResult;
|
import com.fruitshorts.common.pojo.CursorResult;
|
||||||
import com.fruitshorts.common.pojo.PageResult;
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
import com.fruitshorts.content.dal.entity.Drama;
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
@@ -78,6 +79,11 @@ public class CommentService {
|
|||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public CommentRespVO publish(Long userId, CommentPublishReqVO req, String ip, String deviceId) {
|
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());
|
dramaService.getPublished(req.getDramaId());
|
||||||
if (req.getEpisodeId() != null) {
|
if (req.getEpisodeId() != null) {
|
||||||
episodeService.getPublished(req.getEpisodeId());
|
episodeService.getPublished(req.getEpisodeId());
|
||||||
@@ -90,7 +96,8 @@ public class CommentService {
|
|||||||
throw new ServiceException(ErrorCode.BAD_REQUEST,
|
throw new ServiceException(ErrorCode.BAD_REQUEST,
|
||||||
"评论最多 " + properties.getCommentMaxLength() + " 字");
|
"评论最多 " + properties.getCommentMaxLength() + " 字");
|
||||||
}
|
}
|
||||||
checkRate(userId);
|
checkRate(identity);
|
||||||
|
checkDuplicate(identity, req, text);
|
||||||
|
|
||||||
SensitiveWordCheck check = sensitiveWordService.check(text);
|
SensitiveWordCheck check = sensitiveWordService.check(text);
|
||||||
if (check.blocked()) {
|
if (check.blocked()) {
|
||||||
@@ -110,7 +117,7 @@ public class CommentService {
|
|||||||
ContentComment comment = new ContentComment();
|
ContentComment comment = new ContentComment();
|
||||||
comment.setDramaId(req.getDramaId());
|
comment.setDramaId(req.getDramaId());
|
||||||
comment.setEpisodeId(req.getEpisodeId() == null ? 0L : req.getEpisodeId());
|
comment.setEpisodeId(req.getEpisodeId() == null ? 0L : req.getEpisodeId());
|
||||||
comment.setUserId(userId);
|
comment.setUserId(identity.userId());
|
||||||
comment.setParentId(parent == null ? 0L : parent.getId());
|
comment.setParentId(parent == null ? 0L : parent.getId());
|
||||||
comment.setRootId(resolveRootId(parent));
|
comment.setRootId(resolveRootId(parent));
|
||||||
comment.setContent(check.sanitized());
|
comment.setContent(check.sanitized());
|
||||||
@@ -121,7 +128,7 @@ public class CommentService {
|
|||||||
comment.setReplyCount(0);
|
comment.setReplyCount(0);
|
||||||
comment.setReportCount(0);
|
comment.setReportCount(0);
|
||||||
comment.setIp(ip == null ? "" : ip);
|
comment.setIp(ip == null ? "" : ip);
|
||||||
comment.setDeviceId(deviceId == null ? "" : deviceId);
|
comment.setDeviceId(identity.authenticated() ? "" : identity.deviceId());
|
||||||
comment.setCountryCode("");
|
comment.setCountryCode("");
|
||||||
commentMapper.insert(comment);
|
commentMapper.insert(comment);
|
||||||
|
|
||||||
@@ -134,13 +141,22 @@ public class CommentService {
|
|||||||
.eq(ContentComment::getId, parent.getId())
|
.eq(ContentComment::getId, parent.getId())
|
||||||
.setSql("reply_count = reply_count + 1"));
|
.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 则看该楼层的回复。
|
* 游标翻页。unsetRootId 表示只看顶层评论;传 rootId 则看该楼层的回复。
|
||||||
*/
|
*/
|
||||||
public CursorResult<CommentRespVO> page(Long meUserId, CommentQueryReqVO req) {
|
public CursorResult<CommentRespVO> page(Long meUserId, CommentQueryReqVO req) {
|
||||||
|
return page(meUserId, "", req);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CursorResult<CommentRespVO> page(
|
||||||
|
Long meUserId, String meDeviceId, CommentQueryReqVO req) {
|
||||||
if (req.getDramaId() == null) {
|
if (req.getDramaId() == null) {
|
||||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "缺少 dramaId");
|
throw new ServiceException(ErrorCode.BAD_REQUEST, "缺少 dramaId");
|
||||||
}
|
}
|
||||||
@@ -151,8 +167,13 @@ public class CommentService {
|
|||||||
.eq(req.getEpisodeId() != null, ContentComment::getEpisodeId, req.getEpisodeId())
|
.eq(req.getEpisodeId() != null, ContentComment::getEpisodeId, req.getEpisodeId())
|
||||||
.eq(ContentComment::getRootId, rootFilter)
|
.eq(ContentComment::getRootId, rootFilter)
|
||||||
.lt(req.getCursor() != null, ContentComment::getId, req.getCursor());
|
.lt(req.getCursor() != null, ContentComment::getId, req.getCursor());
|
||||||
if (meUserId == null) {
|
if (meUserId == null && (meDeviceId == null || meDeviceId.isBlank())) {
|
||||||
wrapper.eq(ContentComment::getStatus, STATUS_PUBLISHED);
|
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 {
|
} else {
|
||||||
// 已发布 + 自己待审核的
|
// 已发布 + 自己待审核的
|
||||||
wrapper.and(w -> w.eq(ContentComment::getStatus, STATUS_PUBLISHED)
|
wrapper.and(w -> w.eq(ContentComment::getStatus, STATUS_PUBLISHED)
|
||||||
@@ -166,13 +187,25 @@ public class CommentService {
|
|||||||
rows = rows.subList(0, size);
|
rows = rows.subList(0, size);
|
||||||
}
|
}
|
||||||
Long nextCursor = rows.isEmpty() ? null : rows.get(rows.size() - 1).getId();
|
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)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void delete(Long userId, Long commentId) {
|
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);
|
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, "只能删除自己的评论");
|
throw new ServiceException(ErrorCode.FORBIDDEN, "只能删除自己的评论");
|
||||||
}
|
}
|
||||||
softDelete(comment);
|
softDelete(comment);
|
||||||
@@ -278,7 +311,7 @@ public class CommentService {
|
|||||||
req.getKeyword())
|
req.getKeyword())
|
||||||
.orderByDesc(ContentComment::getId));
|
.orderByDesc(ContentComment::getId));
|
||||||
List<ContentComment> rows = result.getRecords();
|
List<ContentComment> rows = result.getRecords();
|
||||||
List<CommentRespVO> list = toRespList(rows, null);
|
List<CommentRespVO> list = toRespList(rows, new InteractionIdentity(null, ""));
|
||||||
fillAdminContentInfo(rows, list);
|
fillAdminContentInfo(rows, list);
|
||||||
return new PageResult<>(result.getTotal(), list);
|
return new PageResult<>(result.getTotal(), list);
|
||||||
}
|
}
|
||||||
@@ -349,15 +382,87 @@ public class CommentService {
|
|||||||
commentMapper.updateById(comment);
|
commentMapper.updateById(comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkRate(Long userId) {
|
private void checkRate(InteractionIdentity identity) {
|
||||||
Long recent = commentMapper.selectCount(Wrappers.<ContentComment>lambdaQuery()
|
var query = Wrappers.<ContentComment>lambdaQuery()
|
||||||
.eq(ContentComment::getUserId, userId)
|
.ge(ContentComment::getCreatedAt, LocalDateTime.now().minusMinutes(1));
|
||||||
.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()) {
|
if (recent != null && recent >= properties.getCommentPerMinute()) {
|
||||||
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "发言过于频繁,请稍后再试");
|
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "发言过于频繁,请稍后再试");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void checkDuplicate(
|
||||||
|
InteractionIdentity identity, CommentPublishReqVO req, String text) {
|
||||||
|
var query = Wrappers.<ContentComment>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<ContentComment> deviceComments = commentMapper.selectList(
|
||||||
|
Wrappers.<ContentComment>lambdaQuery()
|
||||||
|
.isNull(ContentComment::getUserId)
|
||||||
|
.eq(ContentComment::getDeviceId, deviceId));
|
||||||
|
int migrated = 0;
|
||||||
|
int duplicates = 0;
|
||||||
|
for (ContentComment deviceComment : deviceComments) {
|
||||||
|
ContentComment existing = commentMapper.selectOne(
|
||||||
|
Wrappers.<ContentComment>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.<ContentComment>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) {
|
private Long resolveRootId(ContentComment parent) {
|
||||||
if (parent == null) {
|
if (parent == null) {
|
||||||
return 0L;
|
return 0L;
|
||||||
@@ -371,27 +476,45 @@ public class CommentService {
|
|||||||
return Math.min(value, properties.getPageMaxSize());
|
return Math.min(value, properties.getPageMaxSize());
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<CommentRespVO> toRespList(List<ContentComment> rows, Long meUserId) {
|
private List<CommentRespVO> toRespList(
|
||||||
|
List<ContentComment> rows, InteractionIdentity identity) {
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
Map<Long, AppUser> users = new HashMap<>();
|
Map<Long, AppUser> users = new HashMap<>();
|
||||||
List<Long> userIds = rows.stream().map(ContentComment::getUserId).distinct().toList();
|
List<Long> userIds = rows.stream()
|
||||||
for (AppUser user : appUserMapper.selectBatchIds(userIds)) {
|
.map(ContentComment::getUserId)
|
||||||
users.put(user.getId(), user);
|
.filter(Objects::nonNull)
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
if (!userIds.isEmpty()) {
|
||||||
|
for (AppUser user : appUserMapper.selectBatchIds(userIds)) {
|
||||||
|
users.put(user.getId(), user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Set<Long> likedIds = new HashSet<>();
|
Set<Long> likedIds = new HashSet<>();
|
||||||
if (meUserId != null) {
|
if (identity.userId() != null || StringUtils.hasText(identity.deviceId())) {
|
||||||
for (ContentLike like : likeMapper.selectList(Wrappers.<ContentLike>lambdaQuery()
|
var query = Wrappers.<ContentLike>lambdaQuery()
|
||||||
.eq(ContentLike::getUserId, meUserId)
|
|
||||||
.eq(ContentLike::getTargetType, "COMMENT")
|
.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());
|
likedIds.add(like.getTargetId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
List<CommentRespVO> list = new ArrayList<>(rows.size());
|
List<CommentRespVO> list = new ArrayList<>(rows.size());
|
||||||
for (ContentComment row : rows) {
|
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;
|
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();
|
CommentRespVO vo = new CommentRespVO();
|
||||||
vo.setId(comment.getId());
|
vo.setId(comment.getId());
|
||||||
vo.setDramaId(comment.getDramaId());
|
vo.setDramaId(comment.getDramaId());
|
||||||
@@ -444,12 +571,20 @@ public class CommentService {
|
|||||||
vo.setReplyCount(comment.getReplyCount());
|
vo.setReplyCount(comment.getReplyCount());
|
||||||
vo.setReportCount(comment.getReportCount());
|
vo.setReportCount(comment.getReportCount());
|
||||||
vo.setCreatedAt(comment.getCreatedAt());
|
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);
|
vo.setLiked(liked);
|
||||||
if (author != null) {
|
if (author != null) {
|
||||||
vo.setUid(author.getUid());
|
vo.setUid(author.getUid());
|
||||||
vo.setNickname(author.getNickname());
|
vo.setNickname(author.getNickname());
|
||||||
vo.setAvatarUrl(author.getAvatarUrl());
|
vo.setAvatarUrl(author.getAvatarUrl());
|
||||||
|
} else {
|
||||||
|
vo.setUid("");
|
||||||
|
vo.setNickname("游客");
|
||||||
|
vo.setAvatarUrl("");
|
||||||
}
|
}
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-7
@@ -5,6 +5,7 @@ package com.fruitshorts.interaction.service;
|
|||||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
import com.fruitshorts.common.exception.ErrorCode;
|
import com.fruitshorts.common.exception.ErrorCode;
|
||||||
import com.fruitshorts.common.exception.ServiceException;
|
import com.fruitshorts.common.exception.ServiceException;
|
||||||
|
import com.fruitshorts.common.interaction.InteractionIdentity;
|
||||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
import com.fruitshorts.content.service.EpisodeService;
|
import com.fruitshorts.content.service.EpisodeService;
|
||||||
@@ -20,6 +21,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@@ -44,6 +46,11 @@ public class LikeService {
|
|||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public LikeToggleRespVO toggle(Long userId, LikeToggleReqVO req) {
|
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);
|
String type = req.getTargetType().trim().toUpperCase(Locale.ROOT);
|
||||||
if (!TYPE_DRAMA.equals(type) && !TYPE_EPISODE.equals(type) && !TYPE_COMMENT.equals(type)) {
|
if (!TYPE_DRAMA.equals(type) && !TYPE_EPISODE.equals(type) && !TYPE_COMMENT.equals(type)) {
|
||||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "只支持对短剧、剧集或评论点赞");
|
throw new ServiceException(ErrorCode.BAD_REQUEST, "只支持对短剧、剧集或评论点赞");
|
||||||
@@ -51,22 +58,26 @@ public class LikeService {
|
|||||||
Long targetId = req.getTargetId();
|
Long targetId = req.getTargetId();
|
||||||
validateTarget(type, targetId);
|
validateTarget(type, targetId);
|
||||||
|
|
||||||
ContentLike existing = likeMapper.selectOne(Wrappers.<ContentLike>lambdaQuery()
|
ContentLike existing = likeMapper.selectOne(identityQuery(identity)
|
||||||
.eq(ContentLike::getUserId, userId)
|
|
||||||
.eq(ContentLike::getTargetType, type)
|
.eq(ContentLike::getTargetType, type)
|
||||||
.eq(ContentLike::getTargetId, targetId)
|
.eq(ContentLike::getTargetId, targetId)
|
||||||
.last("limit 1"));
|
.last("limit 1"));
|
||||||
boolean liked;
|
boolean liked;
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
ContentLike like = new ContentLike();
|
ContentLike like = new ContentLike();
|
||||||
like.setUserId(userId);
|
like.setUserId(identity.userId());
|
||||||
|
like.setDeviceId(identity.authenticated() ? "" : identity.deviceId());
|
||||||
like.setTargetType(type);
|
like.setTargetType(type);
|
||||||
like.setTargetId(targetId);
|
like.setTargetId(targetId);
|
||||||
likeMapper.insert(like);
|
likeMapper.insert(like);
|
||||||
liked = true;
|
liked = true;
|
||||||
adjustCount(type, targetId, 1);
|
adjustCount(type, targetId, 1);
|
||||||
} else {
|
} else {
|
||||||
likeMapper.physicalDelete(userId, type, targetId);
|
if (identity.authenticated()) {
|
||||||
|
likeMapper.physicalDelete(identity.userId(), type, targetId);
|
||||||
|
} else {
|
||||||
|
likeMapper.physicalDeleteDevice(identity.deviceId(), type, targetId);
|
||||||
|
}
|
||||||
liked = false;
|
liked = false;
|
||||||
adjustCount(type, targetId, -1);
|
adjustCount(type, targetId, -1);
|
||||||
}
|
}
|
||||||
@@ -91,7 +102,13 @@ public class LikeService {
|
|||||||
|
|
||||||
/** 批量判断用户是否已点赞,一次查询覆盖多个目标。 */
|
/** 批量判断用户是否已点赞,一次查询覆盖多个目标。 */
|
||||||
public Set<Long> likedTargetIds(Long userId, String type, Collection<Long> targetIds) {
|
public Set<Long> likedTargetIds(Long userId, String type, Collection<Long> targetIds) {
|
||||||
if (userId == null || targetIds == null || targetIds.isEmpty()) {
|
return likedTargetIds(userId, "", type, targetIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录用户优先;匿名状态按设备号查询。 */
|
||||||
|
public Set<Long> likedTargetIds(Long userId, String deviceId, String type,
|
||||||
|
Collection<Long> targetIds) {
|
||||||
|
if (targetIds == null || targetIds.isEmpty()) {
|
||||||
return Set.of();
|
return Set.of();
|
||||||
}
|
}
|
||||||
Set<Long> normalized = new HashSet<>();
|
Set<Long> normalized = new HashSet<>();
|
||||||
@@ -103,8 +120,10 @@ public class LikeService {
|
|||||||
if (normalized.isEmpty()) {
|
if (normalized.isEmpty()) {
|
||||||
return Set.of();
|
return Set.of();
|
||||||
}
|
}
|
||||||
return likeMapper.selectList(Wrappers.<ContentLike>lambdaQuery()
|
if (userId == null && (deviceId == null || deviceId.isBlank())) {
|
||||||
.eq(ContentLike::getUserId, userId)
|
return Set.of();
|
||||||
|
}
|
||||||
|
return likeMapper.selectList(identityQuery(new InteractionIdentity(userId, deviceId))
|
||||||
.eq(ContentLike::getTargetType, type)
|
.eq(ContentLike::getTargetType, type)
|
||||||
.in(ContentLike::getTargetId, normalized))
|
.in(ContentLike::getTargetId, normalized))
|
||||||
.stream()
|
.stream()
|
||||||
@@ -112,6 +131,52 @@ public class LikeService {
|
|||||||
.collect(java.util.stream.Collectors.toSet());
|
.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<ContentLike> deviceLikes = likeMapper.selectList(
|
||||||
|
Wrappers.<ContentLike>lambdaQuery()
|
||||||
|
.isNull(ContentLike::getUserId)
|
||||||
|
.eq(ContentLike::getDeviceId, deviceId));
|
||||||
|
int migrated = 0;
|
||||||
|
int duplicates = 0;
|
||||||
|
for (ContentLike deviceLike : deviceLikes) {
|
||||||
|
ContentLike existing = likeMapper.selectOne(Wrappers.<ContentLike>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<ContentLike>
|
||||||
|
identityQuery(InteractionIdentity identity) {
|
||||||
|
if (identity.authenticated()) {
|
||||||
|
return Wrappers.<ContentLike>lambdaQuery()
|
||||||
|
.eq(ContentLike::getUserId, identity.userId());
|
||||||
|
}
|
||||||
|
return Wrappers.<ContentLike>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) {
|
private void adjustCount(String type, Long targetId, int delta) {
|
||||||
if (TYPE_DRAMA.equals(type)) {
|
if (TYPE_DRAMA.equals(type)) {
|
||||||
dramaService.adjustLikeCount(targetId, delta);
|
dramaService.adjustLikeCount(targetId, delta);
|
||||||
|
|||||||
+3
@@ -20,4 +20,7 @@ public class CommentPublishReqVO {
|
|||||||
|
|
||||||
/** 回复某条评论时传它的 id;顶层评论不传 */
|
/** 回复某条评论时传它的 id;顶层评论不传 */
|
||||||
private Long parentId;
|
private Long parentId;
|
||||||
|
|
||||||
|
/** 兼容旧调用;正常客户端通过 X-Device-Id 下发。 */
|
||||||
|
private String deviceId;
|
||||||
}
|
}
|
||||||
+3
@@ -16,4 +16,7 @@ public class LikeToggleReqVO {
|
|||||||
|
|
||||||
@NotNull(message = "缺少 targetId")
|
@NotNull(message = "缺少 targetId")
|
||||||
private Long targetId;
|
private Long targetId;
|
||||||
|
|
||||||
|
/** 兼容旧调用;正常客户端通过 X-Device-Id 下发。 */
|
||||||
|
private String deviceId;
|
||||||
}
|
}
|
||||||
+1
@@ -20,6 +20,7 @@ public class UserFavorite {
|
|||||||
@TableId(type = IdType.ASSIGN_ID)
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
private Long id;
|
private Long id;
|
||||||
private Long userId;
|
private Long userId;
|
||||||
|
private String deviceId;
|
||||||
private Long dramaId;
|
private Long dramaId;
|
||||||
private Integer sort;
|
private Integer sort;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|||||||
+7
@@ -17,4 +17,11 @@ public interface UserFavoriteMapper extends BaseMapper<UserFavorite> {
|
|||||||
*/
|
*/
|
||||||
@Delete("DELETE FROM user_favorite WHERE user_id = #{userId} AND drama_id = #{dramaId}")
|
@Delete("DELETE FROM user_favorite WHERE user_id = #{userId} AND drama_id = #{dramaId}")
|
||||||
int physicalDelete(@Param("userId") Long userId, @Param("dramaId") Long 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);
|
||||||
}
|
}
|
||||||
+77
-7
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
|
|||||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.fruitshorts.common.pojo.PageResult;
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
|
import com.fruitshorts.common.interaction.InteractionIdentity;
|
||||||
import com.fruitshorts.content.dal.entity.Drama;
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
import com.fruitshorts.content.enums.ContentStatus;
|
import com.fruitshorts.content.enums.ContentStatus;
|
||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
@@ -37,21 +38,28 @@ public class FavoriteService {
|
|||||||
/** 返回切换后的状态:true 表示已收藏。 */
|
/** 返回切换后的状态:true 表示已收藏。 */
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public boolean toggle(Long userId, Long dramaId) {
|
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);
|
dramaService.getPublished(dramaId);
|
||||||
UserFavorite existing = favoriteMapper.selectOne(Wrappers.<UserFavorite>lambdaQuery()
|
UserFavorite existing = favoriteMapper.selectOne(identityQuery(identity)
|
||||||
.eq(UserFavorite::getUserId, userId)
|
|
||||||
.eq(UserFavorite::getDramaId, dramaId)
|
.eq(UserFavorite::getDramaId, dramaId)
|
||||||
.last("limit 1"));
|
.last("limit 1"));
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
// 必须物理删除:唯一索引 (user_id, drama_id) 会挡住"取消后再收藏"
|
// 必须物理删除:唯一索引 (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) {
|
if (removed > 0) {
|
||||||
dramaService.adjustFavoriteCount(dramaId, -1);
|
dramaService.adjustFavoriteCount(dramaId, -1);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
UserFavorite favorite = new UserFavorite();
|
UserFavorite favorite = new UserFavorite();
|
||||||
favorite.setUserId(userId);
|
favorite.setUserId(identity.userId());
|
||||||
|
favorite.setDeviceId(identity.authenticated() ? "" : identity.deviceId());
|
||||||
favorite.setDramaId(dramaId);
|
favorite.setDramaId(dramaId);
|
||||||
favorite.setSort(0);
|
favorite.setSort(0);
|
||||||
favoriteMapper.insert(favorite);
|
favoriteMapper.insert(favorite);
|
||||||
@@ -68,7 +76,13 @@ public class FavoriteService {
|
|||||||
|
|
||||||
/** 批量判断用户是否已收藏,一次查询覆盖多部剧。 */
|
/** 批量判断用户是否已收藏,一次查询覆盖多部剧。 */
|
||||||
public Set<Long> favoriteDramaIds(Long userId, Collection<Long> dramaIds) {
|
public Set<Long> favoriteDramaIds(Long userId, Collection<Long> dramaIds) {
|
||||||
if (userId == null || dramaIds == null || dramaIds.isEmpty()) {
|
return favoriteDramaIds(userId, "", dramaIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录用户优先;匿名状态按设备号查询。 */
|
||||||
|
public Set<Long> favoriteDramaIds(Long userId, String deviceId,
|
||||||
|
Collection<Long> dramaIds) {
|
||||||
|
if (dramaIds == null || dramaIds.isEmpty()) {
|
||||||
return Set.of();
|
return Set.of();
|
||||||
}
|
}
|
||||||
Set<Long> normalized = new HashSet<>();
|
Set<Long> normalized = new HashSet<>();
|
||||||
@@ -80,14 +94,62 @@ public class FavoriteService {
|
|||||||
if (normalized.isEmpty()) {
|
if (normalized.isEmpty()) {
|
||||||
return Set.of();
|
return Set.of();
|
||||||
}
|
}
|
||||||
return favoriteMapper.selectList(Wrappers.<UserFavorite>lambdaQuery()
|
if (userId == null && (deviceId == null || deviceId.isBlank())) {
|
||||||
.eq(UserFavorite::getUserId, userId)
|
return Set.of();
|
||||||
|
}
|
||||||
|
return favoriteMapper.selectList(identityQuery(new InteractionIdentity(userId, deviceId))
|
||||||
.in(UserFavorite::getDramaId, normalized))
|
.in(UserFavorite::getDramaId, normalized))
|
||||||
.stream()
|
.stream()
|
||||||
.map(UserFavorite::getDramaId)
|
.map(UserFavorite::getDramaId)
|
||||||
.collect(java.util.stream.Collectors.toSet());
|
.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<UserFavorite> deviceFavorites = favoriteMapper.selectList(
|
||||||
|
Wrappers.<UserFavorite>lambdaQuery()
|
||||||
|
.isNull(UserFavorite::getUserId)
|
||||||
|
.eq(UserFavorite::getDeviceId, deviceId));
|
||||||
|
int migrated = 0;
|
||||||
|
int duplicates = 0;
|
||||||
|
for (UserFavorite deviceFavorite : deviceFavorites) {
|
||||||
|
UserFavorite existing = favoriteMapper.selectOne(
|
||||||
|
Wrappers.<UserFavorite>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<UserFavorite>
|
||||||
|
identityQuery(InteractionIdentity identity) {
|
||||||
|
if (identity.authenticated()) {
|
||||||
|
return Wrappers.<UserFavorite>lambdaQuery()
|
||||||
|
.eq(UserFavorite::getUserId, identity.userId());
|
||||||
|
}
|
||||||
|
return Wrappers.<UserFavorite>lambdaQuery()
|
||||||
|
.isNull(UserFavorite::getUserId)
|
||||||
|
.eq(UserFavorite::getDeviceId, identity.deviceId());
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MergeResult(int migrated, int duplicates) {
|
||||||
|
}
|
||||||
|
|
||||||
/** 分页查询收藏短剧,保持收藏时间与 dramaId 稳定排序。 */
|
/** 分页查询收藏短剧,保持收藏时间与 dramaId 稳定排序。 */
|
||||||
public PageResult<Drama> page(Long userId, int pageIndex, int pageSize) {
|
public PageResult<Drama> page(Long userId, int pageIndex, int pageSize) {
|
||||||
int safeIndex = Math.max(pageIndex, 1);
|
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)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void removeBatch(Long userId, List<Long> dramaIds) {
|
public void removeBatch(Long userId, List<Long> dramaIds) {
|
||||||
if (dramaIds == null || dramaIds.isEmpty()) {
|
if (dramaIds == null || dramaIds.isEmpty()) {
|
||||||
|
|||||||
+6
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
package com.fruitshorts.playback.service;
|
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.Drama;
|
||||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
@@ -57,6 +59,10 @@ public class PlaybackService {
|
|||||||
|
|
||||||
boolean free = episode.getIsFree() != null && episode.getIsFree() == 1;
|
boolean free = episode.getIsFree() != null && episode.getIsFree() == 1;
|
||||||
boolean entitled = userId != null && entitlementPort.isUnlocked(userId, drama.getId(), episode.getId());
|
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);
|
String source = playableSource(episode);
|
||||||
|
|
||||||
PlayRespVO vo = new PlayRespVO();
|
PlayRespVO vo = new PlayRespVO();
|
||||||
|
|||||||
+3
@@ -11,4 +11,7 @@ public class FavoriteToggleReqVO {
|
|||||||
|
|
||||||
@NotNull(message = "缺少 dramaId")
|
@NotNull(message = "缺少 dramaId")
|
||||||
private Long dramaId;
|
private Long dramaId;
|
||||||
|
|
||||||
|
/** 兼容旧调用;正常客户端通过 X-Device-Id 下发。 */
|
||||||
|
private String deviceId;
|
||||||
}
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.user.event;
|
||||||
|
|
||||||
|
/** 登录/注册成功后,在同一事务内触发设备互动合并。 */
|
||||||
|
public record AppInteractionMergeRequested(Long userId, String deviceId) {
|
||||||
|
}
|
||||||
+19
-4
@@ -25,8 +25,10 @@ import com.fruitshorts.user.vo.LoginReqVO;
|
|||||||
import com.fruitshorts.user.vo.LogoutReqVO;
|
import com.fruitshorts.user.vo.LogoutReqVO;
|
||||||
import com.fruitshorts.user.vo.RegisterReqVO;
|
import com.fruitshorts.user.vo.RegisterReqVO;
|
||||||
import com.fruitshorts.user.vo.TokenRespVO;
|
import com.fruitshorts.user.vo.TokenRespVO;
|
||||||
|
import com.fruitshorts.user.event.AppInteractionMergeRequested;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -63,6 +65,7 @@ public class AppAuthService {
|
|||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final ConfigService configService;
|
private final ConfigService configService;
|
||||||
private final GoogleIdTokenVerifier googleVerifier;
|
private final GoogleIdTokenVerifier googleVerifier;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
/** 后台 sys_config 缺失或非法时使用;小于等于 0 表示不限制。 */
|
/** 后台 sys_config 缺失或非法时使用;小于等于 0 表示不限制。 */
|
||||||
@Value("${fruitshorts.auth.register-max-per-ip-per-day:5}")
|
@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(),
|
TokenRespVO resp = issueTokens(user, credential, req.getDeviceId(), req.getDeviceModel(),
|
||||||
req.getOsVersion(), req.getAppVersion(), req.getPlatform(), clientIp);
|
req.getOsVersion(), req.getAppVersion(), req.getPlatform(), clientIp);
|
||||||
resp.setNewUser(true);
|
resp.setNewUser(true);
|
||||||
|
requestInteractionMerge(user.getId(), req.getDeviceId());
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,8 +181,10 @@ public class AppAuthService {
|
|||||||
appUserMapper.updateById(user);
|
appUserMapper.updateById(user);
|
||||||
touchDevice(user.getId(), req.getDeviceId(), req.getPlatform(), req.getDeviceModel());
|
touchDevice(user.getId(), req.getDeviceId(), req.getPlatform(), req.getDeviceModel());
|
||||||
|
|
||||||
return issueTokens(user, credential, req.getDeviceId(), req.getDeviceModel(), req.getOsVersion(),
|
TokenRespVO resp = issueTokens(user, credential, req.getDeviceId(), req.getDeviceModel(),
|
||||||
req.getAppVersion(), req.getPlatform(), clientIp);
|
req.getOsVersion(), req.getAppVersion(), req.getPlatform(), clientIp);
|
||||||
|
requestInteractionMerge(user.getId(), req.getDeviceId());
|
||||||
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -188,7 +194,7 @@ public class AppAuthService {
|
|||||||
* 再落一条 GOOGLE 凭证用于下次直接登录;已有邮箱账号时只补 GOOGLE 凭证,不重复建号。
|
* 再落一条 GOOGLE 凭证用于下次直接登录;已有邮箱账号时只补 GOOGLE 凭证,不重复建号。
|
||||||
*/
|
*/
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public TokenRespVO googleLogin(String idToken) {
|
public TokenRespVO googleLogin(String idToken, String deviceId) {
|
||||||
GoogleUser googleUser = googleVerifier.verify(idToken);
|
GoogleUser googleUser = googleVerifier.verify(idToken);
|
||||||
String email = requireEmail(googleUser);
|
String email = requireEmail(googleUser);
|
||||||
AppUserCredential googleCredential = findCredential(CredentialType.GOOGLE.name(), email);
|
AppUserCredential googleCredential = findCredential(CredentialType.GOOGLE.name(), email);
|
||||||
@@ -215,8 +221,10 @@ public class AppAuthService {
|
|||||||
user.setLastLoginAt(LocalDateTime.now());
|
user.setLastLoginAt(LocalDateTime.now());
|
||||||
user.setLastLoginIp("");
|
user.setLastLoginIp("");
|
||||||
appUserMapper.updateById(user);
|
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);
|
resp.setNewUser(newUser);
|
||||||
|
requestInteractionMerge(user.getId(), deviceId);
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,6 +444,13 @@ public class AppAuthService {
|
|||||||
return resp;
|
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_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";
|
private static final String REGISTER_DEVICE_LIMIT_KEY = "app.register.max-per-device-per-day";
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
Reference in new issue
Block a user