feat: phase1 UI prep — P1 安全(XFF防护), P2 规模化(平台索引+region保护), P3 文档补全

P1 安全: WebUtils.getClientIp 改为 remoteAddr 优先 + 可信代理 CIDR 配置
P2 规模化: DISTINCT ON 查询补匹配排序的部分索引 (migrate-k113)
P2 一致性: syncCountryByIp regionCode 同语言/时区/币种加保护,不覆盖用户自定义
P3 文档: api-phase1.md 新增 platform 参数说明, IpRegionService 新增集成测试 + VERSION
新增: CountryProfile 52 国映射 + IpRegionService 离线 ip2region 解析
平台筛选: app-users/favorites/likes/watch-history 按最近一次会话 ANDROID/IOS 筛选
This commit is contained in:
Vicky authored and trae-solo-agent committed 2026-10-27 15:45:15 +08:00
1 parent a41ab18636
commit 272aa62434
34 files changed
+748 -77

No files matched your search

@@ -35,13 +35,14 @@ public class AppAuthController {
private final AppOtpService otpService;
private final AppAuthService authService;
private final WebUtils webUtils;
/** 发送验证码。频控在服务端,客户端不需要自己限流。 */
@Operation(summary = "发送验证码(频控在服务端)")
@RateLimit(limit = 20, windowSeconds = 60, key = "otp")
@PostMapping("/otp")
public Result<Void> sendOtp(@Valid @RequestBody OtpSendReqVO req, HttpServletRequest request) {
otpService.send(req, WebUtils.getClientIp(request));
otpService.send(req, webUtils.clientIp(request));
return Result.ok();
}
@@ -50,7 +51,7 @@ public class AppAuthController {
public Result<TokenRespVO> register(@Valid @RequestBody RegisterReqVO req,
HttpServletRequest request) {
applyHeaderDeviceId(req, request);
return Result.ok(authService.register(req, WebUtils.getClientIp(request)));
return Result.ok(authService.register(req, webUtils.clientIp(request)));
}
@Operation(summary = "登录(验证码或密码)")
@@ -59,7 +60,7 @@ public class AppAuthController {
public Result<TokenRespVO> login(@Valid @RequestBody LoginReqVO req,
HttpServletRequest request) {
applyHeaderDeviceId(req, request);
return Result.ok(authService.login(req, WebUtils.getClientIp(request)));
return Result.ok(authService.login(req, webUtils.clientIp(request)));
}
@Operation(summary = "Google 登录(未配置 clientId 时返回明确错误)")
@@ -69,7 +70,8 @@ public class AppAuthController {
String headerDeviceId = request.getHeader("X-Device-Id");
String deviceId = StringUtils.hasText(headerDeviceId)
? headerDeviceId : req.getDeviceId();
return Result.ok(authService.googleLogin(req.getIdToken(), deviceId));
return Result.ok(authService.googleLogin(req.getIdToken(), deviceId,
webUtils.clientIp(request)));
}
@Operation(summary = "刷新令牌(旧 refresh token 立即失效)")
@@ -48,6 +48,7 @@ public class AppCommentController {
private final EpisodeService episodeService;
private final InteractionIdentityResolver identityResolver;
private final AnonymousInteractionRateLimiter anonymousRateLimiter;
private final WebUtils webUtils;
@Operation(summary = "发布评论")
@PostMapping
@@ -59,7 +60,7 @@ public class AppCommentController {
req.getDeviceId());
anonymousRateLimiter.check(identity, "comment");
return Result.ok(commentService.publish(
identity, req, WebUtils.getClientIp(request)));
identity, req, webUtils.clientIp(request)));
}
@Operation(summary = "评论列表(游标翻页,可匿名浏览)")
@@ -37,6 +37,7 @@ public class AppDramaController {
private final EpisodeService episodeService;
private final CommentService commentService;
private final AppEpisodeStatsService appEpisodeStatsService;
private final WebUtils webUtils;
/** 详情:包含剧集列表,客户端进详情页一次请求即可渲染 */
@Operation(summary = "短剧详情(含剧集列表,进详情页一次请求即可渲染)")
@@ -56,7 +57,7 @@ public class AppDramaController {
SecurityUtils.getUserIdOrNull(),
id,
request.getHeader("X-Device-Id"),
WebUtils.getClientIp(request));
webUtils.clientIp(request));
DramaEpisode episode = episodeService.getExisting(id);
vo.setLikeCount(episode.getLikeCount() == null ? 0L : episode.getLikeCount());
vo.setCommentCount(commentService.countByEpisodes(List.of(id)).getOrDefault(id, 0L));
@@ -25,11 +25,12 @@ import org.springframework.web.bind.annotation.RestController;
public class AppEventController {
private final AppEventService eventService;
private final WebUtils webUtils;
@Operation(summary = "埋点事件批量上报")
@PostMapping("/events")
public Result<Void> report(@Valid @RequestBody AppEventReqVO req, HttpServletRequest request) {
eventService.save(req, SecurityUtils.getUserIdOrNull(), WebUtils.getClientIp(request));
eventService.save(req, SecurityUtils.getUserIdOrNull(), webUtils.clientIp(request));
return Result.ok();
}
}
@@ -47,6 +47,11 @@ springdoc:
path: /v3/api-docs
fruitshorts:
web:
# 可信代理 CIDR 列表(逗号分隔)。默认空 = 永不解析 XFF,直接用 request.getRemoteAddr()。
# 配了反代(Nginx/K8s ingress)时填上其 IP 段,才会从 XFF 取最右侧可信一跳。
# 生产若开启了 Tomcat RemoteIpValve(prod profile 已配),保持空即可。
trusted-proxies: ${FRUITSHORTS_TRUSTED_PROXIES:}
auth:
# 仅作 sys_config 缺失/非法时的兜底;运行时优先读取后台「系统配置」
register-max-per-ip-per-day: ${REGISTER_MAX_PER_IP_PER_DAY:5}