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}
@@ -4,6 +4,7 @@ package com.fruitshorts.app.controller;
import com.fruitshorts.app.support.ControllerContractSupport;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.framework.web.WebUtils;
import com.fruitshorts.user.service.AppAuthService;
import com.fruitshorts.user.service.AppOtpService;
import com.fruitshorts.user.vo.TokenRespVO;
@@ -32,13 +33,15 @@ class AppAuthControllerContractTest {
private AppOtpService otpService;
private AppAuthService authService;
private WebUtils webUtils;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
otpService = mock(AppOtpService.class);
authService = mock(AppAuthService.class);
mockMvc = ControllerContractSupport.mockMvcOf(new AppAuthController(otpService, authService));
webUtils = mock(WebUtils.class);
mockMvc = ControllerContractSupport.mockMvcOf(new AppAuthController(otpService, authService, webUtils));
}
@Test
@@ -51,7 +51,8 @@ class AppCatalogControllerContractTest {
dramaMvc = ControllerContractSupport.mockMvcOf(
new AppDramaController(catalogService, mock(PlaybackService.class),
mock(EpisodeService.class), mock(CommentService.class),
mock(AppEpisodeStatsService.class)));
mock(AppEpisodeStatsService.class),
mock(com.fruitshorts.framework.web.WebUtils.class)));
}
@Test
@@ -38,7 +38,8 @@ class AppCommentControllerContractTest {
commentService,
episodeService,
mock(InteractionIdentityResolver.class),
mock(AnonymousInteractionRateLimiter.class)));
mock(AnonymousInteractionRateLimiter.class),
mock(com.fruitshorts.framework.web.WebUtils.class)));
}
@Test
@@ -234,7 +234,8 @@ class AppCommentCountAnonymousSecurityTest {
commentService,
episodeService,
identityResolver,
rateLimiter);
rateLimiter,
mock(com.fruitshorts.framework.web.WebUtils.class));
}
@Bean
@@ -303,7 +304,8 @@ class AppCommentCountAnonymousSecurityTest {
mock(PlaybackService.class),
episodeService,
commentService,
appEpisodeStatsService);
appEpisodeStatsService,
mock(com.fruitshorts.framework.web.WebUtils.class));
}
@Bean
@@ -340,7 +342,7 @@ class AppCommentCountAnonymousSecurityTest {
RestAuthEntryPoint restAuthEntryPoint(ObjectMapper objectMapper) {
return new RestAuthEntryPoint(
objectMapper,
new AccessDeniedRecorder(mock(ObjectProvider.class)));
new AccessDeniedRecorder(mock(ObjectProvider.class), mock(ObjectProvider.class)));
}
@Bean
@@ -54,7 +54,8 @@ class AppPlaybackContractTest {
given(commentService.countByEpisodes(any())).willReturn(Map.of());
mockMvc = ControllerContractSupport.mockMvcOf(
new AppDramaController(mock(AppCatalogService.class), playbackService,
episodeService, commentService, episodeStatsService));
episodeService, commentService, episodeStatsService,
mock(com.fruitshorts.framework.web.WebUtils.class)));
}
@Test
@@ -22,6 +22,7 @@ import com.fruitshorts.user.service.AppOtpService;
import com.fruitshorts.user.service.CredentialHasher;
import com.fruitshorts.user.service.GoogleIdTokenVerifier;
import com.fruitshorts.user.service.GoogleUser;
import com.fruitshorts.user.service.IpRegionService;
import com.fruitshorts.user.service.OtpSender;
import com.fruitshorts.user.vo.LoginReqVO;
import com.fruitshorts.user.vo.RegisterReqVO;
@@ -67,6 +68,7 @@ class AppAuthServiceTest {
private ConfigService configService;
private GoogleIdTokenVerifier googleVerifier;
private ApplicationEventPublisher eventPublisher;
private IpRegionService ipRegionService;
private AppAuthService authService;
@BeforeEach
@@ -83,11 +85,14 @@ class AppAuthServiceTest {
configService = mock(ConfigService.class);
googleVerifier = mock(GoogleIdTokenVerifier.class);
eventPublisher = mock(ApplicationEventPublisher.class);
ipRegionService = mock(IpRegionService.class);
given(configService.get(anyString(), anyString()))
.willAnswer(invocation -> invocation.getArgument(1));
// 单测里默认解析不出国家,走客户端区域 / 印尼兜底
given(ipRegionService.resolveCountry(any())).willReturn("");
authService = new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
deviceMapper, otpService, hasher, tokenProvider, passwordEncoder, configService,
googleVerifier, eventPublisher);
googleVerifier, eventPublisher, ipRegionService);
}
@Test
@@ -264,6 +269,41 @@ class AppAuthServiceTest {
assertThat(event.deviceId()).isEqualTo("device-9");
}
@Test
void should_sync_country_from_ip_on_login() {
stubLogin(credential("bcrypt-hash"), user(AppUserStatus.NORMAL));
given(passwordEncoder.matches("abcd1234", "bcrypt-hash")).willReturn(true);
given(ipRegionService.resolveCountry(any())).willReturn("TH");
authService.login(emailLogin("abcd1234"), "49.237.8.1");
ArgumentCaptor<AppUser> captor = ArgumentCaptor.forClass(AppUser.class);
verify(userMapper).updateById(captor.capture());
AppUser saved = captor.getValue();
assertThat(saved.getCountryCode()).isEqualTo("TH");
assertThat(saved.getRegionCode()).isEqualTo("TH");
assertThat(saved.getLanguage()).isEqualTo("en-US");
assertThat(saved.getTimezone()).isEqualTo("Asia/Bangkok");
assertThat(saved.getCurrency()).isEqualTo("THB");
}
@Test
void should_keep_user_customized_language_when_syncing_country() {
AppUser user = user(AppUserStatus.NORMAL);
user.setLanguage("zh-CN");
stubLogin(credential("bcrypt-hash"), user);
given(passwordEncoder.matches("abcd1234", "bcrypt-hash")).willReturn(true);
given(ipRegionService.resolveCountry(any())).willReturn("TH");
authService.login(emailLogin("abcd1234"), "49.237.8.1");
ArgumentCaptor<AppUser> captor = ArgumentCaptor.forClass(AppUser.class);
verify(userMapper).updateById(captor.capture());
assertThat(captor.getValue().getCountryCode()).isEqualTo("TH");
// 用户自己改过的语言不被 IP 覆盖
assertThat(captor.getValue().getLanguage()).isEqualTo("zh-CN");
}
@Test
void should_reject_login_when_password_does_not_match() {
stubLogin(credential("bcrypt-hash"), user(AppUserStatus.NORMAL));
@@ -318,7 +358,7 @@ class AppAuthServiceTest {
});
given(userMapper.selectById(any())).willAnswer(invocation -> inserted.get());
TokenRespVO resp = authService.googleLogin("google-id-token", "device-9");
TokenRespVO resp = authService.googleLogin("google-id-token", "device-9", "203.0.113.9");
assertThat(resp.getAccessToken()).isEqualTo("access-1");
assertThat(resp.isNewUser()).isTrue();
@@ -333,7 +373,7 @@ class AppAuthServiceTest {
given(googleVerifier.verify(anyString()))
.willReturn(new GoogleUser("sub-1", "", "Alice", ""));
assertThatThrownBy(() -> authService.googleLogin("google-id-token", "device-9"))
assertThatThrownBy(() -> authService.googleLogin("google-id-token", "device-9", "203.0.113.9"))
.isInstanceOf(ServiceException.class)
.hasMessage("Google 账号未返回邮箱,无法登录");
}
@@ -392,7 +432,7 @@ class AppAuthServiceTest {
AppOtpService realOtpService = new AppOtpService(otpMapper, mock(OtpSender.class), otpHasher);
return new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
deviceMapper, realOtpService, hasher, tokenProvider, passwordEncoder, configService,
googleVerifier, eventPublisher);
googleVerifier, eventPublisher, ipRegionService);
}
private void setRegisterLimits(int perIp, int perDevice) {
@@ -0,0 +1,42 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.user.enums.CountryProfile;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/** 国家解析与映射:IP 库文本 → ISO-2,再据此选语言/时区/币种。 */
class CountryProfileTest {
@Test
void should_map_ip_region_text_to_iso_code() {
assertThat(CountryProfile.fromIpRegion("中国|0|广东省|深圳市|电信")).isEqualTo("CN");
assertThat(CountryProfile.fromIpRegion("中国|0|台湾省|0|中华电信")).isEqualTo("TW");
assertThat(CountryProfile.fromIpRegion("中国|0|香港|0|0")).isEqualTo("HK");
assertThat(CountryProfile.fromIpRegion("中国|0|澳门|0|澳门电讯")).isEqualTo("MO");
assertThat(CountryProfile.fromIpRegion("美国|0|0|0|Level3")).isEqualTo("US");
assertThat(CountryProfile.fromIpRegion("印度尼西亚|0|大雅加达|雅加达|0")).isEqualTo("ID");
}
@Test
void should_return_empty_for_private_ip_and_unknown_country() {
assertThat(CountryProfile.fromIpRegion("0|0|0|内网IP|内网IP")).isEmpty();
assertThat(CountryProfile.fromIpRegion("")).isEmpty();
// 未收录的国家交给上层回退到客户端区域
assertThat(CountryProfile.fromIpRegion("津巴布韦|0|0|0|0")).isEmpty();
}
@Test
void should_normalize_client_region_and_fallback_to_default() {
assertThat(CountryProfile.normalizeClientCode(" id ")).isEqualTo("ID");
assertThat(CountryProfile.normalizeClientCode("us")).isEqualTo("US");
assertThat(CountryProfile.normalizeClientCode("XX")).isEmpty();
assertThat(CountryProfile.normalizeClientCode("")).isEmpty();
assertThat(CountryProfile.of("US").currency()).isEqualTo("USD");
assertThat(CountryProfile.of("US").language()).isEqualTo("en-US");
assertThat(CountryProfile.of("").code()).isEqualTo("ID");
assertThat(CountryProfile.of("").currency()).isEqualTo("IDR");
}
}
@@ -0,0 +1,49 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.user.service.IpRegionService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* IpRegionService 集成测试:真实 xdb 文件 + 真实 resolve。
*
* <p>xdb 在 fruitshorts-module-user 的 resources/ip2region/ip2region.xdb 下(11MB),
* 打包后会进 app-api 的 classpath。这个测试验证 Service 层调 ip2region 库的行为——
* 合法公网 IP → ISO-2 国家码,内网/保留地址/空 → 空串。
*/
class IpRegionServiceTest {
private IpRegionService service;
@BeforeEach
void setUp() {
service = new IpRegionService();
}
@Test
void should_resolve_known_public_ips() {
assertThat(service.resolveCountry("8.8.8.8")).isEqualTo("US");
assertThat(service.resolveCountry("114.114.114.114")).isEqualTo("CN");
}
@Test
void should_return_empty_for_private_loopback_and_reserved() {
assertThat(service.resolveCountry("127.0.0.1")).isEmpty();
assertThat(service.resolveCountry("0.0.0.0")).isEmpty();
assertThat(service.resolveCountry("10.0.0.1")).isEmpty();
assertThat(service.resolveCountry("192.168.1.1")).isEmpty();
assertThat(service.resolveCountry("172.16.0.1")).isEmpty();
assertThat(service.resolveCountry("169.254.1.1")).isEmpty();
}
@Test
void should_return_empty_for_invalid_inputs() {
assertThat(service.resolveCountry("")).isEmpty();
assertThat(service.resolveCountry(null)).isEmpty();
assertThat(service.resolveCountry("not-an-ip")).isEmpty();
assertThat(service.resolveCountry("999.999.999.999")).isEmpty();
}
}
@@ -2,6 +2,7 @@
package com.fruitshorts.app.support;
import com.fruitshorts.framework.log.AccessDeniedRecorder;
import com.fruitshorts.framework.web.WebUtils;
import com.fruitshorts.framework.web.JacksonLongToStringConfig;
import com.fruitshorts.framework.web.GlobalExceptionHandler;
import com.fasterxml.jackson.databind.SerializationFeature;
@@ -30,7 +31,9 @@ public final class ControllerContractSupport {
/** 组装一个只含指定控制器与全局异常处理的 MockMvc。 */
@SuppressWarnings("unchecked")
public static MockMvc mockMvcOf(Object controller) {
AccessDeniedRecorder recorder = new AccessDeniedRecorder(Mockito.mock(ObjectProvider.class));
AccessDeniedRecorder recorder = new AccessDeniedRecorder(
Mockito.mock(ObjectProvider.class),
Mockito.mock(ObjectProvider.class));
return MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler(recorder))
// 与 Spring Boot 的 Jackson 配置对齐:时间按 ISO 字符串输出而不是数组,