20260726225220

This commit is contained in:
oneao committed 2026-07-26 22:52:21 +08:00
1 parent e3c7c6f8da
commit a8478d60ed
170 files changed
+6786 -1678

No files matched your search

@@ -6,6 +6,7 @@ import cn.g3soft.fmsapi.utils.ApiResponse;
import cn.g3soft.fmsapi.utils.ParamUtils;
import cn.g3soft.fmsapi.utils.StringUtils;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -24,7 +25,8 @@ public class AuthController {
@PostMapping("/login")
public ApiResponse<Map<String, Object>> login(
@RequestBody(required = false) Map<String, Object> request
@RequestBody(required = false) Map<String, Object> request,
HttpServletRequest httpRequest
) throws SQLException {
String orgId = ParamUtils.getString(request, "orgid");
String userId = ParamUtils.getString(request, "userid");
@@ -40,7 +42,8 @@ public class AuthController {
Optional<Map<String, Object>> loginResponse = authService.login(
orgId,
userId,
password
password,
httpRequest
);
if (loginResponse.isEmpty()) {
return ApiResponse.fail(
@@ -3,6 +3,7 @@ package cn.g3soft.fmsapi.service;
import cn.g3soft.fmsapi.utils.DbUtils;
import cn.g3soft.fmsapi.utils.JwtUtils;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
@@ -27,10 +28,14 @@ public class AuthService {
@Resource
private ActiveSessionRegistry activeSessionRegistry;
@Resource
private LoginLogService loginLogService;
public Optional<Map<String, Object>> login(
String orgId,
String userId,
String password
String password,
HttpServletRequest request
) throws SQLException {
String normalizedOrgId = orgId.trim().toUpperCase(Locale.ROOT);
String normalizedUserId = userId.trim();
@@ -40,25 +45,36 @@ public class AuthService {
);
if (users.isEmpty()) {
loginLogService.recordLogin(
normalizedOrgId, normalizedUserId, null,
false, "账号不存在", null, request);
return Optional.empty();
}
Map<String, Object> account = users.getFirst();
Map<String, Object> account = users.get(0);
String storedPassword = getString(account, "b_password");
if (!passwordMatches(storedPassword, password)) {
loginLogService.recordLogin(
normalizedOrgId, normalizedUserId, null,
false, "密码错误", null, request);
return Optional.empty();
}
String accountId = getString(account, "b_account");
long userPrimaryKey = getLong(account, "b_id");
String name = getString(account, "b_name");
String sessionId = UUID.randomUUID().toString();
String token = jwtUtils.generateToken(accountId, normalizedOrgId, sessionId);
activeSessionRegistry.activate(normalizedOrgId, accountId, sessionId);
loginLogService.recordLogin(
normalizedOrgId, accountId, userPrimaryKey,
true, null, sessionId, request);
Map<String, Object> user = new LinkedHashMap<>();
user.put("id", userPrimaryKey);
user.put("account", accountId);
user.put("name", getString(account, "b_name"));
user.put("name", name);
Map<String, Object> result = new LinkedHashMap<>();
result.put("token", token);
@@ -0,0 +1,83 @@
package cn.g3soft.fmsapi.service;
import cn.g3soft.fmsapi.database.OrgContext;
import cn.g3soft.fmsapi.utils.ClientInfo;
import cn.g3soft.fmsapi.utils.ClientInfoExtractor;
import cn.g3soft.fmsapi.utils.snowflake.idgen.IdGenerator;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 登录日志服务:在登录成功或失败时采集客户端信息,并通过通用 saveobjt 机制写入 s_log_login 表。
* 写日志失败不影响登录主流程;机构上下文通过 OrgContext 临时设置并在 finally 中清理。
*/
@Service
public class LoginLogService {
private static final Logger log = LoggerFactory.getLogger(LoginLogService.class);
private final DataSaveService dataSaveService;
private final ClientInfoExtractor clientInfoExtractor;
public LoginLogService(DataSaveService dataSaveService, ClientInfoExtractor clientInfoExtractor) {
this.dataSaveService = dataSaveService;
this.clientInfoExtractor = clientInfoExtractor;
}
public void recordLogin(
String orgId,
String account,
Long userId,
boolean success,
String failReason,
String sessionId,
HttpServletRequest request
) {
if (orgId == null || orgId.isBlank()) {
return;
}
try {
ClientInfo info = clientInfoExtractor.extract(request);
Map<String, Object> row = new LinkedHashMap<>();
row.put("b_id", IdGenerator.nextId());
row.put("b_org_id", orgId);
row.put("b_user_id", userId);
row.put("b_account", account);
row.put("b_login_time", Timestamp.valueOf(LocalDateTime.now()));
row.put("b_ip", info.ip());
row.put("b_ip_location", info.ipLocation());
row.put("b_browser", info.browser());
row.put("b_browser_version", info.browserVersion());
row.put("b_os", info.os());
row.put("b_os_version", info.osVersion());
row.put("b_device_type", info.deviceType());
row.put("b_user_agent", info.userAgent());
row.put("b_login_result", success ? 1 : 0);
row.put("b_fail_reason", failReason);
row.put("b_session_id", sessionId);
Map<String, Object> tableOp = new LinkedHashMap<>();
tableOp.put("table", "s_log_login");
tableOp.put("key_field", "b_id");
tableOp.put("inserts", List.of(row));
OrgContext.setOrgId(orgId);
try {
dataSaveService.save(List.of(tableOp));
} finally {
OrgContext.clear();
}
} catch (Exception e) {
log.error("写入登录日志失败 orgId={} account={}", orgId, account, e);
}
}
}
@@ -0,0 +1,15 @@
package cn.g3soft.fmsapi.utils;
/**
* 登录时采集到的客户端环境信息载体。
*/
public record ClientInfo(
String ip,
String ipLocation,
String browser,
String browserVersion,
String os,
String osVersion,
String deviceType,
String userAgent) {
}
@@ -0,0 +1,110 @@
package cn.g3soft.fmsapi.utils;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import ua_parser.Client;
import ua_parser.Parser;
/**
* 组合 IpUtils、IpRegionSearcher 与 uap-java,从 HttpServletRequest 提取客户端环境信息。
*/
@Component
public class ClientInfoExtractor {
private static final Logger log = LoggerFactory.getLogger(ClientInfoExtractor.class);
private final IpRegionSearcher ipRegionSearcher;
// uap-java 的 Parser 加载一次正则表达式后即可安全复用(无状态、线程安全)
private final Parser uaParser = new Parser();
public ClientInfoExtractor(IpRegionSearcher ipRegionSearcher) {
this.ipRegionSearcher = ipRegionSearcher;
}
public ClientInfo extract(HttpServletRequest request) {
if (request == null) {
return new ClientInfo("unknown", "未知", "未知", "", "未知", "", "Unknown", null);
}
String ip = IpUtils.getClientIp(request);
String userAgent = request.getHeader("User-Agent");
String ipLocation = ipRegionSearcher.search(ip);
String browser = "未知";
String browserVersion = "";
String os = "未知";
String osVersion = "";
String deviceType = "Unknown";
if (userAgent != null && !userAgent.isBlank()) {
try {
Client client = uaParser.parse(userAgent);
if (client.userAgent != null) {
browser = orDefault(client.userAgent.family, "未知");
browserVersion = buildVersion(
client.userAgent.major, client.userAgent.minor, client.userAgent.patch);
}
if (client.os != null) {
os = orDefault(client.os.family, "未知");
osVersion = buildVersion(client.os.major, client.os.minor, client.os.patch);
}
if (client.device != null) {
deviceType = deriveDeviceType(client.device.family, userAgent);
}
} catch (Exception e) {
log.warn("User-Agent 解析失败: {}", userAgent, e);
}
}
return new ClientInfo(ip, ipLocation, browser, browserVersion, os, osVersion, deviceType, userAgent);
}
private static String buildVersion(String major, String minor, String patch) {
StringBuilder sb = new StringBuilder();
if (isNotBlank(major)) {
sb.append(major);
}
if (isNotBlank(minor)) {
sb.append('.').append(minor);
}
if (isNotBlank(patch)) {
sb.append('.').append(patch);
}
return sb.toString();
}
private static String deriveDeviceType(String deviceFamily, String userAgent) {
if (deviceFamily == null) {
return "Unknown";
}
String family = deviceFamily.toLowerCase();
if (family.contains("ipad") || family.contains("tablet") || family.contains("kindle")) {
return "Tablet";
}
if (family.contains("spider") || family.contains("bot") || family.contains("crawler")) {
return "Bot";
}
if (family.contains("iphone") || family.contains("android")
|| family.contains("windows phone") || family.contains("phone")) {
return "Mobile";
}
// device.family 通常为 "Other",再依据 UA 关键字推断
String ua = userAgent.toLowerCase();
if (ua.contains("mobile")) {
return "Mobile";
}
if (family.equals("other")) {
return "Desktop";
}
return "Desktop";
}
private static String orDefault(String value, String def) {
return (value == null || value.isBlank()) ? def : value;
}
private static boolean isNotBlank(String s) {
return s != null && !s.isBlank();
}
}
@@ -0,0 +1,114 @@
package cn.g3soft.fmsapi.utils;
import jakarta.annotation.PostConstruct;
import org.lionsoul.ip2region.xdb.LongByteArray;
import org.lionsoul.ip2region.xdb.Searcher;
import org.lionsoul.ip2region.xdb.Version;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
/**
* 基于本地 ip2region xdb 数据库的 IP 归属地查询。
* 启动时将 xdb 文件读入内存,避免每次查询的磁盘 IO。
*/
@Component
public class IpRegionSearcher {
private static final Logger log = LoggerFactory.getLogger(IpRegionSearcher.class);
private Searcher searcher;
@PostConstruct
public void init() {
try (InputStream in = new ClassPathResource("ip2region.xdb").getInputStream()) {
byte[] bytes = in.readAllBytes();
LongByteArray buffer = new LongByteArray();
buffer.append(bytes);
searcher = Searcher.newWithBuffer(Version.IPv4, buffer);
log.info("ip2region Searcher 初始化完成,数据大小 {} 字节", bytes.length);
} catch (IOException e) {
log.error("未找到 classpath 下的 ip2region.xdb,IP 归属地将返回「未知」", e);
searcher = null;
} catch (Exception e) {
log.error("ip2region Searcher 初始化失败,IP 归属地将返回「未知」", e);
searcher = null;
}
}
/**
* 根据 IP 查询归属地,返回格式如 国家|省|市|运营商。
* 内网/保留地址直接返回「内网」;任何异常或数据缺失时返回「未知」,不影响主流程。
*/
public String search(String ip) {
if (ip == null || ip.isBlank()) {
return "未知";
}
if (searcher == null) {
return "未知";
}
if (isInternalIp(ip)) {
return "内网";
}
try {
String region = searcher.search(ip);
return (region == null || region.isBlank()) ? "未知" : region;
} catch (Exception e) {
log.warn("ip2region 查询失败 ip={}", ip, e);
return "未知";
}
}
/**
* 判断是否为内网/保留地址(IPv4 RFC1918、回环、链路本地、未指定地址,以及 IPv6 回环/唯一本地地址)。
*/
private static boolean isInternalIp(String ip) {
if (ip.contains(".")) {
String v4 = ip;
int colon = ip.lastIndexOf(':');
if (colon >= 0 && ip.substring(colon + 1).contains(".")) {
v4 = ip.substring(colon + 1);
}
String[] parts = v4.split("\\.");
if (parts.length != 4) {
return false;
}
int[] o = new int[4];
try {
for (int i = 0; i < 4; i++) {
int n = Integer.parseUnsignedInt(parts[i]);
if (n > 255) {
return false;
}
o[i] = n;
}
} catch (NumberFormatException e) {
return false;
}
if (o[0] == 0) {
return true; // 0.0.0.0/8 未指定
}
if (o[0] == 10) {
return true; // 10.0.0.0/8
}
if (o[0] == 127) {
return true; // 127.0.0.0/8 回环
}
if (o[0] == 169 && o[1] == 254) {
return true; // 169.254.0.0/16 链路本地
}
if (o[0] == 172 && o[1] >= 16 && o[1] <= 31) {
return true; // 172.16.0.0/12
}
return o[0] == 192 && o[1] == 168; // 192.168.0.0/16
}
if (ip.equalsIgnoreCase("::1")) {
return true; // IPv6 回环
}
return ip.startsWith("fe80") || ip.startsWith("fc") || ip.startsWith("fd");
}
}
@@ -0,0 +1,51 @@
package cn.g3soft.fmsapi.utils;
import jakarta.servlet.http.HttpServletRequest;
/**
* 从 HttpServletRequest 中解析客户端真实 IP。
* 获取顺序兼容 Nginx 等反向代理:X-Real-IP → X-Forwarded-For(首个) → 远程地址。
*/
public final class IpUtils {
private static final String UNKNOWN = "unknown";
private IpUtils() {
}
public static String getClientIp(HttpServletRequest request) {
if (request == null) {
return UNKNOWN;
}
String ip = request.getHeader("X-Real-IP");
if (isEffective(ip)) {
return ip.trim();
}
ip = request.getHeader("X-Forwarded-For");
if (isEffective(ip)) {
// X-Forwarded-For 可能包含多个以逗号分隔的 IP,取第一个
return ip.split(",")[0].trim();
}
ip = request.getHeader("Proxy-Client-IP");
if (isEffective(ip)) {
return ip.trim();
}
ip = request.getHeader("WL-Proxy-Client-IP");
if (isEffective(ip)) {
return ip.trim();
}
ip = request.getRemoteAddr();
if (ip == null || ip.isBlank()) {
return UNKNOWN;
}
// 归一化 IPv6 本地回环地址
if ("0:0:0:0:0:0:0:1".equals(ip)) {
ip = "127.0.0.1";
}
return ip.trim();
}
private static boolean isEffective(String ip) {
return ip != null && !ip.isBlank() && !UNKNOWN.equalsIgnoreCase(ip.trim());
}
}
@@ -20,6 +20,7 @@ class AuthServiceTests {
private DbUtils dbUtils;
private JwtUtils jwtUtils;
private ActiveSessionRegistry activeSessionRegistry;
private LoginLogService loginLogService;
private AuthService authService;
@BeforeEach
@@ -27,6 +28,7 @@ class AuthServiceTests {
dbUtils = mock(DbUtils.class);
jwtUtils = mock(JwtUtils.class);
activeSessionRegistry = new ActiveSessionRegistry();
loginLogService = mock(LoginLogService.class);
authService = new AuthService();
ReflectionTestUtils.setField(authService, "dbUtils", dbUtils);
@@ -36,6 +38,7 @@ class AuthServiceTests {
"activeSessionRegistry",
activeSessionRegistry
);
ReflectionTestUtils.setField(authService, "loginLogService", loginLogService);
}
@Test
@@ -56,7 +59,7 @@ class AuthServiceTests {
))
.thenReturn("token");
Optional<Map<String, Object>> result = authService.login("fms", "g3soft", "");
Optional<Map<String, Object>> result = authService.login("fms", "g3soft", "", null);
assertThat(result).isPresent();
assertThat(result.orElseThrow()).containsEntry("token", "token");