feat: record login ip region metadata

This commit is contained in:
hy001 2026-05-06 20:54:03 +08:00
parent eed2c7f05b
commit 41729137ed
11 changed files with 735 additions and 151 deletions

View File

@ -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<String> ALLOWED_CHINA_REGIONS = Arrays.asList("香港", "澳门", "台湾");
private static final String IP_CACHE_KEY_PREFIX = "IP_KEY_V2:";
private static final List<String> ALLOWED_CHINA_REGIONS = List.of(
"香港", "澳门", "澳門", "台湾", "台灣", "Hong Kong", "Macau", "Macao", "Taiwan"
);
private static final List<IpGeoProvider> 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);
}
}
}

View File

@ -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));
}
}

View File

@ -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.

View File

@ -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);

View File

@ -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<String> 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())

View File

@ -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字符串

View File

@ -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;
/**

View File

@ -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 获取国家二字码 USCNHK.
* 结果缓存到 Redis永久有效IP 归属地基本不变.
public class IpCountryUtils {
private static final String REDIS_KEY_PREFIX = "IP_COUNTRY_V2:";
private static final List<String> ALLOWED_CHINA_REGIONS = List.of(
"香港", "澳门", "澳門", "台湾", "台灣", "Hong Kong", "Macau", "Macao", "Taiwan"
);
private static final List<IpGeoProvider> 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 获取国家二字码 USCNHK.
* 结果缓存到 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);
}
}
}

View File

@ -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));
}
}

View File

@ -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);

View File

@ -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`;