diff --git a/rc-auth/src/main/java/com/red/circle/auth/common/LoginAccessValidationService.java b/rc-auth/src/main/java/com/red/circle/auth/common/LoginAccessValidationService.java index 71c16b54..add9e10a 100644 --- a/rc-auth/src/main/java/com/red/circle/auth/common/LoginAccessValidationService.java +++ b/rc-auth/src/main/java/com/red/circle/auth/common/LoginAccessValidationService.java @@ -1,6 +1,7 @@ package com.red.circle.auth.common; import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.red.circle.auth.storage.RedCircleCredentialService; import com.red.circle.component.redis.service.RedisService; @@ -10,6 +11,7 @@ import com.red.circle.tool.core.text.StringUtils; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.concurrent.RejectedExecutionException; @@ -19,6 +21,7 @@ import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; +import org.apache.http.impl.client.LaxRedirectStrategy; import org.apache.http.util.EntityUtils; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; @@ -31,9 +34,16 @@ import org.springframework.stereotype.Component; public class LoginAccessValidationService { private static final String LOGIN_WHITE_CONFIG = "LOGIN_WHITE_CONFIG"; - private static final String APPCODE_CONFIG = "APPCODE ******"; - private static final String REQUEST_URL = "https://c2ba.api.huachen.cn/ip"; - private static final List ALLOWED_CHINA_REGIONS = Arrays.asList("香港", "澳门", "台湾"); + private static final String IP_CACHE_KEY_PREFIX = "IP_KEY_V2:"; + private static final List ALLOWED_CHINA_REGIONS = List.of( + "香港", "澳门", "澳門", "台湾", "台灣", "Hong Kong", "Macau", "Macao", "Taiwan" + ); + private static final List IP_GEO_PROVIDERS = List.of( + new IpGeoProvider("ip.sb", "https://api.ip.sb/geoip/%s"), + new IpGeoProvider("freeipapi", "https://freeipapi.com/api/json/%s"), + new IpGeoProvider("ipwhois", "https://ipwhois.app/json/%s?format=json"), + new IpGeoProvider("ip.nc.gy", "https://ip.nc.gy/json?ip=%s") + ); private final EnumConfigClient enumConfigClient; private final RedCircleCredentialService redCircleCredentialService; @@ -134,10 +144,7 @@ public class LoginAccessValidationService { } private boolean isIpValidationFailed(String ip) { - JSONObject dataObject = loadIpData(ip); - return dataObject != null - && "中国".equals(dataObject.getString("country")) - && !ALLOWED_CHINA_REGIONS.contains(dataObject.getString("region")); + return isMainlandChina(loadIpData(ip)); } private boolean isZoneValidationFailed(String reqZone) { @@ -146,36 +153,159 @@ public class LoginAccessValidationService { } private JSONObject loadIpData(String ip) { - String redisKey = "IP_KEY:" + ip; + if (StringUtils.isBlank(ip)) { + return null; + } + String redisKey = IP_CACHE_KEY_PREFIX + ip; String data = redisService.getString(redisKey); JSONObject dataObject = null; if (StringUtils.isNotBlank(data)) { dataObject = JSONObject.parseObject(data); } else { - HttpGet httpGet = new HttpGet(REQUEST_URL + "?ip=" + ip); - httpGet.addHeader("Authorization", APPCODE_CONFIG); RequestConfig requestConfig = RequestConfig.custom() - .setConnectTimeout(60000) - .setSocketTimeout(60000) - .setConnectionRequestTimeout(60000) + .setConnectTimeout(5000) + .setSocketTimeout(5000) + .setConnectionRequestTimeout(5000) .build(); - try (CloseableHttpClient httpClient = HttpClients.custom().setDefaultRequestConfig(requestConfig).build(); - CloseableHttpResponse response = httpClient.execute(httpGet)) { - String result = EntityUtils.toString(response.getEntity()); - log.info("请求地址:{},返回信息:{}", REQUEST_URL + "?ip=" + ip, result); - JSONObject jsonObject = JSON.parseObject(result); - if (jsonObject != null && Integer.valueOf(200).equals(jsonObject.getInteger("ret"))) { - dataObject = jsonObject.getJSONObject("data"); + try (CloseableHttpClient httpClient = HttpClients.custom() + .setDefaultRequestConfig(requestConfig) + .setRedirectStrategy(new LaxRedirectStrategy()) + .build()) { + for (IpGeoProvider provider : IP_GEO_PROVIDERS) { + dataObject = requestIpData(httpClient, provider, ip); if (dataObject != null) { redisService.setString(redisKey, dataObject.toJSONString()); + break; } } } catch (Exception e) { - log.error("请求地址:{}, 异常信息:{}", REQUEST_URL + "?ip=" + ip, e.getMessage()); + log.error("load ip geo data error ipAddr={}, message={}", ip, e.getMessage()); } } log.info("IP:{}, 国家信息:{}", ip, dataObject); return dataObject; } + + private JSONObject requestIpData(CloseableHttpClient httpClient, IpGeoProvider provider, String ip) { + String requestUrl = provider.buildUrl(ip); + HttpGet httpGet = new HttpGet(requestUrl); + try (CloseableHttpResponse response = httpClient.execute(httpGet)) { + int statusCode = response.getStatusLine().getStatusCode(); + String result = EntityUtils.toString(response.getEntity()); + log.info("ip geo request provider={} url={} status={} result={}", provider.name, requestUrl, statusCode, result); + if (statusCode < 200 || statusCode >= 300 || StringUtils.isBlank(result)) { + return null; + } + JSONObject jsonObject = JSON.parseObject(result); + return normalizeIpData(provider.name, jsonObject); + } catch (Exception e) { + log.warn("ip geo request failed provider={} ipAddr={} message={}", provider.name, ip, e.getMessage()); + return null; + } + } + + static JSONObject normalizeIpData(String provider, JSONObject jsonObject) { + if (jsonObject == null) { + return null; + } + return switch (provider) { + case "ip.sb" -> buildIpData( + provider, + jsonObject.getString("country_code"), + jsonObject.getString("country"), + jsonObject.getString("region") + ); + case "freeipapi" -> buildIpData( + provider, + jsonObject.getString("countryCode"), + jsonObject.getString("countryName"), + jsonObject.getString("regionName") + ); + case "ipwhois" -> Boolean.FALSE.equals(jsonObject.getBoolean("success")) + ? null + : buildIpData( + provider, + jsonObject.getString("country_code"), + jsonObject.getString("country"), + jsonObject.getString("region") + ); + case "ip.nc.gy" -> normalizeIpNcGy(provider, jsonObject); + default -> null; + }; + } + + static boolean isMainlandChina(JSONObject dataObject) { + if (dataObject == null) { + return false; + } + String countryCode = dataObject.getString("country_id"); + String country = dataObject.getString("country"); + boolean china = "CN".equalsIgnoreCase(countryCode) + || "中国".equals(country) + || "China".equalsIgnoreCase(country); + if (!china) { + return false; + } + String region = dataObject.getString("region"); + return StringUtils.isBlank(region) || !ALLOWED_CHINA_REGIONS.contains(region); + } + + private static JSONObject normalizeIpNcGy(String provider, JSONObject jsonObject) { + JSONObject countryObject = jsonObject.getJSONObject("country"); + if (countryObject == null) { + countryObject = jsonObject.getJSONObject("registered_country"); + } + if (countryObject == null) { + return null; + } + String region = null; + JSONArray subdivisions = jsonObject.getJSONArray("subdivisions"); + if (subdivisions != null && !subdivisions.isEmpty()) { + JSONObject subdivision = subdivisions.getJSONObject(0); + if (subdivision != null) { + region = getName(subdivision.getJSONObject("names")); + } + } + return buildIpData( + provider, + countryObject.getString("iso_code"), + getName(countryObject.getJSONObject("names")), + region + ); + } + + private static JSONObject buildIpData(String provider, String countryCode, String country, String region) { + if (StringUtils.isBlank(countryCode) && StringUtils.isBlank(country)) { + return null; + } + JSONObject dataObject = new JSONObject(); + if (StringUtils.isNotBlank(countryCode)) { + String normalizedCountryCode = countryCode.trim().toUpperCase(Locale.ROOT); + dataObject.put("country_id", normalizedCountryCode); + dataObject.put("country_code", normalizedCountryCode); + } + dataObject.put("country", country); + dataObject.put("region", region); + dataObject.put("source", provider); + return dataObject; + } + + private static String getName(JSONObject names) { + if (names == null) { + return null; + } + String name = names.getString("zh-CN"); + if (StringUtils.isNotBlank(name)) { + return name; + } + return names.getString("en"); + } + + private record IpGeoProvider(String name, String urlPattern) { + + String buildUrl(String ip) { + return String.format(urlPattern, ip); + } + } } diff --git a/rc-auth/src/test/java/com/red/circle/auth/common/LoginAccessValidationServiceTest.java b/rc-auth/src/test/java/com/red/circle/auth/common/LoginAccessValidationServiceTest.java new file mode 100644 index 00000000..04b74f11 --- /dev/null +++ b/rc-auth/src/test/java/com/red/circle/auth/common/LoginAccessValidationServiceTest.java @@ -0,0 +1,80 @@ +package com.red.circle.auth.common; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import org.junit.Test; + +public class LoginAccessValidationServiceTest { + + @Test + public void normalizeIpSbMainlandChina() { + JSONObject data = LoginAccessValidationService.normalizeIpData( + "ip.sb", + JSON.parseObject(""" + {"country":"China","country_code":"CN","timezone":"Asia/Shanghai"} + """) + ); + + assertEquals("CN", data.getString("country_id")); + assertEquals("China", data.getString("country")); + assertTrue(LoginAccessValidationService.isMainlandChina(data)); + } + + @Test + public void normalizeFreeIpApiMainlandChina() { + JSONObject data = LoginAccessValidationService.normalizeIpData( + "freeipapi", + JSON.parseObject(""" + {"countryName":"China","countryCode":"CN","regionName":"Shandong"} + """) + ); + + assertEquals("CN", data.getString("country_id")); + assertEquals("Shandong", data.getString("region")); + assertTrue(LoginAccessValidationService.isMainlandChina(data)); + } + + @Test + public void normalizeIpWhoisUnitedStates() { + JSONObject data = LoginAccessValidationService.normalizeIpData( + "ipwhois", + JSON.parseObject(""" + {"success":true,"country":"United States","country_code":"US","region":"California"} + """) + ); + + assertEquals("US", data.getString("country_id")); + assertFalse(LoginAccessValidationService.isMainlandChina(data)); + } + + @Test + public void normalizeIpNcGyMainlandChina() { + JSONObject data = LoginAccessValidationService.normalizeIpData( + "ip.nc.gy", + JSON.parseObject(""" + { + "country":{"iso_code":"CN","names":{"en":"China","zh-CN":"中国"}}, + "subdivisions":[{"names":{"en":"Jiangsu","zh-CN":"江苏"}}] + } + """) + ); + + assertEquals("CN", data.getString("country_id")); + assertEquals("中国", data.getString("country")); + assertEquals("江苏", data.getString("region")); + assertTrue(LoginAccessValidationService.isMainlandChina(data)); + } + + @Test + public void allowChinaSpecialRegions() { + JSONObject data = JSON.parseObject(""" + {"country_id":"CN","country":"中国","region":"香港"} + """); + + assertFalse(LoginAccessValidationService.isMainlandChina(data)); + } +} diff --git a/rc-service/rc-inner-api/other-inner/other-inner-model/src/main/java/com/red/circle/other/inner/model/dto/user/UserLoginLoggerDTO.java b/rc-service/rc-inner-api/other-inner/other-inner-model/src/main/java/com/red/circle/other/inner/model/dto/user/UserLoginLoggerDTO.java index 9e6d714f..d61814f9 100644 --- a/rc-service/rc-inner-api/other-inner/other-inner-model/src/main/java/com/red/circle/other/inner/model/dto/user/UserLoginLoggerDTO.java +++ b/rc-service/rc-inner-api/other-inner/other-inner-model/src/main/java/com/red/circle/other/inner/model/dto/user/UserLoginLoggerDTO.java @@ -36,15 +36,45 @@ public class UserLoginLoggerDTO implements Serializable { */ private String deviceId; - /** - * 登陆IP. - */ - private String ip; - - /** - * 登陆方式 0.手机 1.facebook 2.google. - */ - private String loginType; + /** + * 登陆IP. + */ + private String ip; + + /** + * 登陆 IP 国家码. + */ + private String ipCountryCode; + + /** + * 登陆 IP 国家/地区名称. + */ + private String ipCountryName; + + /** + * 登陆 IP 地区/省. + */ + private String ipRegion; + + /** + * 请求时区. + */ + private String reqZoneId; + + /** + * 是否被登录访问校验屏蔽. + */ + private Boolean loginBlocked; + + /** + * 登录访问校验屏蔽原因. + */ + private String blockReason; + + /** + * 登陆方式 0.手机 1.facebook 2.google. + */ + private String loginType; /** * 登陆方式 0.手机 1.facebook 2.google. diff --git a/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/convertor/user/UserProfileAppConvertor.java b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/convertor/user/UserProfileAppConvertor.java index 29510654..b4e65854 100644 --- a/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/convertor/user/UserProfileAppConvertor.java +++ b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/convertor/user/UserProfileAppConvertor.java @@ -55,10 +55,11 @@ public interface UserProfileAppConvertor { .setSysOrigin(cmd.getReqSysOrigin().getOrigin()) .setSysOriginChild(cmd.getReqSysOrigin().getOriginChild()) .setRequestClient(cmd.getReqClient().name()) - .setIp(cmd.getReqIp()) - .setLanguage(cmd.getReqLanguage()) - .setReqTime(new Timestamp(cmd.getReqTime().getTime())); - } + .setIp(cmd.getReqIp()) + .setLanguage(cmd.getReqLanguage()) + .setZoneId(cmd.getReqZoneId()) + .setReqTime(new Timestamp(cmd.getReqTime().getTime())); + } UserLevelCO toUserLevelCO(UserConsumptionLevel level); diff --git a/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/listener/user/LoginLoggerListener.java b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/listener/user/LoginLoggerListener.java index 8765a7c2..4ced613e 100644 --- a/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/listener/user/LoginLoggerListener.java +++ b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/listener/user/LoginLoggerListener.java @@ -38,20 +38,26 @@ import com.red.circle.other.infra.database.rds.service.user.user.ExpandService; import com.red.circle.other.infra.database.rds.service.user.user.InviteUserRegisterService; import com.red.circle.other.infra.database.rds.service.user.user.InviteUserRewardRecordService; import com.red.circle.other.infra.database.rds.service.user.user.InviteUserSummaryService; -import com.red.circle.other.infra.database.rds.service.user.user.LoginLoggerService; -import com.red.circle.other.infra.enums.user.logger.RewardOriginEnum; -import com.red.circle.other.inner.enums.config.EnumConfigKey; -import com.red.circle.other.inner.model.cmd.user.IncrDeviceRegisterCmd; -import com.red.circle.tool.core.date.TimestampUtils; -import com.red.circle.tool.core.json.JacksonUtils; -import com.red.circle.tool.core.num.ArithmeticUtils; -import com.red.circle.tool.core.sequence.IdWorkerUtils; -import com.red.circle.wallet.inner.endpoint.wallet.WalletDiamondClient; -import com.red.circle.wallet.inner.model.cmd.DiamondReceiptCmd; -import java.math.BigDecimal; -import java.util.Objects; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import com.red.circle.other.infra.database.rds.service.user.user.LoginLoggerService; +import com.red.circle.other.infra.enums.user.logger.RewardOriginEnum; +import com.red.circle.other.infra.utils.IpCountryUtils; +import com.red.circle.other.inner.enums.config.EnumConfigKey; +import com.red.circle.other.inner.model.cmd.user.IncrDeviceRegisterCmd; +import com.red.circle.other.inner.model.dto.sys.EnumConfigDTO; +import com.red.circle.tool.core.date.TimestampUtils; +import com.red.circle.tool.core.json.JacksonUtils; +import com.red.circle.tool.core.num.ArithmeticUtils; +import com.red.circle.tool.core.sequence.IdWorkerUtils; +import com.red.circle.tool.core.text.StringUtils; +import com.red.circle.wallet.inner.endpoint.wallet.WalletDiamondClient; +import com.red.circle.wallet.inner.model.cmd.DiamondReceiptCmd; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; /** * 登录日志. @@ -65,11 +71,12 @@ public class LoginLoggerListener implements MessageListener { private final ExpandService expandService; private final CensorMqMessage censorMqMessage; - private final UserProfileGateway userProfileGateway; - private final LoginLoggerService loginLoggerService; - private final MessageEventProcess messageEventProcess; - private final WalletDiamondClient walletDiamondClient; - private final RegisterDeviceGateway registerDeviceGateway; + private final UserProfileGateway userProfileGateway; + private final LoginLoggerService loginLoggerService; + private final MessageEventProcess messageEventProcess; + private final IpCountryUtils ipCountryUtils; + private final WalletDiamondClient walletDiamondClient; + private final RegisterDeviceGateway registerDeviceGateway; private final EnumConfigCacheService enumConfigCacheService; private final InviteUserSummaryService inviteUserSummaryService; private final InviteCampaignGoClient inviteCampaignGoClient; @@ -116,7 +123,7 @@ public class LoginLoggerListener implements MessageListener { } // 保存日志 - saveLoginLogger(event); + saveLoginLogger(event, userProfile); // 账号不可用冻结状态 if (AccountStatusEnum.isArchive(userProfile.getAccountStatus()) || @@ -291,17 +298,25 @@ public class LoginLoggerListener implements MessageListener { } - private void saveLoginLogger(LoginLoggerEvent event) { - if (Objects.nonNull(event.getAuthType())) { - try { - loginLoggerService.save(new LoginLogger() - .setId(IdWorkerUtils.getId()) - .setDeviceId(event.getImei()) - .setUserId(event.getUserId()) - .setIp(event.ipNullDefault()) - .setLoginType(event.getAuthType()) - .setAppVersion(event.getAppVersion()) - .setAppPlatform(event.getRequestClient()) + private void saveLoginLogger(LoginLoggerEvent event, UserProfile userProfile) { + if (Objects.nonNull(event.getAuthType())) { + try { + IpCountryUtils.IpGeoData ipGeoData = ipCountryUtils.getGeoData(event.ipNullDefault()); + LoginBlockResult blockResult = resolveLoginBlockResult(event, userProfile, ipGeoData); + loginLoggerService.save(new LoginLogger() + .setId(IdWorkerUtils.getId()) + .setDeviceId(event.getImei()) + .setUserId(event.getUserId()) + .setIp(event.ipNullDefault()) + .setIpCountryCode(ipGeoData.countryCode()) + .setIpCountryName(ipGeoData.countryName()) + .setIpRegion(ipGeoData.region()) + .setReqZoneId(Objects.toString(event.getZoneId(), "")) + .setLoginBlocked(blockResult.blocked()) + .setBlockReason(blockResult.reason()) + .setLoginType(event.getAuthType()) + .setAppVersion(event.getAppVersion()) + .setAppPlatform(event.getRequestClient()) .setSysVersion(event.getAppPhoneSysVersion()) .setSysModel(event.getAppPhoneModel()) .setCreateTime(TimestampUtils.now()) @@ -312,10 +327,54 @@ public class LoginLoggerListener implements MessageListener { } return; } - log.warn("数据异常[2]:{}", JacksonUtils.toJson(event)); - } - - private LatestMobileDevice toLatestMobileDevice(LoginLoggerEvent event) { + log.warn("数据异常[2]:{}", JacksonUtils.toJson(event)); + } + + private LoginBlockResult resolveLoginBlockResult(LoginLoggerEvent event, UserProfile userProfile, + IpCountryUtils.IpGeoData ipGeoData) { + if (isLoginWhiteAccount(event, userProfile)) { + return LoginBlockResult.notBlocked(); + } + List reasons = new ArrayList<>(); + if (IpCountryUtils.isMainlandChina(ipGeoData)) { + reasons.add("中国大陆IP"); + } + if (Objects.equals("Asia/Shanghai", event.getZoneId())) { + reasons.add("中国时区"); + } + return reasons.isEmpty() + ? LoginBlockResult.notBlocked() + : new LoginBlockResult(true, String.join(",", reasons)); + } + + private boolean isLoginWhiteAccount(LoginLoggerEvent event, UserProfile userProfile) { + if (Objects.isNull(userProfile) || StringUtils.isBlank(userProfile.getAccount())) { + return false; + } + try { + EnumConfigDTO config = enumConfigCacheService.getByCode("LOGIN_WHITE_CONFIG", event.getSysOrigin()); + String whiteConfig = Objects.nonNull(config) ? config.getVal() : ""; + if (StringUtils.isBlank(whiteConfig)) { + return false; + } + return Arrays.stream(whiteConfig.split(",")) + .map(String::trim) + .anyMatch(userProfile.getAccount()::equals); + } catch (Exception ex) { + log.warn("login white account config load failed sysOrigin={} userId={}", + event.getSysOrigin(), event.getUserId(), ex); + return false; + } + } + + private record LoginBlockResult(Boolean blocked, String reason) { + + static LoginBlockResult notBlocked() { + return new LoginBlockResult(false, ""); + } + } + + private LatestMobileDevice toLatestMobileDevice(LoginLoggerEvent event) { return new LatestMobileDevice() .setUserId(event.getUserId()) .setLanguage(event.getLanguage()) diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/convertor/user/UserProfileInfraConvertor.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/convertor/user/UserProfileInfraConvertor.java index 5c7fae88..1c70ec79 100644 --- a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/convertor/user/UserProfileInfraConvertor.java +++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/convertor/user/UserProfileInfraConvertor.java @@ -87,10 +87,11 @@ public interface UserProfileInfraConvertor { .setSysOrigin(cmd.getReqSysOrigin().getOrigin()) .setSysOriginChild(cmd.getReqSysOrigin().getOriginChild()) .setRequestClient(cmd.getReqClient().name()) - .setIp(cmd.getReqIp()) - .setLanguage(cmd.getReqLanguage()) - .setReqTime(new Timestamp(cmd.getReqTime().getTime())); - } + .setIp(cmd.getReqIp()) + .setLanguage(cmd.getReqLanguage()) + .setZoneId(cmd.getReqZoneId()) + .setReqTime(new Timestamp(cmd.getReqTime().getTime())); + } /** * 将PhotoItem列表转为JSON字符串 diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/entity/user/user/LoginLogger.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/entity/user/user/LoginLogger.java index da36fd68..62d14486 100644 --- a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/entity/user/user/LoginLogger.java +++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/rds/entity/user/user/LoginLogger.java @@ -49,13 +49,49 @@ public class LoginLogger implements Serializable { /** * 登陆IP */ - @TableField("ip") - private String ip; - - /** - * 登陆方式. - */ - @TableField("login_type") + @TableField("ip") + private String ip; + + /** + * 登陆 IP 国家码. + */ + @TableField("ip_country_code") + private String ipCountryCode; + + /** + * 登陆 IP 国家/地区名称. + */ + @TableField("ip_country_name") + private String ipCountryName; + + /** + * 登陆 IP 地区/省. + */ + @TableField("ip_region") + private String ipRegion; + + /** + * 请求时区. + */ + @TableField("req_zone_id") + private String reqZoneId; + + /** + * 是否被登录访问校验屏蔽. + */ + @TableField("login_blocked") + private Boolean loginBlocked; + + /** + * 登录访问校验屏蔽原因. + */ + @TableField("block_reason") + private String blockReason; + + /** + * 登陆方式. + */ + @TableField("login_type") private String loginType; /** diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/utils/IpCountryUtils.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/utils/IpCountryUtils.java index ef830db5..c11100f1 100644 --- a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/utils/IpCountryUtils.java +++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/utils/IpCountryUtils.java @@ -1,19 +1,23 @@ package com.red.circle.other.infra.utils; -import com.alibaba.fastjson.JSON; -import com.alibaba.fastjson.JSONObject; -import com.red.circle.component.redis.service.RedisService; -import com.red.circle.tool.core.text.StringUtils; -import java.util.Objects; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.http.client.config.RequestConfig; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.util.EntityUtils; -import org.springframework.stereotype.Component; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.red.circle.component.redis.service.RedisService; +import com.red.circle.tool.core.text.StringUtils; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.impl.client.LaxRedirectStrategy; +import org.apache.http.util.EntityUtils; +import org.springframework.stereotype.Component; /** * IP 归属国家查询工具. @@ -21,59 +25,217 @@ import org.springframework.stereotype.Component; @Slf4j @Component @RequiredArgsConstructor -public class IpCountryUtils { - - private static final String APPCODE = "APPCODE ****"; - private static final String REQUEST_URL = "https://c2ba.api.huachen.cn/ip"; - private static final String REDIS_KEY_PREFIX = "IP_COUNTRY:"; - - private final RedisService redisService; - - /** - * 根据 IP 获取国家二字码(如 US、CN、HK). - * 结果缓存到 Redis,永久有效(IP 归属地基本不变). +public class IpCountryUtils { + + private static final String REDIS_KEY_PREFIX = "IP_COUNTRY_V2:"; + private static final List ALLOWED_CHINA_REGIONS = List.of( + "香港", "澳门", "澳門", "台湾", "台灣", "Hong Kong", "Macau", "Macao", "Taiwan" + ); + private static final List IP_GEO_PROVIDERS = List.of( + new IpGeoProvider("ip.sb", "https://api.ip.sb/geoip/%s"), + new IpGeoProvider("freeipapi", "https://freeipapi.com/api/json/%s"), + new IpGeoProvider("ipwhois", "https://ipwhois.app/json/%s?format=json"), + new IpGeoProvider("ip.nc.gy", "https://ip.nc.gy/json?ip=%s") + ); + + private final RedisService redisService; + + public IpGeoData getGeoData(String ip) { + if (StringUtils.isBlank(ip) || Objects.equals("-", ip)) { + return IpGeoData.empty(); + } + String redisKey = REDIS_KEY_PREFIX + ip; + String cached = redisService.getString(redisKey); + if (StringUtils.isNotBlank(cached)) { + return IpGeoData.fromJson(JSONObject.parseObject(cached)); + } + + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(5000) + .setSocketTimeout(5000) + .setConnectionRequestTimeout(5000) + .build(); + + try (CloseableHttpClient httpClient = HttpClients.custom() + .setDefaultRequestConfig(requestConfig) + .setRedirectStrategy(new LaxRedirectStrategy()) + .build()) { + for (IpGeoProvider provider : IP_GEO_PROVIDERS) { + IpGeoData data = requestIpGeoData(httpClient, provider, ip); + if (!data.isEmpty()) { + redisService.setString(redisKey, data.toJson().toJSONString()); + return data; + } + } + } catch (Exception e) { + log.error("IpCountryUtils load ip geo data error ip={}, message={}", ip, e.getMessage()); + } + return IpGeoData.empty(); + } + + /** + * 根据 IP 获取国家二字码(如 US、CN、HK). + * 结果缓存到 Redis,永久有效(IP 归属地基本不变). * * @param ip 用户 IP - * @return 国家二字码,查询失败返回空字符串 + * @return 国家二字码,查询失败返回空字符串 + */ + public String getCountryCode(String ip) { + return getGeoData(ip).countryCode(); + } + + /** + * 判断 IP 是否属于美国. */ - public String getCountryCode(String ip) { - if (StringUtils.isBlank(ip)) { - return ""; - } - String redisKey = REDIS_KEY_PREFIX + ip; - String cached = redisService.getString(redisKey); - if (StringUtils.isNotBlank(cached)) { - return cached; - } - - HttpGet httpGet = new HttpGet(REQUEST_URL + "?ip=" + ip); - httpGet.addHeader("Authorization", APPCODE); - RequestConfig requestConfig = RequestConfig.custom() - .setConnectTimeout(60000).setSocketTimeout(60000).setConnectionRequestTimeout(60000).build(); - - try (CloseableHttpClient httpClient = HttpClients.custom().setDefaultRequestConfig(requestConfig).build(); - CloseableHttpResponse response = httpClient.execute(httpGet)) { - String result = EntityUtils.toString(response.getEntity()); - log.info("IpCountryUtils ip={} result={}", ip, result); - JSONObject jsonObject = JSON.parseObject(result); - if (Objects.equals(jsonObject.getInteger("ret"), 200)) { - JSONObject data = jsonObject.getJSONObject("data"); - String countryId = data.getString("country_id"); - if (StringUtils.isNotBlank(countryId)) { - redisService.setString(redisKey, countryId); - return countryId; - } - } - } catch (Exception e) { - log.error("IpCountryUtils ip={} error={}", ip, e.getMessage()); - } - return ""; - } - - /** - * 判断 IP 是否属于美国. - */ - public boolean isUsIp(String ip) { - return "US".equalsIgnoreCase(getCountryCode(ip)); - } -} + public boolean isUsIp(String ip) { + return "US".equalsIgnoreCase(getCountryCode(ip)); + } + + public boolean isMainlandChina(String ip) { + return isMainlandChina(getGeoData(ip)); + } + + public static boolean isMainlandChina(IpGeoData data) { + if (data == null || data.isEmpty()) { + return false; + } + boolean china = "CN".equalsIgnoreCase(data.countryCode()) + || "中国".equals(data.countryName()) + || "China".equalsIgnoreCase(data.countryName()); + if (!china) { + return false; + } + return StringUtils.isBlank(data.region()) || !ALLOWED_CHINA_REGIONS.contains(data.region()); + } + + private IpGeoData requestIpGeoData(CloseableHttpClient httpClient, IpGeoProvider provider, String ip) { + String requestUrl = provider.buildUrl(ip); + HttpGet httpGet = new HttpGet(requestUrl); + try (CloseableHttpResponse response = httpClient.execute(httpGet)) { + int statusCode = response.getStatusLine().getStatusCode(); + String result = EntityUtils.toString(response.getEntity()); + log.info("IpCountryUtils request provider={} url={} status={} result={}", provider.name, requestUrl, statusCode, result); + if (statusCode < 200 || statusCode >= 300 || StringUtils.isBlank(result)) { + return IpGeoData.empty(); + } + return normalizeIpData(provider.name, JSON.parseObject(result)); + } catch (Exception e) { + log.warn("IpCountryUtils request failed provider={} ip={} message={}", provider.name, ip, e.getMessage()); + return IpGeoData.empty(); + } + } + + static IpGeoData normalizeIpData(String provider, JSONObject jsonObject) { + if (jsonObject == null) { + return IpGeoData.empty(); + } + return switch (provider) { + case "ip.sb" -> buildIpData( + provider, + jsonObject.getString("country_code"), + jsonObject.getString("country"), + jsonObject.getString("region") + ); + case "freeipapi" -> buildIpData( + provider, + jsonObject.getString("countryCode"), + jsonObject.getString("countryName"), + jsonObject.getString("regionName") + ); + case "ipwhois" -> Boolean.FALSE.equals(jsonObject.getBoolean("success")) + ? IpGeoData.empty() + : buildIpData( + provider, + jsonObject.getString("country_code"), + jsonObject.getString("country"), + jsonObject.getString("region") + ); + case "ip.nc.gy" -> normalizeIpNcGy(provider, jsonObject); + default -> IpGeoData.empty(); + }; + } + + private static IpGeoData normalizeIpNcGy(String provider, JSONObject jsonObject) { + JSONObject countryObject = jsonObject.getJSONObject("country"); + if (countryObject == null) { + countryObject = jsonObject.getJSONObject("registered_country"); + } + if (countryObject == null) { + return IpGeoData.empty(); + } + String region = null; + JSONArray subdivisions = jsonObject.getJSONArray("subdivisions"); + if (subdivisions != null && !subdivisions.isEmpty()) { + JSONObject subdivision = subdivisions.getJSONObject(0); + if (subdivision != null) { + region = getName(subdivision.getJSONObject("names")); + } + } + return buildIpData( + provider, + countryObject.getString("iso_code"), + getName(countryObject.getJSONObject("names")), + region + ); + } + + private static IpGeoData buildIpData(String provider, String countryCode, String countryName, String region) { + if (StringUtils.isBlank(countryCode) && StringUtils.isBlank(countryName)) { + return IpGeoData.empty(); + } + String normalizedCountryCode = StringUtils.isBlank(countryCode) + ? "" + : countryCode.trim().toUpperCase(Locale.ROOT); + return new IpGeoData(normalizedCountryCode, Objects.toString(countryName, ""), Objects.toString(region, ""), provider); + } + + private static String getName(JSONObject names) { + if (names == null) { + return ""; + } + String name = names.getString("zh-CN"); + if (StringUtils.isNotBlank(name)) { + return name; + } + return Objects.toString(names.getString("en"), ""); + } + + private record IpGeoProvider(String name, String urlPattern) { + + String buildUrl(String ip) { + return String.format(urlPattern, ip); + } + } + + public record IpGeoData(String countryCode, String countryName, String region, String source) { + + public static IpGeoData empty() { + return new IpGeoData("", "", "", ""); + } + + static IpGeoData fromJson(JSONObject jsonObject) { + if (jsonObject == null) { + return empty(); + } + return new IpGeoData( + Objects.toString(jsonObject.getString("countryCode"), ""), + Objects.toString(jsonObject.getString("countryName"), ""), + Objects.toString(jsonObject.getString("region"), ""), + Objects.toString(jsonObject.getString("source"), "") + ); + } + + JSONObject toJson() { + JSONObject jsonObject = new JSONObject(); + jsonObject.put("countryCode", countryCode); + jsonObject.put("countryName", countryName); + jsonObject.put("region", region); + jsonObject.put("source", source); + return jsonObject; + } + + public boolean isEmpty() { + return StringUtils.isBlank(countryCode) && StringUtils.isBlank(countryName); + } + } +} diff --git a/rc-service/rc-service-other/other-infrastructure/src/test/java/com/red/circle/other/infra/utils/IpCountryUtilsTest.java b/rc-service/rc-service-other/other-infrastructure/src/test/java/com/red/circle/other/infra/utils/IpCountryUtilsTest.java new file mode 100644 index 00000000..50f83903 --- /dev/null +++ b/rc-service/rc-service-other/other-infrastructure/src/test/java/com/red/circle/other/infra/utils/IpCountryUtilsTest.java @@ -0,0 +1,77 @@ +package com.red.circle.other.infra.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.alibaba.fastjson.JSON; +import org.junit.jupiter.api.Test; + +public class IpCountryUtilsTest { + + @Test + public void normalizeIpSbMainlandChina() { + IpCountryUtils.IpGeoData data = IpCountryUtils.normalizeIpData( + "ip.sb", + JSON.parseObject(""" + {"country":"China","country_code":"CN","timezone":"Asia/Shanghai"} + """) + ); + + assertEquals("CN", data.countryCode()); + assertEquals("China", data.countryName()); + assertTrue(IpCountryUtils.isMainlandChina(data)); + } + + @Test + public void normalizeFreeIpApiMainlandChina() { + IpCountryUtils.IpGeoData data = IpCountryUtils.normalizeIpData( + "freeipapi", + JSON.parseObject(""" + {"countryName":"China","countryCode":"CN","regionName":"Shandong"} + """) + ); + + assertEquals("CN", data.countryCode()); + assertEquals("Shandong", data.region()); + assertTrue(IpCountryUtils.isMainlandChina(data)); + } + + @Test + public void normalizeIpWhoisUnitedStates() { + IpCountryUtils.IpGeoData data = IpCountryUtils.normalizeIpData( + "ipwhois", + JSON.parseObject(""" + {"success":true,"country":"United States","country_code":"US","region":"California"} + """) + ); + + assertEquals("US", data.countryCode()); + assertFalse(IpCountryUtils.isMainlandChina(data)); + } + + @Test + public void normalizeIpNcGyMainlandChina() { + IpCountryUtils.IpGeoData data = IpCountryUtils.normalizeIpData( + "ip.nc.gy", + JSON.parseObject(""" + { + "country":{"iso_code":"CN","names":{"en":"China","zh-CN":"中国"}}, + "subdivisions":[{"names":{"en":"Jiangsu","zh-CN":"江苏"}}] + } + """) + ); + + assertEquals("CN", data.countryCode()); + assertEquals("中国", data.countryName()); + assertEquals("江苏", data.region()); + assertTrue(IpCountryUtils.isMainlandChina(data)); + } + + @Test + public void allowChinaSpecialRegions() { + IpCountryUtils.IpGeoData data = new IpCountryUtils.IpGeoData("CN", "中国", "香港", "test"); + + assertFalse(IpCountryUtils.isMainlandChina(data)); + } +} diff --git a/rc-service/rc-service-other/other-inner-endpoint/src/main/java/com/red/circle/other/app/inner/convertor/user/UserProfileInnerConvertor.java b/rc-service/rc-service-other/other-inner-endpoint/src/main/java/com/red/circle/other/app/inner/convertor/user/UserProfileInnerConvertor.java index c77969f1..0f0dbacd 100644 --- a/rc-service/rc-service-other/other-inner-endpoint/src/main/java/com/red/circle/other/app/inner/convertor/user/UserProfileInnerConvertor.java +++ b/rc-service/rc-service-other/other-inner-endpoint/src/main/java/com/red/circle/other/app/inner/convertor/user/UserProfileInnerConvertor.java @@ -65,10 +65,11 @@ public interface UserProfileInnerConvertor { .setSysOrigin(cmd.getReqSysOrigin().getOrigin()) .setSysOriginChild(cmd.getReqSysOrigin().getOriginChild()) .setRequestClient(cmd.getReqClient().name()) - .setIp(cmd.getReqIp()) - .setLanguage(cmd.getReqLanguage()) - .setReqTime(new Timestamp(cmd.getReqTime().getTime())); - } + .setIp(cmd.getReqIp()) + .setLanguage(cmd.getReqLanguage()) + .setZoneId(cmd.getReqZoneId()) + .setReqTime(new Timestamp(cmd.getReqTime().getTime())); + } UserProfile toUserProfile(UserBaseInfoCmd cmd); diff --git a/sql/20260506_user_login_logger_ip_region.sql b/sql/20260506_user_login_logger_ip_region.sql new file mode 100644 index 00000000..862f875d --- /dev/null +++ b/sql/20260506_user_login_logger_ip_region.sql @@ -0,0 +1,7 @@ +ALTER TABLE `user_login_logger` + ADD COLUMN `ip_country_code` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '登录IP国家码' AFTER `ip`, + ADD COLUMN `ip_country_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '登录IP国家/地区名称' AFTER `ip_country_code`, + ADD COLUMN `ip_region` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '登录IP地区/省' AFTER `ip_country_name`, + ADD COLUMN `req_zone_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '请求时区' AFTER `ip_region`, + ADD COLUMN `login_blocked` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否被登录访问校验屏蔽' AFTER `req_zone_id`, + ADD COLUMN `block_reason` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '登录访问校验屏蔽原因' AFTER `login_blocked`;