feat: record login ip region metadata
This commit is contained in:
parent
eed2c7f05b
commit
41729137ed
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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字符串
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@ -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<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 获取国家二字码(如 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
7
sql/20260506_user_login_logger_ip_region.sql
Normal file
7
sql/20260506_user_login_logger_ip_region.sql
Normal 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`;
|
||||
Loading…
x
Reference in New Issue
Block a user