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

@@ -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 字符串输出而不是数组,