diff --git a/code/fms/fms-api/config/dbconfigs/README.md b/code/fms/fms-api/config/dbconfigs/README.md index 54940590..024b9474 100644 --- a/code/fms/fms-api/config/dbconfigs/README.md +++ b/code/fms/fms-api/config/dbconfigs/README.md @@ -14,3 +14,14 @@ driver=com.microsoft.sqlserver.jdbc.SQLServerDriver ``` The `*.properties` files in this directory are ignored by Git. + +## Development startup for legacy SQL Server + +Run the following command from the `fms-api` directory when connecting to a +legacy SQL Server that only supports TLS 1.0: + +```batch +set JAVA_HOME=D:\devtool\jdk\jdk-21 +set PATH=%JAVA_HOME%\bin;%PATH% +mvn "-Dspring-boot.run.jvmArguments=-Djava.security.properties=./config/legacy-tls.security" spring-boot:run +``` diff --git a/code/fms/fms-api/config/legacy-tls.security b/code/fms/fms-api/config/legacy-tls.security new file mode 100644 index 00000000..882abf47 --- /dev/null +++ b/code/fms/fms-api/config/legacy-tls.security @@ -0,0 +1,3 @@ +# Development-only compatibility for SQL Server 2008 R2. +# Keep this override scoped to the fms-api JVM. +jdk.tls.disabledAlgorithms=SSLv3, TLSv1.1, DTLSv1.0, RC4, DES, MD5withRSA, DH keySize < 1024, EC keySize < 224, 3DES_EDE_CBC, anon, NULL, ECDH diff --git a/code/fms/fms-api/pom.xml b/code/fms/fms-api/pom.xml index 3659fe72..b55a8c78 100644 --- a/code/fms/fms-api/pom.xml +++ b/code/fms/fms-api/pom.xml @@ -43,6 +43,11 @@ druid 1.2.18 + + org.projectlombok + lombok + true + io.jsonwebtoken jjwt-api diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/FmsApiApplication.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/FmsApiApplication.java index 29e2d6e1..f3cc8c9b 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/FmsApiApplication.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/FmsApiApplication.java @@ -1,5 +1,7 @@ package cn.g3soft.fmsapi; +import cn.g3soft.fmsapi.utils.snowflake.contract.IdGeneratorOptions; +import cn.g3soft.fmsapi.utils.snowflake.idgen.IdGenerator; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @@ -9,6 +11,10 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan; public class FmsApiApplication { public static void main(String[] args) { + IdGeneratorOptions options = new IdGeneratorOptions(); + options.WorkerId = 1; + IdGenerator.setIdGenerator(options); + SpringApplication.run(FmsApiApplication.class, args); } diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/config/JwtAuthFilter.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/config/JwtAuthFilter.java index e64c4f80..275afff1 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/config/JwtAuthFilter.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/config/JwtAuthFilter.java @@ -1,6 +1,7 @@ package cn.g3soft.fmsapi.config; import cn.g3soft.fmsapi.database.OrgContext; +import cn.g3soft.fmsapi.service.ActiveSessionRegistry; import cn.g3soft.fmsapi.utils.ApiResponse; import cn.g3soft.fmsapi.utils.JwtUtils; import io.jsonwebtoken.JwtException; @@ -24,6 +25,9 @@ public class JwtAuthFilter extends OncePerRequestFilter { @Resource private JwtUtils jwtUtils; + @Resource + private ActiveSessionRegistry activeSessionRegistry; + @Resource private ObjectMapper objectMapper; @@ -39,16 +43,42 @@ public class JwtAuthFilter extends OncePerRequestFilter { HttpServletResponse response, FilterChain filterChain ) throws ServletException, IOException { - String orgId; + JwtUtils.TokenClaims claims; try { String token = getToken(request); - orgId = jwtUtils.getOrgId(token); - OrgContext.setOrgId(orgId); + claims = jwtUtils.parseToken(token); } catch (JwtException | IllegalArgumentException exception) { - writeUnauthorized(response); + writeUnauthorized( + response, + ApiResponse.AUTH_ERROR_CODE, + "未登录或登录已失效" + ); return; } + if (!activeSessionRegistry.hasActiveSession(claims.orgId(), claims.userId())) { + writeUnauthorized( + response, + ApiResponse.AUTH_ERROR_CODE, + "未登录或登录已失效" + ); + return; + } + + if (!activeSessionRegistry.isActive( + claims.orgId(), + claims.userId(), + claims.sessionId() + )) { + writeUnauthorized( + response, + ApiResponse.SESSION_REPLACED_CODE, + "账号已在其他设备登录,请重新登录" + ); + return; + } + + OrgContext.setOrgId(claims.orgId()); try { filterChain.doFilter(request, response); } finally { @@ -64,11 +94,12 @@ public class JwtAuthFilter extends OncePerRequestFilter { return authorization.substring(BEARER_PREFIX.length()).trim(); } - private void writeUnauthorized(HttpServletResponse response) throws IOException { - ApiResponse body = ApiResponse.fail( - ApiResponse.AUTH_ERROR_CODE, - "未登录或登录已失效" - ); + private void writeUnauthorized( + HttpServletResponse response, + int code, + String message + ) throws IOException { + ApiResponse body = ApiResponse.fail(code, message); response.setStatus(200); response.setCharacterEncoding("UTF-8"); response.setContentType("application/json"); diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/DataController.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/DataController.java index 6b37d779..5d0ce85f 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/DataController.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/DataController.java @@ -1,8 +1,11 @@ package cn.g3soft.fmsapi.controller; +import cn.g3soft.fmsapi.service.DataSaveService; import cn.g3soft.fmsapi.service.DataService; import cn.g3soft.fmsapi.utils.ApiResponse; +import cn.g3soft.fmsapi.utils.snowflake.idgen.IdGenerator; import jakarta.annotation.Resource; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -19,6 +22,9 @@ public class DataController { @Resource private DataService dataService; + @Resource + private DataSaveService dataSaveService; + @PostMapping("/loaddata") public ApiResponse>> loadData( @RequestBody(required = false) Map params @@ -32,4 +38,17 @@ public class DataController { ) throws SQLException { return ApiResponse.success(dataService.loadDataBySql(params)); } + + @PostMapping("/saveobjt") + public ApiResponse saveObject( + @RequestBody(required = false) List> requests + ) throws SQLException { + dataSaveService.save(requests); + return ApiResponse.success("保存成功", null); + } + + @GetMapping("/nextid") + public ApiResponse nextId() { + return ApiResponse.success(IdGenerator.nextId()); + } } diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.java index 18776c8a..2b251d83 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/exception/GlobalExceptionHandler.java @@ -9,6 +9,8 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.sql.SQLException; +import java.util.LinkedHashMap; +import java.util.Map; @RestControllerAdvice public class GlobalExceptionHandler { @@ -25,6 +27,24 @@ public class GlobalExceptionHandler { return ApiResponse.fail(exception.getMessage()); } + @ExceptionHandler(SaveObjectException.class) + public ApiResponse> handleSaveObjectException( + SaveObjectException exception + ) { + LOGGER.error( + "保存失败,table={},action={},index={}", + exception.getTable(), + exception.getAction(), + exception.getIndex(), + exception + ); + Map details = new LinkedHashMap<>(); + details.put("table", exception.getTable()); + details.put("action", exception.getAction()); + details.put("index", exception.getIndex()); + return new ApiResponse<>(ApiResponse.SAVE_ERROR_CODE, "保存失败", details); + } + @ExceptionHandler({SQLException.class, DataAccessException.class}) public ApiResponse handleDatabaseException( Exception exception, diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/exception/SaveObjectException.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/exception/SaveObjectException.java new file mode 100644 index 00000000..3e2d2c3c --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/exception/SaveObjectException.java @@ -0,0 +1,32 @@ +package cn.g3soft.fmsapi.exception; + +public class SaveObjectException extends RuntimeException { + + private final String table; + private final String action; + private final int index; + + public SaveObjectException( + String table, + String action, + int index, + Throwable cause + ) { + super("保存失败", cause); + this.table = table; + this.action = action; + this.index = index; + } + + public String getTable() { + return table; + } + + public String getAction() { + return action; + } + + public int getIndex() { + return index; + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ActiveSessionRegistry.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ActiveSessionRegistry.java new file mode 100644 index 00000000..91e532ff --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ActiveSessionRegistry.java @@ -0,0 +1,30 @@ +package cn.g3soft.fmsapi.service; + +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class ActiveSessionRegistry { + + private final Map activeSessions = new ConcurrentHashMap<>(); + + public void activate(String orgId, String userId, String sessionId) { + activeSessions.put(new AccountKey(orgId, userId), sessionId); + } + + public boolean isActive(String orgId, String userId, String sessionId) { + if (sessionId == null || sessionId.isBlank()) { + return false; + } + return sessionId.equals(activeSessions.get(new AccountKey(orgId, userId))); + } + + public boolean hasActiveSession(String orgId, String userId) { + return activeSessions.containsKey(new AccountKey(orgId, userId)); + } + + private record AccountKey(String orgId, String userId) { + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/AuthService.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/AuthService.java index 0e1a824c..3893f163 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/AuthService.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/AuthService.java @@ -13,6 +13,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.UUID; @Service public class AuthService { @@ -23,6 +24,9 @@ public class AuthService { @Resource private JwtUtils jwtUtils; + @Resource + private ActiveSessionRegistry activeSessionRegistry; + public Optional> login( String orgId, String userId, @@ -33,7 +37,8 @@ public class AuthService { List> users = dbUtils.loadData( normalizedOrgId, "b_user", - "b_id = " + dbUtils.toSqlStringLiteral(normalizedUserId), + "b_id = " + dbUtils.toSqlStringLiteral(normalizedUserId) + + " AND b_canuse = '1'", null, List.of("b_id", "b_name", "b_password") ); @@ -49,7 +54,9 @@ public class AuthService { } String accountId = getString(account, "b_id"); - String token = jwtUtils.generateToken(accountId, normalizedOrgId); + String sessionId = UUID.randomUUID().toString(); + String token = jwtUtils.generateToken(accountId, normalizedOrgId, sessionId); + activeSessionRegistry.activate(normalizedOrgId, accountId, sessionId); Map user = new LinkedHashMap<>(); user.put("id", accountId); diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataSaveService.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataSaveService.java new file mode 100644 index 00000000..8bebd7fc --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataSaveService.java @@ -0,0 +1,165 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.exception.BusinessException; +import cn.g3soft.fmsapi.exception.SaveObjectException; +import cn.g3soft.fmsapi.utils.DbUtils; +import cn.g3soft.fmsapi.utils.ParamUtils; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Service +public class DataSaveService { + + @Resource + private DataSource dataSource; + + @Resource + private DbUtils dbUtils; + + public void save(List> requests) throws SQLException { + if (requests == null || requests.isEmpty()) { + throw new BusinessException("保存数据不能为空"); + } + + try (Connection connection = dataSource.getConnection()) { + boolean originalAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + + try { + Map metadataCache = new LinkedHashMap<>(); + for (Map request : requests) { + saveTable(connection, request, metadataCache); + } + connection.commit(); + } catch (SQLException | RuntimeException exception) { + rollback(connection, exception); + throw exception; + } finally { + if (originalAutoCommit) { + connection.setAutoCommit(true); + } + } + } + } + + private void saveTable( + Connection connection, + Map request, + Map metadataCache + ) { + if (request == null) { + throw new BusinessException("表保存配置不能为空"); + } + + String tableName = ParamUtils.getRequiredString(request, "table"); + String keyField = ParamUtils.getRequiredString(request, "key_field"); + DbUtils.TableMetadata table = loadMetadata( + connection, + tableName, + metadataCache + ); + DbUtils.ColumnMetadata keyColumn = dbUtils.getColumn(table, keyField); + if (keyColumn == null) { + throw new BusinessException("主键字段不存在: " + tableName + "." + keyField); + } + + executeRows( + connection, + table, + keyColumn, + "delete", + rows(request, "deletes") + ); + executeRows( + connection, + table, + keyColumn, + "update", + rows(request, "updates") + ); + executeRows( + connection, + table, + keyColumn, + "insert", + rows(request, "inserts") + ); + } + + private DbUtils.TableMetadata loadMetadata( + Connection connection, + String tableName, + Map metadataCache + ) { + String cacheKey = tableName.toLowerCase(Locale.ROOT); + DbUtils.TableMetadata cached = metadataCache.get(cacheKey); + if (cached != null) { + return cached; + } + + try { + DbUtils.TableMetadata loaded = dbUtils.loadTableMetadata(connection, tableName); + metadataCache.put(cacheKey, loaded); + return loaded; + } catch (SQLException exception) { + throw new SaveObjectException(tableName, "metadata", -1, exception); + } + } + + private void executeRows( + Connection connection, + DbUtils.TableMetadata table, + DbUtils.ColumnMetadata keyColumn, + String action, + List> rows + ) { + for (int index = 0; index < rows.size(); index++) { + try { + switch (action) { + case "delete" -> dbUtils.delete(connection, table, keyColumn, rows.get(index)); + case "update" -> dbUtils.update(connection, table, keyColumn, rows.get(index)); + case "insert" -> dbUtils.insert(connection, table, rows.get(index)); + default -> throw new IllegalArgumentException("不支持的保存操作: " + action); + } + } catch (SQLException exception) { + throw new SaveObjectException(table.requestedName(), action, index, exception); + } + } + } + + private List> rows( + Map request, + String fieldName + ) { + List values = ParamUtils.getList(request, fieldName); + if (values == null) { + return List.of(); + } + + for (Object value : values) { + if (!(value instanceof Map)) { + throw new BusinessException(fieldName + " 参数类型不正确"); + } + } + + @SuppressWarnings("unchecked") + List> rows = (List>) (List) values; + return rows; + } + + private void rollback(Connection connection, Throwable originalException) { + try { + connection.rollback(); + } catch (SQLException rollbackException) { + originalException.addSuppressed(rollbackException); + } + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/ApiResponse.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/ApiResponse.java index 8d34eddd..eff4dbeb 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/ApiResponse.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/ApiResponse.java @@ -8,6 +8,8 @@ public record ApiResponse( public static final int SUCCESS_CODE = 0; public static final int AUTH_ERROR_CODE = 401; + public static final int SESSION_REPLACED_CODE = 40101; + public static final int SAVE_ERROR_CODE = 2001; public static final int SYSTEM_ERROR_CODE = 500; public static final int BUSINESS_ERROR_CODE = 1000; @@ -15,6 +17,10 @@ public record ApiResponse( return new ApiResponse<>(SUCCESS_CODE, "操作成功", data); } + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(SUCCESS_CODE, message, data); + } + public static ApiResponse fail(String message) { return fail(BUSINESS_ERROR_CODE, message); } diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java index 9d2dd5ec..ffdf997d 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java @@ -8,14 +8,17 @@ import org.springframework.stereotype.Component; import javax.sql.DataSource; import java.sql.Connection; +import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.sql.Statement; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -115,6 +118,114 @@ public class DbUtils { return executeQuery(connection, sql); } + public TableMetadata loadTableMetadata( + Connection connection, + String tableName + ) throws SQLException { + if (connection == null) { + throw new IllegalArgumentException("数据库连接不能为空"); + } + + String requestedName = requireText(tableName, "table"); + String quotedName = quoteQualifiedIdentifier(requestedName); + String sql = "SELECT * FROM " + quotedName + " WHERE 1 = 0"; + LinkedHashMap columns = new LinkedHashMap<>(); + + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(sql)) { + ResultSetMetaData metadata = resultSet.getMetaData(); + for (int index = 1; index <= metadata.getColumnCount(); index++) { + String columnName = metadata.getColumnName(index); + validateIdentifier(columnName); + String normalizedName = normalizeName(columnName); + String typeName = metadata.getColumnTypeName(index); + boolean generated = metadata.isAutoIncrement(index) + || metadata.isReadOnly(index) + || "timestamp".equalsIgnoreCase(typeName) + || "rowversion".equalsIgnoreCase(typeName); + columns.put( + normalizedName, + new ColumnMetadata(columnName, normalizedName, !generated) + ); + } + } + + if (columns.isEmpty()) { + throw new BusinessException("目标表没有可识别字段: " + requestedName); + } + return new TableMetadata(requestedName, quotedName, columns); + } + + public ColumnMetadata getColumn(TableMetadata table, String fieldName) { + String requiredFieldName = requireText(fieldName, "字段名"); + validateIdentifier(requiredFieldName); + return table.columnsByName().get(normalizeName(requiredFieldName)); + } + + public int insert( + Connection connection, + TableMetadata table, + Map row + ) throws SQLException { + Map normalizedRow = normalizeRow(row); + LinkedHashMap values = writableValues( + table, + normalizedRow, + null + ); + if (values.isEmpty()) { + throw new BusinessException("没有可新增的字段: " + table.requestedName()); + } + + String columnsSql = joinColumns(values.keySet().stream().toList()); + String placeholders = String.join(", ", Collections.nCopies(values.size(), "?")); + String sql = "INSERT INTO " + table.quotedName() + + " (" + columnsSql + ") VALUES (" + placeholders + ")"; + return executeWrite(connection, sql, new ArrayList<>(values.values()), "新增"); + } + + public int update( + Connection connection, + TableMetadata table, + ColumnMetadata keyColumn, + Map row + ) throws SQLException { + Map normalizedRow = normalizeRow(row); + Object keyValue = requiredKeyValue(normalizedRow, keyColumn, table.requestedName()); + LinkedHashMap values = writableValues( + table, + normalizedRow, + keyColumn.normalizedName() + ); + if (values.isEmpty()) { + throw new BusinessException("没有可修改的字段: " + table.requestedName()); + } + + String setSql = values.keySet().stream() + .map(column -> quoteColumn(column) + " = ?") + .collect(Collectors.joining(", ")); + String sql = "UPDATE " + table.quotedName() + + " SET " + setSql + + " WHERE " + quoteColumn(keyColumn) + " = ?"; + + List parameters = new ArrayList<>(values.values()); + parameters.add(keyValue); + return executeWrite(connection, sql, parameters, "修改"); + } + + public int delete( + Connection connection, + TableMetadata table, + ColumnMetadata keyColumn, + Map row + ) throws SQLException { + Map normalizedRow = normalizeRow(row); + Object keyValue = requiredKeyValue(normalizedRow, keyColumn, table.requestedName()); + String sql = "DELETE FROM " + table.quotedName() + + " WHERE " + quoteColumn(keyColumn) + " = ?"; + return executeWrite(connection, sql, List.of(keyValue), "删除"); + } + private List> executeQuery( Connection connection, String sql @@ -126,6 +237,24 @@ public class DbUtils { } } + private int executeWrite( + Connection connection, + String sql, + List parameters, + String action + ) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < parameters.size(); index++) { + statement.setObject(index + 1, parameters.get(index)); + } + int affectedRows = statement.executeUpdate(); + if (affectedRows != 1) { + throw new SQLException(action + "记录数异常: " + affectedRows); + } + return affectedRows; + } + } + public String toSqlStringLiteral(String value) { if (value == null) { return "NULL"; @@ -212,7 +341,7 @@ public class DbUtils { return quoteQualifiedIdentifier(parts[0]) + direction; } - private String quoteQualifiedIdentifier(String identifier) { + public String quoteQualifiedIdentifier(String identifier) { if (!hasText(identifier)) { throw new BusinessException("数据库标识符不能为空"); } @@ -223,6 +352,75 @@ public class DbUtils { .collect(Collectors.joining(".")); } + private LinkedHashMap writableValues( + TableMetadata table, + Map normalizedRow, + String excludedColumn + ) { + LinkedHashMap values = new LinkedHashMap<>(); + for (ColumnMetadata column : table.columnsByName().values()) { + if (!column.writable() + || column.normalizedName().equals(excludedColumn) + || !normalizedRow.containsKey(column.normalizedName())) { + continue; + } + values.put(column, normalizedRow.get(column.normalizedName())); + } + return values; + } + + private Object requiredKeyValue( + Map normalizedRow, + ColumnMetadata keyColumn, + String tableName + ) { + Object value = normalizedRow.get(keyColumn.normalizedName()); + if (value == null || value instanceof String text && text.isBlank()) { + throw new BusinessException( + "缺少主键字段: " + tableName + "." + keyColumn.name() + ); + } + return value; + } + + private Map normalizeRow(Map row) { + if (row == null) { + throw new BusinessException("保存记录不能为空"); + } + + Map normalized = new LinkedHashMap<>(); + for (Map.Entry entry : row.entrySet()) { + String fieldName = requireText(entry.getKey(), "字段名"); + String normalizedName = normalizeName(fieldName); + if (normalized.containsKey(normalizedName)) { + throw new BusinessException("保存记录包含重复字段: " + fieldName); + } + normalized.put(normalizedName, entry.getValue()); + } + return normalized; + } + + private String joinColumns(List columns) { + return columns.stream() + .map(this::quoteColumn) + .collect(Collectors.joining(", ")); + } + + private String quoteColumn(ColumnMetadata column) { + return quoteQualifiedIdentifier(column.name()); + } + + private String requireText(String value, String fieldName) { + if (!hasText(value)) { + throw new BusinessException(fieldName + " 不能为空"); + } + return value.trim(); + } + + private String normalizeName(String value) { + return value.trim().toLowerCase(Locale.ROOT); + } + private List> readRows(ResultSet resultSet) throws SQLException { ResultSetMetaData metadata = resultSet.getMetaData(); int columnCount = metadata.getColumnCount(); @@ -242,4 +440,18 @@ public class DbUtils { private boolean hasText(String value) { return value != null && !value.isBlank(); } + + public record ColumnMetadata( + String name, + String normalizedName, + boolean writable + ) { + } + + public record TableMetadata( + String requestedName, + String quotedName, + LinkedHashMap columnsByName + ) { + } } diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/JwtUtils.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/JwtUtils.java index 0852b1f8..cea65f90 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/JwtUtils.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/JwtUtils.java @@ -1,6 +1,8 @@ package cn.g3soft.fmsapi.utils; import cn.g3soft.fmsapi.config.AuthProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import jakarta.annotation.PostConstruct; @@ -21,6 +23,7 @@ public class JwtUtils { private SecretKey signingKey; private Duration expiration; + private JwtParser jwtParser; @PostConstruct public void init() { @@ -36,14 +39,16 @@ public class JwtUtils { this.signingKey = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); this.expiration = properties.getJwtExpiration(); + this.jwtParser = Jwts.parser().verifyWith(signingKey).build(); } - public String generateToken(String userId, String orgId) { + public String generateToken(String userId, String orgId, String sessionId) { Instant issuedAt = Instant.now(); Instant expiresAt = issuedAt.plus(expiration); return Jwts.builder() .subject(userId) + .id(sessionId) .claim("orgid", orgId) .issuedAt(Date.from(issuedAt)) .expiration(Date.from(expiresAt)) @@ -51,12 +56,26 @@ public class JwtUtils { .compact(); } + public TokenClaims parseToken(String token) { + Claims claims = jwtParser.parseSignedClaims(token).getPayload(); + return new TokenClaims( + required(claims.getSubject(), "用户ID"), + required(claims.get("orgid", String.class), "机构码"), + required(claims.getId(), "会话ID") + ); + } + public String getOrgId(String token) { - return Jwts.parser() - .verifyWith(signingKey) - .build() - .parseSignedClaims(token) - .getPayload() - .get("orgid", String.class); + return parseToken(token).orgId(); + } + + private String required(String value, String claimName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("登录凭证缺少" + claimName); + } + return value; + } + + public record TokenClaims(String userId, String orgId, String sessionId) { } } diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/contract/IIdGenerator.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/contract/IIdGenerator.java new file mode 100644 index 00000000..3074dd68 --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/contract/IIdGenerator.java @@ -0,0 +1,5 @@ +package cn.g3soft.fmsapi.utils.snowflake.contract; + +public interface IIdGenerator { + long newLong() throws IdGeneratorException; +} \ No newline at end of file diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/contract/ISnowWorker.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/contract/ISnowWorker.java new file mode 100644 index 00000000..a43f0777 --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/contract/ISnowWorker.java @@ -0,0 +1,5 @@ +package cn.g3soft.fmsapi.utils.snowflake.contract; + +public interface ISnowWorker { + long nextId() throws IdGeneratorException; +} \ No newline at end of file diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorException.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorException.java new file mode 100644 index 00000000..32af7fea --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorException.java @@ -0,0 +1,25 @@ +package cn.g3soft.fmsapi.utils.snowflake.contract; + +public class IdGeneratorException extends RuntimeException { + + public IdGeneratorException() { + super(); + } + + public IdGeneratorException(String message) { + super(message); + } + + public IdGeneratorException(Throwable cause) { + super(cause); + } + + public IdGeneratorException(String message, Throwable cause) { + super(message, cause); + } + + public IdGeneratorException(String msgFormat, Object... args) { + super(String.format(msgFormat, args)); + } + +} \ No newline at end of file diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorOptions.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorOptions.java new file mode 100644 index 00000000..9e96bd0b --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/contract/IdGeneratorOptions.java @@ -0,0 +1,60 @@ +package cn.g3soft.fmsapi.utils.snowflake.contract; + +public class IdGeneratorOptions { + + /** + * 雪花计算方法 + * (1-漂移算法|2-传统算法),默认1 + */ + public short Method = 1; + + /** + * 基础时间(ms单位) + * 不能超过当前系统时间 + */ + public long BaseTime = 1582136402000L; + + /** + * 机器码 + * 必须由外部设定,最大值 2^WorkerIdBitLength-1 + */ + public short WorkerId = 0; + + /** + * 机器码位长 + * 默认值6,取值范围 [1, 15](要求:序列数位长+机器码位长不超过22) + */ + public byte WorkerIdBitLength = 6; + + /** + * 序列数位长 + * 默认值6,取值范围 [3, 21](要求:序列数位长+机器码位长不超过22) + */ + public byte SeqBitLength = 6; + + /** + * 最大序列数(含) + * 设置范围 [MinSeqNumber, 2^SeqBitLength-1],默认值0,表示最大序列数取最大值(2^SeqBitLength-1]) + */ + public short MaxSeqNumber = 0; + + /** + * 最小序列数(含) + * 默认值5,取值范围 [5, MaxSeqNumber],每毫秒的前5个序列数对应编号是0-4是保留位,其中1-4是时间回拨相应预留位,0是手工新值预留位 + */ + public short MinSeqNumber = 5; + + /** + * 最大漂移次数(含) + * 默认2000,推荐范围500-10000(与计算能力有关) + */ + public short TopOverCostCount = 2000; + + public IdGeneratorOptions() { + + } + + public IdGeneratorOptions(short workerId) { + WorkerId = workerId; + } +} \ No newline at end of file diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/contract/OverCostActionArg.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/contract/OverCostActionArg.java new file mode 100644 index 00000000..67d8d804 --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/contract/OverCostActionArg.java @@ -0,0 +1,45 @@ +package cn.g3soft.fmsapi.utils.snowflake.contract; +/** + * Id生成时回调参数 + */ +public class OverCostActionArg { + /** + * 事件类型 + * 1-开始,2-结束,8-漂移 + */ + public int ActionType = 0; + + /** + * 时间戳 + */ + public long TimeTick = 0; + + /** + * 机器码 + */ + public short WorkerId = 0; + + /** + * + */ + public int OverCostCountInOneTerm = 0; + + /** + * 漂移期间生产ID个数 + */ + public int GenCountInOneTerm = 0; + + /** + * 漂移周期 + */ + public int TermIndex = 0; + + public OverCostActionArg(short workerId, long timeTick, int actionType, int overCostCountInOneTerm, int genCountWhenOverCost, int index) { + ActionType = actionType; + TimeTick = timeTick; + WorkerId = workerId; + OverCostCountInOneTerm = overCostCountInOneTerm; + GenCountInOneTerm = genCountWhenOverCost; + TermIndex = index; + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM1.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM1.java new file mode 100644 index 00000000..18beb5e2 --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM1.java @@ -0,0 +1,229 @@ +package cn.g3soft.fmsapi.utils.snowflake.core; + +import cn.g3soft.fmsapi.utils.snowflake.contract.ISnowWorker; +import cn.g3soft.fmsapi.utils.snowflake.contract.IdGeneratorException; +import cn.g3soft.fmsapi.utils.snowflake.contract.IdGeneratorOptions; +import cn.g3soft.fmsapi.utils.snowflake.contract.OverCostActionArg; + +public class SnowWorkerM1 implements ISnowWorker { + /** + * 基础时间 + */ + protected final long BaseTime; + + /** + * 机器码 + */ + protected final short WorkerId; + + /** + * 机器码位长 + */ + protected final byte WorkerIdBitLength; + + /** + * 自增序列数位长 + */ + protected final byte SeqBitLength; + + /** + * 最大序列数(含) + */ + protected final int MaxSeqNumber; + + /** + * 最小序列数(含) + */ + protected final short MinSeqNumber; + + /** + * 最大漂移次数(含) + */ + protected final int TopOverCostCount; + + protected final byte _TimestampShift; + protected final static byte[] _SyncLock = new byte[0]; + + protected short _CurrentSeqNumber; + protected long _LastTimeTick = 0; + protected long _TurnBackTimeTick = 0; + protected byte _TurnBackIndex = 0; + + protected boolean _IsOverCost = false; + protected int _OverCostCountInOneTerm = 0; + protected int _GenCountInOneTerm = 0; + protected int _TermIndex = 0; + + public SnowWorkerM1(IdGeneratorOptions options) { + BaseTime = options.BaseTime != 0 ? options.BaseTime : 1582136402000L; + WorkerIdBitLength = options.WorkerIdBitLength == 0 ? 6 : options.WorkerIdBitLength; + WorkerId = options.WorkerId; + SeqBitLength = options.SeqBitLength == 0 ? 6 : options.SeqBitLength; + MaxSeqNumber = options.MaxSeqNumber <= 0 ? (1 << SeqBitLength) - 1 : options.MaxSeqNumber; + MinSeqNumber = options.MinSeqNumber; + // TopOverCostCount = options.TopOverCostCount == 0 ? 2000 : options.TopOverCostCount; + TopOverCostCount = options.TopOverCostCount; + _TimestampShift = (byte) (WorkerIdBitLength + SeqBitLength); + _CurrentSeqNumber = MinSeqNumber; + } + + private void DoGenIdAction(OverCostActionArg arg) { + + } + + private void BeginOverCostAction(long useTimeTick) { + + } + + private void EndOverCostAction(long useTimeTick) { + // if (_TermIndex > 10000) { + // _TermIndex = 0; + // } + } + + private void BeginTurnBackAction(long useTimeTick) { + + } + + private void EndTurnBackAction(long useTimeTick) { + + } + + private long NextOverCostId() { + long currentTimeTick = GetCurrentTimeTick(); + + if (currentTimeTick > _LastTimeTick) { + EndOverCostAction(currentTimeTick); + + _LastTimeTick = currentTimeTick; + _CurrentSeqNumber = MinSeqNumber; + _IsOverCost = false; + _OverCostCountInOneTerm = 0; + _GenCountInOneTerm = 0; + + return CalcId(_LastTimeTick); + } + + if (_OverCostCountInOneTerm >= TopOverCostCount) { + EndOverCostAction(currentTimeTick); + + _LastTimeTick = GetNextTimeTick(); + _CurrentSeqNumber = MinSeqNumber; + _IsOverCost = false; + _OverCostCountInOneTerm = 0; + _GenCountInOneTerm = 0; + + return CalcId(_LastTimeTick); + } + + if (_CurrentSeqNumber > MaxSeqNumber) { + _LastTimeTick++; + _CurrentSeqNumber = MinSeqNumber; + _IsOverCost = true; + _OverCostCountInOneTerm++; + _GenCountInOneTerm++; + + return CalcId(_LastTimeTick); + } + + _GenCountInOneTerm++; + return CalcId(_LastTimeTick); + } + + private long NextNormalId() throws IdGeneratorException { + long currentTimeTick = GetCurrentTimeTick(); + + if (currentTimeTick < _LastTimeTick) { + if (_TurnBackTimeTick < 1) { + _TurnBackTimeTick = _LastTimeTick - 1; + _TurnBackIndex++; + // 每毫秒序列数的前5位是预留位,0用于手工新值,1-4是时间回拨次序 + // 支持4次回拨次序(避免回拨重叠导致ID重复),可无限次回拨(次序循环使用)。 + if (_TurnBackIndex > 4) { + _TurnBackIndex = 1; + } + //BeginTurnBackAction(_TurnBackTimeTick); + } + + // try { + // Thread.sleep(1); + // } catch (InterruptedException e) { + // e.printStackTrace(); + // } + + return CalcTurnBackId(_TurnBackTimeTick); + } + + // 时间追平时,_TurnBackTimeTick清零 + if (_TurnBackTimeTick > 0) { + EndTurnBackAction(_TurnBackTimeTick); + _TurnBackTimeTick = 0; + } + + if (currentTimeTick > _LastTimeTick) { + _LastTimeTick = currentTimeTick; + _CurrentSeqNumber = MinSeqNumber; + + return CalcId(_LastTimeTick); + } + + if (_CurrentSeqNumber > MaxSeqNumber) { + BeginOverCostAction(currentTimeTick); + + _TermIndex++; + _LastTimeTick++; + _CurrentSeqNumber = MinSeqNumber; + _IsOverCost = true; + _OverCostCountInOneTerm = 1; + _GenCountInOneTerm = 1; + + return CalcId(_LastTimeTick); + } + + return CalcId(_LastTimeTick); + } + + private long CalcId(long useTimeTick) { + long result = ((useTimeTick << _TimestampShift) + + ((long) WorkerId << SeqBitLength) + + (int) _CurrentSeqNumber); + + _CurrentSeqNumber++; + return result; + } + + private long CalcTurnBackId(long useTimeTick) { + long result = ((useTimeTick << _TimestampShift) + + ((long) WorkerId << SeqBitLength) + _TurnBackIndex); + + _TurnBackTimeTick--; + return result; + } + + protected long GetCurrentTimeTick() { + long millis = System.currentTimeMillis(); + return millis - BaseTime; + } + + protected long GetNextTimeTick() { + long tempTimeTicker = GetCurrentTimeTick(); + + while (tempTimeTicker <= _LastTimeTick) { + try { + Thread.sleep(1); + } catch (InterruptedException e) { + e.printStackTrace(); + } + tempTimeTicker = GetCurrentTimeTick(); + } + + return tempTimeTicker; + } + + @Override + public long nextId() { + synchronized (_SyncLock) { + return _IsOverCost ? NextOverCostId() : NextNormalId(); + } + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM2.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM2.java new file mode 100644 index 00000000..c01fccd0 --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/core/SnowWorkerM2.java @@ -0,0 +1,37 @@ +package cn.g3soft.fmsapi.utils.snowflake.core; + + +import cn.g3soft.fmsapi.utils.snowflake.contract.IdGeneratorException; +import cn.g3soft.fmsapi.utils.snowflake.contract.IdGeneratorOptions; + +public class SnowWorkerM2 extends SnowWorkerM1 { + public SnowWorkerM2(IdGeneratorOptions options) { + super(options); + } + + @Override + public long nextId() { + synchronized (_SyncLock) { + long currentTimeTick = GetCurrentTimeTick(); + + if (_LastTimeTick == currentTimeTick) { + if (_CurrentSeqNumber++ > MaxSeqNumber) { + _CurrentSeqNumber = MinSeqNumber; + currentTimeTick = GetNextTimeTick(); + } + } else { + _CurrentSeqNumber = MinSeqNumber; + } + + if (currentTimeTick < _LastTimeTick) { + throw new IdGeneratorException("Time error for {0} milliseconds", _LastTimeTick - currentTimeTick); + } + + _LastTimeTick = currentTimeTick; + long result = ((currentTimeTick << _TimestampShift) + ((long) WorkerId << SeqBitLength) + (int) _CurrentSeqNumber); + + return result; + } + + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/idgen/DefaultIdGenerator.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/idgen/DefaultIdGenerator.java new file mode 100644 index 00000000..d6b3083f --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/idgen/DefaultIdGenerator.java @@ -0,0 +1,90 @@ +package cn.g3soft.fmsapi.utils.snowflake.idgen; + + +import cn.g3soft.fmsapi.utils.snowflake.contract.IIdGenerator; +import cn.g3soft.fmsapi.utils.snowflake.contract.ISnowWorker; +import cn.g3soft.fmsapi.utils.snowflake.contract.IdGeneratorException; +import cn.g3soft.fmsapi.utils.snowflake.contract.IdGeneratorOptions; +import cn.g3soft.fmsapi.utils.snowflake.core.SnowWorkerM1; +import cn.g3soft.fmsapi.utils.snowflake.core.SnowWorkerM2; + +public class DefaultIdGenerator implements IIdGenerator { + + private static ISnowWorker _SnowWorker = null; + + public DefaultIdGenerator(IdGeneratorOptions options) throws IdGeneratorException { + if (options == null) { + throw new IdGeneratorException("options error."); + } + + // 1.BaseTime + if (options.BaseTime < 315504000000L || options.BaseTime > System.currentTimeMillis()) { + throw new IdGeneratorException("BaseTime error."); + } + + // 2.WorkerIdBitLength + if (options.WorkerIdBitLength <= 0) { + throw new IdGeneratorException("WorkerIdBitLength error.(range:[1, 21])"); + } + if (options.WorkerIdBitLength + options.SeqBitLength > 22) { + throw new IdGeneratorException("error:WorkerIdBitLength + SeqBitLength <= 22"); + } + + // 3.WorkerId + int maxWorkerIdNumber = (1 << options.WorkerIdBitLength) - 1; + if (maxWorkerIdNumber == 0) { + maxWorkerIdNumber = 63; + } + if (options.WorkerId < 0 || options.WorkerId > maxWorkerIdNumber) { + throw new IdGeneratorException( + "WorkerId error. (range:[0, " + (maxWorkerIdNumber > 0 ? maxWorkerIdNumber : 63) + "]"); + } + + // 4.SeqBitLength + if (options.SeqBitLength < 2 || options.SeqBitLength > 21) { + throw new IdGeneratorException("SeqBitLength error. (range:[2, 21])"); + } + + // 5.MaxSeqNumber + int maxSeqNumber = (1 << options.SeqBitLength) - 1; + if (maxSeqNumber == 0) { + maxSeqNumber = 63; + } + if (options.MaxSeqNumber < 0 || options.MaxSeqNumber > maxSeqNumber) { + throw new IdGeneratorException("MaxSeqNumber error. (range:[1, " + maxSeqNumber + "]"); + } + + // 6.MinSeqNumber + if (options.MinSeqNumber < 5 || options.MinSeqNumber > maxSeqNumber) { + throw new IdGeneratorException("MinSeqNumber error. (range:[5, " + maxSeqNumber + "]"); + } + + // 7.TopOverCostCount + if (options.TopOverCostCount < 0 || options.TopOverCostCount > 10000) { + throw new IdGeneratorException("TopOverCostCount error. (range:[0, 10000]"); + } + + switch (options.Method) { + case 2: + _SnowWorker = new SnowWorkerM2(options); + break; + case 1: + default: + _SnowWorker = new SnowWorkerM1(options); + break; + } + + if (options.Method == 1) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + } + + @Override + public long newLong() { + return _SnowWorker.nextId(); + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/idgen/IdGenerator.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/idgen/IdGenerator.java new file mode 100644 index 00000000..2dcb7abf --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/snowflake/idgen/IdGenerator.java @@ -0,0 +1,34 @@ +package cn.g3soft.fmsapi.utils.snowflake.idgen; + +import cn.g3soft.fmsapi.utils.snowflake.contract.IIdGenerator; +import cn.g3soft.fmsapi.utils.snowflake.contract.IdGeneratorException; +import cn.g3soft.fmsapi.utils.snowflake.contract.IdGeneratorOptions; +import lombok.Getter; + +public class IdGenerator { + @Getter + private static IIdGenerator idGenInstance = null; + + /** + * 设置参数,建议程序初始化时执行一次 + */ + public static void setIdGenerator(IdGeneratorOptions options) throws IdGeneratorException { + idGenInstance = new DefaultIdGenerator(options); + } + + /** + * 生成新的Id + * 调用本方法前,请确保调用了 SetIdGenerator 方法做初始化。 + * + */ + public static long nextId() throws IdGeneratorException { + // if (idGenInstance == null) { + // idGenInstance = new DefaultIdGenerator(new IdGeneratorOptions((short) 1)); + // } + + if (idGenInstance == null) + throw new IdGeneratorException("Please initialize Yitter.IdGeneratorOptions first."); + + return idGenInstance.newLong(); + } +} diff --git a/code/fms/fms-api/src/main/resources/application.yaml b/code/fms/fms-api/src/main/resources/application.yaml index a6c149d2..7332141a 100644 --- a/code/fms/fms-api/src/main/resources/application.yaml +++ b/code/fms/fms-api/src/main/resources/application.yaml @@ -1,4 +1,5 @@ server: + port: 8088 servlet: context-path: /api diff --git a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.java b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.java index 1c533686..7ad9b9bb 100644 --- a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.java +++ b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/exception/GlobalExceptionHandlerTests.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import java.sql.SQLException; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -24,4 +25,23 @@ class GlobalExceptionHandlerTests { assertThat(response.message()).isEqualTo("系统异常,请稍后重试"); assertThat(response.data()).isNull(); } + + @Test + void saveExceptionReturnsOperationLocation() { + GlobalExceptionHandler handler = new GlobalExceptionHandler(); + SaveObjectException exception = new SaveObjectException( + "bs_business", + "update", + 2, + new SQLException("update failed") + ); + + ApiResponse> response = handler.handleSaveObjectException(exception); + + assertThat(response.code()).isEqualTo(ApiResponse.SAVE_ERROR_CODE); + assertThat(response.message()).isEqualTo("保存失败"); + assertThat(response.data()).containsEntry("table", "bs_business"); + assertThat(response.data()).containsEntry("action", "update"); + assertThat(response.data()).containsEntry("index", 2); + } } diff --git a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.java b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.java new file mode 100644 index 00000000..5342d937 --- /dev/null +++ b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.java @@ -0,0 +1,31 @@ +package cn.g3soft.fmsapi.service; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ActiveSessionRegistryTests { + + private final ActiveSessionRegistry registry = new ActiveSessionRegistry(); + + @Test + void newerLoginReplacesPreviousSessionForSameAccount() { + assertThat(registry.hasActiveSession("G3HD", "USER_A")).isFalse(); + + registry.activate("G3HD", "USER_A", "session-a"); + registry.activate("G3HD", "USER_A", "session-b"); + + assertThat(registry.hasActiveSession("G3HD", "USER_A")).isTrue(); + assertThat(registry.isActive("G3HD", "USER_A", "session-a")).isFalse(); + assertThat(registry.isActive("G3HD", "USER_A", "session-b")).isTrue(); + } + + @Test + void sameUserIdInDifferentOrganizationsHasIndependentSessions() { + registry.activate("G3HD", "USER_A", "g3hd-session"); + registry.activate("OTHER", "USER_A", "other-session"); + + assertThat(registry.isActive("G3HD", "USER_A", "g3hd-session")).isTrue(); + assertThat(registry.isActive("OTHER", "USER_A", "other-session")).isTrue(); + } +} diff --git a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/DataSaveServiceTests.java b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/DataSaveServiceTests.java new file mode 100644 index 00000000..5501cfbc --- /dev/null +++ b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/DataSaveServiceTests.java @@ -0,0 +1,133 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.exception.SaveObjectException; +import cn.g3soft.fmsapi.utils.DbUtils; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class DataSaveServiceTests { + + @Test + void savesDirectArrayInOneTransactionAndKeepsEmptyString() throws Exception { + JdbcFixture fixture = new JdbcFixture("b_id", "b_amount"); + DataSaveService service = fixture.createService(); + + Map request = Map.of( + "table", "b_user", + "key_field", "b_id", + "inserts", List.of(Map.of("b_id", "I001", "b_amount", "")), + "updates", List.of(Map.of("b_id", "U001", "b_amount", "12")), + "deletes", List.of(Map.of("b_id", "D001")) + ); + + service.save(List.of(request)); + + var ordered = inOrder( + fixture.connection, + fixture.deleteStatement, + fixture.updateStatement, + fixture.insertStatement + ); + ordered.verify(fixture.connection).setAutoCommit(false); + ordered.verify(fixture.deleteStatement).executeUpdate(); + ordered.verify(fixture.updateStatement).executeUpdate(); + ordered.verify(fixture.insertStatement).executeUpdate(); + ordered.verify(fixture.connection).commit(); + ordered.verify(fixture.connection).setAutoCommit(true); + + verify(fixture.deleteStatement).setObject(1, "D001"); + verify(fixture.updateStatement).setObject(1, "12"); + verify(fixture.updateStatement).setObject(2, "U001"); + verify(fixture.insertStatement).setObject(1, "I001"); + verify(fixture.insertStatement).setObject(2, ""); + } + + @Test + void sqlFailureRollsBackAndReportsOperationLocation() throws Exception { + JdbcFixture fixture = new JdbcFixture("b_id", "b_name"); + DataSaveService service = fixture.createService(); + when(fixture.updateStatement.executeUpdate()).thenThrow(new SQLException("update failed")); + + Map request = Map.of( + "table", "b_user", + "key_field", "b_id", + "inserts", List.of(), + "updates", List.of(Map.of("b_id", "U001", "b_name", "User")), + "deletes", List.of() + ); + + assertThatThrownBy(() -> service.save(List.of(request))) + .isInstanceOfSatisfying(SaveObjectException.class, exception -> { + assertThat(exception.getTable()).isEqualTo("b_user"); + assertThat(exception.getAction()).isEqualTo("update"); + assertThat(exception.getIndex()).isZero(); + }); + + verify(fixture.connection).rollback(); + verify(fixture.connection, org.mockito.Mockito.never()).commit(); + } + + private static final class JdbcFixture { + + private final DataSource dataSource = mock(DataSource.class); + private final Connection connection = mock(Connection.class); + private final Statement metadataStatement = mock(Statement.class); + private final ResultSet metadataResultSet = mock(ResultSet.class); + private final ResultSetMetaData metadata = mock(ResultSetMetaData.class); + private final PreparedStatement deleteStatement = mock(PreparedStatement.class); + private final PreparedStatement updateStatement = mock(PreparedStatement.class); + private final PreparedStatement insertStatement = mock(PreparedStatement.class); + + private JdbcFixture(String firstColumn, String secondColumn) throws SQLException { + when(dataSource.getConnection()).thenReturn(connection); + when(connection.getAutoCommit()).thenReturn(true); + when(connection.createStatement()).thenReturn(metadataStatement); + when(metadataStatement.executeQuery("SELECT * FROM [b_user] WHERE 1 = 0")) + .thenReturn(metadataResultSet); + when(metadataResultSet.getMetaData()).thenReturn(metadata); + when(metadata.getColumnCount()).thenReturn(2); + when(metadata.getColumnName(1)).thenReturn(firstColumn); + when(metadata.getColumnName(2)).thenReturn(secondColumn); + when(metadata.getColumnTypeName(1)).thenReturn("varchar"); + when(metadata.getColumnTypeName(2)).thenReturn("varchar"); + when(metadata.isAutoIncrement(1)).thenReturn(false); + when(metadata.isAutoIncrement(2)).thenReturn(false); + + when(connection.prepareStatement("DELETE FROM [b_user] WHERE [b_id] = ?")) + .thenReturn(deleteStatement); + when(connection.prepareStatement( + "UPDATE [b_user] SET [" + secondColumn + "] = ? WHERE [b_id] = ?" + )).thenReturn(updateStatement); + when(connection.prepareStatement( + "INSERT INTO [b_user] ([b_id], [" + secondColumn + "]) VALUES (?, ?)" + )).thenReturn(insertStatement); + when(deleteStatement.executeUpdate()).thenReturn(1); + when(updateStatement.executeUpdate()).thenReturn(1); + when(insertStatement.executeUpdate()).thenReturn(1); + } + + private DataSaveService createService() { + DataSaveService service = new DataSaveService(); + ReflectionTestUtils.setField(service, "dataSource", dataSource); + ReflectionTestUtils.setField(service, "dbUtils", new DbUtils()); + return service; + } + } +} diff --git a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/utils/JwtUtilsTests.java b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/utils/JwtUtilsTests.java new file mode 100644 index 00000000..f6190c52 --- /dev/null +++ b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/utils/JwtUtilsTests.java @@ -0,0 +1,37 @@ +package cn.g3soft.fmsapi.utils; + +import cn.g3soft.fmsapi.config.AuthProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +class JwtUtilsTests { + + private JwtUtils jwtUtils; + + @BeforeEach + void setUp() { + AuthProperties properties = new AuthProperties(); + properties.setJwtSecret("test-jwt-secret-with-at-least-32-bytes"); + properties.setJwtExpiration(Duration.ofHours(1)); + + jwtUtils = new JwtUtils(); + ReflectionTestUtils.setField(jwtUtils, "properties", properties); + jwtUtils.init(); + } + + @Test + void tokenContainsAccountAndSessionClaims() { + String token = jwtUtils.generateToken("USER_A", "G3HD", "session-a"); + + JwtUtils.TokenClaims claims = jwtUtils.parseToken(token); + + assertThat(claims.userId()).isEqualTo("USER_A"); + assertThat(claims.orgId()).isEqualTo("G3HD"); + assertThat(claims.sessionId()).isEqualTo("session-a"); + } +} diff --git a/code/fms/fms-vue/index.html b/code/fms/fms-vue/index.html index 99b16711..9c935825 100644 --- a/code/fms/fms-vue/index.html +++ b/code/fms/fms-vue/index.html @@ -5,7 +5,7 @@ - FMS 管理系统 + FMS 货代物流管理系统
diff --git a/code/fms/fms-vue/src/layouts/components/AppSidebar.vue b/code/fms/fms-vue/src/layouts/components/AppSidebar.vue index 6d9ff3d1..3de9e3d3 100644 --- a/code/fms/fms-vue/src/layouts/components/AppSidebar.vue +++ b/code/fms/fms-vue/src/layouts/components/AppSidebar.vue @@ -23,9 +23,9 @@ const menuIcon = (icon) => () => h(icon, { size: 16, strokeWidth: 1.8 }); const menuItems = [ { - key: "/home", + key: "/dashboard", icon: menuIcon(House), - label: "首页", + label: "工作台", }, { key: "business", diff --git a/code/fms/fms-vue/src/router/index.js b/code/fms/fms-vue/src/router/index.js index 4d7cfbde..d92d8fdf 100644 --- a/code/fms/fms-vue/src/router/index.js +++ b/code/fms/fms-vue/src/router/index.js @@ -145,7 +145,9 @@ const router = createRouter({ router.beforeEach((to) => { NProgress.start(); - document.title = to.meta.title ? `${to.meta.title} - FMS 管理系统` : "FMS 管理系统"; + document.title = to.meta.title + ? `${to.meta.title} - FMS 货代物流管理系统` + : "FMS 货代物流管理系统"; const authStore = useAuthStore(); if (to.meta.requiresAuth && !authStore.isAuthenticated) { diff --git a/code/fms/fms-vue/src/services/api.js b/code/fms/fms-vue/src/services/api.js index 19a9fe50..660e6ccf 100644 --- a/code/fms/fms-vue/src/services/api.js +++ b/code/fms/fms-vue/src/services/api.js @@ -5,7 +5,7 @@ import { post } from "./http"; * @param {string} orgId 机构码。 * @param {string} userId 用户 ID。 * @param {string} password 密码。 - * @returns {Promise<{token: string, orgid: string, user: {id: string, name: string}}>} 登录会话。 + * @returns {Promise<{success: boolean, code: number, message: string, data: {token: string, orgid: string, user: {id: string, name: string}}}>} 登录响应。 */ export function loginApi(orgId, userId, password) { return post("/auth/login", { @@ -21,7 +21,7 @@ export function loginApi(orgId, userId, password) { * @param {string} [searchCondition] 查询条件,不包含 WHERE。 * @param {string} [orderField] 排序字段,可带 ASC 或 DESC。 * @param {string[]} [searchColumns] 需要返回的字段列表,省略时返回全部字段。 - * @returns {Promise} 查询结果行。 + * @returns {Promise<{success: boolean, code: number, message: string, data: object[]}>} 查询响应。 */ export function loadDataApi(viewName, searchCondition, orderField, searchColumns) { return post("/data/loaddata", { @@ -35,7 +35,7 @@ export function loadDataApi(viewName, searchCondition, orderField, searchColumns /** * 执行只读 SQL 查询。 * @param {string} sql 完整的 SELECT 或 WITH 查询语句。 - * @returns {Promise} 查询结果行。 + * @returns {Promise<{success: boolean, code: number, message: string, data: object[]}>} 查询响应。 */ export function loadDataBySqlApi(sql) { return post("/data/loaddatabysql", { sql }); diff --git a/code/fms/fms-vue/src/services/http.js b/code/fms/fms-vue/src/services/http.js index f4c21ded..94f94772 100644 --- a/code/fms/fms-vue/src/services/http.js +++ b/code/fms/fms-vue/src/services/http.js @@ -1,6 +1,9 @@ import axios from "axios"; import { useAuthStore } from "../stores/auth"; +const AUTH_ERROR_CODE = 401; +const SESSION_REPLACED_CODE = 40101; + const http = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || "/api", timeout: 15000, @@ -12,19 +15,25 @@ const http = axios.create({ let redirectingToLogin = false; -function redirectToLogin() { +function redirectToLogin(reason) { const currentPath = window.location.pathname.replace(/\/+$/, "") || "/"; if (currentPath === "/login" || redirectingToLogin) return; redirectingToLogin = true; const redirect = `${window.location.pathname}${window.location.search}${window.location.hash}`; - window.location.assign(`/login?redirect=${encodeURIComponent(redirect)}`); + const query = new URLSearchParams({ redirect }); + if (reason) query.set("reason", reason); + window.location.assign(`/login?${query.toString()}`); } -function handleUnauthorized() { +function isAuthenticationError(code) { + return code === AUTH_ERROR_CODE || code === SESSION_REPLACED_CODE; +} + +function handleUnauthorized(code) { const authStore = useAuthStore(); authStore.clearSession(); - redirectToLogin(); + redirectToLogin(code === SESSION_REPLACED_CODE ? "session-replaced" : undefined); } http.interceptors.request.use((config) => { @@ -42,25 +51,34 @@ http.interceptors.response.use( const payload = response.data; if (payload?.code === undefined) return payload; - if (payload.code === 0) return payload.data; + const result = { + ...payload, + success: payload.code === 0, + }; + if (result.success) return result; - if (payload.code === 401) handleUnauthorized(); + if (isAuthenticationError(result.code)) handleUnauthorized(result.code); - const error = new Error(payload.message || "请求失败"); - error.code = payload.code; + const error = new Error(result.message || "请求失败"); + error.code = result.code; + error.data = result.data; + error.success = result.success; throw error; }, (error) => { const status = error.response?.status; const payload = error.response?.data; - if (status === 401 || payload?.code === 401) handleUnauthorized(); + const authenticationCode = payload?.code ?? status; + if (isAuthenticationError(authenticationCode)) handleUnauthorized(authenticationCode); const requestError = new Error( payload?.message || (error.code === "ECONNABORTED" ? "请求超时,请稍后重试" : "网络异常,请检查服务是否正常"), ); requestError.code = payload?.code ?? status; + requestError.data = payload?.data; + requestError.success = false; throw requestError; }, ); diff --git a/code/fms/fms-vue/src/stores/auth.js b/code/fms/fms-vue/src/stores/auth.js index 97f1661b..339c3121 100644 --- a/code/fms/fms-vue/src/stores/auth.js +++ b/code/fms/fms-vue/src/stores/auth.js @@ -1,6 +1,13 @@ import { computed, ref } from "vue"; import { defineStore } from "pinia"; +try { + // Remove sessions persisted by earlier versions; authentication is session-only now. + localStorage.removeItem("fms-auth"); +} catch { + // Storage can be unavailable in restricted browser contexts. +} + export const useAuthStore = defineStore( "auth", () => { @@ -33,6 +40,7 @@ export const useAuthStore = defineStore( { persist: { key: "fms-auth", + storage: sessionStorage, pick: ["token", "orgId", "user"], }, }, diff --git a/code/fms/fms-vue/src/views/DashboardView.vue b/code/fms/fms-vue/src/views/DashboardView.vue index 6486b954..305b5d37 100644 --- a/code/fms/fms-vue/src/views/DashboardView.vue +++ b/code/fms/fms-vue/src/views/DashboardView.vue @@ -1,5 +1,12 @@ @@ -194,6 +241,10 @@ const columns = [ padding: 0 2px 2px; } +.session-panel :deep(.ant-table-wrapper) { + padding: 0 2px 2px; +} + .quick-links { display: grid; padding: 4px; diff --git a/code/fms/fms-vue/src/views/DataListView.vue b/code/fms/fms-vue/src/views/DataListView.vue index 4cabfe49..a536d03f 100644 --- a/code/fms/fms-vue/src/views/DataListView.vue +++ b/code/fms/fms-vue/src/views/DataListView.vue @@ -188,7 +188,8 @@ async function loadData() { loadError.value = ""; try { - const result = await loadDataApi(viewName); + const response = await loadDataApi(viewName); + const result = response.data; if (currentSequence !== loadSequence) return; sourceRows.value = (Array.isArray(result) ? result : []).map(normalizeRow); applyLocalFilters(); diff --git a/code/fms/fms-vue/src/views/LoginView.vue b/code/fms/fms-vue/src/views/LoginView.vue index 85d84fb7..4ffc73a9 100644 --- a/code/fms/fms-vue/src/views/LoginView.vue +++ b/code/fms/fms-vue/src/views/LoginView.vue @@ -1,22 +1,54 @@