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:
1 parent
a41ab18636
commit
272aa62434
34 files changed
+748
-77
No files matched your search
+4
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+5
-3
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+44
-4
@@ -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");
|
||||
}
|
||||
}
|
||||
+49
@@ -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();
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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 字符串输出而不是数组,
|
||||
|
||||
Reference in new issue
Block a user