20260717170047

This commit is contained in:
oneao committed 2026-07-17 17:00:48 +08:00
1 parent 49dafe524c
commit 35aa108a34
40 files changed
+6218 -413

No files matched your search

@@ -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
```
@@ -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
+5
View File
@@ -43,6 +43,11 @@
<artifactId>druid</artifactId>
<version>1.2.18</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
@@ -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);
}
@@ -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<Void> body = ApiResponse.fail(
ApiResponse.AUTH_ERROR_CODE,
"未登录或登录已失效"
);
private void writeUnauthorized(
HttpServletResponse response,
int code,
String message
) throws IOException {
ApiResponse<Void> body = ApiResponse.fail(code, message);
response.setStatus(200);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
@@ -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<List<Map<String, Object>>> loadData(
@RequestBody(required = false) Map<String, Object> params
@@ -32,4 +38,17 @@ public class DataController {
) throws SQLException {
return ApiResponse.success(dataService.loadDataBySql(params));
}
@PostMapping("/saveobjt")
public ApiResponse<Void> saveObject(
@RequestBody(required = false) List<Map<String, Object>> requests
) throws SQLException {
dataSaveService.save(requests);
return ApiResponse.success("保存成功", null);
}
@GetMapping("/nextid")
public ApiResponse<Long> nextId() {
return ApiResponse.success(IdGenerator.nextId());
}
}
@@ -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<Map<String, Object>> handleSaveObjectException(
SaveObjectException exception
) {
LOGGER.error(
"保存失败,table={},action={},index={}",
exception.getTable(),
exception.getAction(),
exception.getIndex(),
exception
);
Map<String, Object> 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<Void> handleDatabaseException(
Exception exception,
@@ -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;
}
}
@@ -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<AccountKey, String> 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) {
}
}
@@ -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<Map<String, Object>> login(
String orgId,
String userId,
@@ -33,7 +37,8 @@ public class AuthService {
List<Map<String, Object>> 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<String, Object> user = new LinkedHashMap<>();
user.put("id", accountId);
@@ -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<Map<String, Object>> 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<String, DbUtils.TableMetadata> metadataCache = new LinkedHashMap<>();
for (Map<String, Object> 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<String, Object> request,
Map<String, DbUtils.TableMetadata> 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<String, DbUtils.TableMetadata> 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<Map<String, Object>> 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<Map<String, Object>> rows(
Map<String, Object> 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<Map<String, Object>> rows = (List<Map<String, Object>>) (List<?>) values;
return rows;
}
private void rollback(Connection connection, Throwable originalException) {
try {
connection.rollback();
} catch (SQLException rollbackException) {
originalException.addSuppressed(rollbackException);
}
}
}
@@ -8,6 +8,8 @@ public record ApiResponse<T>(
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<T>(
return new ApiResponse<>(SUCCESS_CODE, "操作成功", data);
}
public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>(SUCCESS_CODE, message, data);
}
public static <T> ApiResponse<T> fail(String message) {
return fail(BUSINESS_ERROR_CODE, message);
}
@@ -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<String, ColumnMetadata> 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<String, Object> row
) throws SQLException {
Map<String, Object> normalizedRow = normalizeRow(row);
LinkedHashMap<ColumnMetadata, Object> 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<String, Object> row
) throws SQLException {
Map<String, Object> normalizedRow = normalizeRow(row);
Object keyValue = requiredKeyValue(normalizedRow, keyColumn, table.requestedName());
LinkedHashMap<ColumnMetadata, Object> 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<Object> parameters = new ArrayList<>(values.values());
parameters.add(keyValue);
return executeWrite(connection, sql, parameters, "修改");
}
public int delete(
Connection connection,
TableMetadata table,
ColumnMetadata keyColumn,
Map<String, Object> row
) throws SQLException {
Map<String, Object> 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<Map<String, Object>> executeQuery(
Connection connection,
String sql
@@ -126,6 +237,24 @@ public class DbUtils {
}
}
private int executeWrite(
Connection connection,
String sql,
List<Object> 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<ColumnMetadata, Object> writableValues(
TableMetadata table,
Map<String, Object> normalizedRow,
String excludedColumn
) {
LinkedHashMap<ColumnMetadata, Object> 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<String, Object> 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<String, Object> normalizeRow(Map<String, Object> row) {
if (row == null) {
throw new BusinessException("保存记录不能为空");
}
Map<String, Object> normalized = new LinkedHashMap<>();
for (Map.Entry<String, Object> 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<ColumnMetadata> 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<Map<String, Object>> 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<String, ColumnMetadata> columnsByName
) {
}
}
@@ -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) {
}
}
@@ -0,0 +1,5 @@
package cn.g3soft.fmsapi.utils.snowflake.contract;
public interface IIdGenerator {
long newLong() throws IdGeneratorException;
}
@@ -0,0 +1,5 @@
package cn.g3soft.fmsapi.utils.snowflake.contract;
public interface ISnowWorker {
long nextId() throws IdGeneratorException;
}
@@ -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));
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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();
}
}
}
@@ -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;
}
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -1,4 +1,5 @@
server:
port: 8088
servlet:
context-path: /api
@@ -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<Map<String, Object>> 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);
}
}
@@ -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();
}
}
@@ -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<String, Object> 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<String, Object> 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;
}
}
}
@@ -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");
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ffffff" />
<title>FMS 管理系统</title>
<title>FMS 货代物流管理系统</title>
</head>
<body>
<div id="app"></div>
@@ -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",
+3 -1
View File
@@ -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) {
+3 -3
View File
@@ -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<object[]>} 查询结果行。
* @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<object[]>} 查询结果行。
* @returns {Promise<{success: boolean, code: number, message: string, data: object[]}>} 查询响应。
*/
export function loadDataBySqlApi(sql) {
return post("/data/loaddatabysql", { sql });
+27 -9
View File
@@ -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;
},
);
+8
View File
@@ -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"],
},
},
+52 -1
View File
@@ -1,5 +1,12 @@
<script setup>
import { CircleCheck, Clock3, DollarSign, FileText, Plus } from "@lucide/vue";
import { ref } from "vue";
import { CircleCheck, Clock3, DollarSign, FileText, Plus, RefreshCw } from "@lucide/vue";
import { App } from "antdv-next";
import { loadDataApi } from "../services/api";
const { message } = App.useApp();
const usersLoading = ref(false);
const users = ref([]);
const metrics = [
{ label: "今日新增", value: "0", tone: "blue", icon: FileText },
@@ -14,6 +21,25 @@ const columns = [
{ title: "业务状态", dataIndex: "status", key: "status" },
{ title: "更新时间", dataIndex: "updatedAt", key: "updatedAt" },
];
const userColumns = [
{ title: "用户ID", dataIndex: "b_id", key: "b_id" },
{ title: "用户名称", dataIndex: "b_name", key: "b_name" },
];
async function loadUsers() {
usersLoading.value = true;
try {
const response = await loadDataApi("b_user", null, "b_id ASC", ["b_id", "b_name"]);
users.value = response.data;
} catch (error) {
if (error.code !== 401 && error.code !== 40101) {
message.error(error.message || "用户查询失败");
}
} finally {
usersLoading.value = false;
}
}
</script>
<template>
@@ -74,6 +100,27 @@ const columns = [
</nav>
</div>
</section>
<section class="panel session-panel">
<div class="panel__header">
<div>
<h2>登录会话测试</h2>
<span>b_user 用户列表</span>
</div>
<a-button :loading="usersLoading" @click="loadUsers">
<template #icon><RefreshCw /></template>
查询用户
</a-button>
</div>
<a-table
:columns="userColumns"
:data-source="users"
:loading="usersLoading"
:pagination="false"
row-key="b_id"
size="small"
/>
</section>
</div>
</template>
@@ -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;
+2 -1
View File
@@ -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();
+115 -321
View File
@@ -1,22 +1,54 @@
<script setup>
import { reactive, ref } from "vue";
import { onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Building2, LockKeyhole, LogIn, MapPin, Ship, Truck, UserRound } from "@lucide/vue";
import { App } from "antdv-next";
import { loginApi } from "../services/api";
import { useAuthStore } from "../stores/auth";
const LOGIN_IDENTITY_KEY = "fms-login-identity";
function readRememberedIdentity() {
try {
const identity = JSON.parse(localStorage.getItem(LOGIN_IDENTITY_KEY) || "{}");
return {
orgId: typeof identity.orgId === "string" ? identity.orgId : "",
userId: typeof identity.userId === "string" ? identity.userId : "",
};
} catch {
return { orgId: "", userId: "" };
}
}
function rememberIdentity(orgId, userId) {
try {
localStorage.setItem(LOGIN_IDENTITY_KEY, JSON.stringify({ orgId, userId }));
} catch {
// Login remains available when browser storage is restricted.
}
}
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const { message } = App.useApp();
const loading = ref(false);
const rememberedIdentity = readRememberedIdentity();
const form = reactive({
orgId: "",
userId: "",
orgId: rememberedIdentity.orgId,
userId: rememberedIdentity.userId,
password: "",
});
onMounted(() => {
if (route.query.reason !== "session-replaced") return;
message.warning("账号已在其他设备登录,请重新登录");
const query = { ...route.query };
delete query.reason;
router.replace({ name: "login", query });
});
function getRedirectPath() {
const redirect = route.query.redirect;
return typeof redirect === "string" && redirect.startsWith("/") && !redirect.startsWith("//")
@@ -25,11 +57,16 @@ function getRedirectPath() {
}
async function submit() {
if (loading.value) return;
loading.value = true;
try {
const session = await loginApi(form.orgId, form.userId, form.password);
const response = await loginApi(form.orgId, form.userId, form.password);
const session = response.data;
authStore.setSession(session, form.orgId);
rememberIdentity(form.orgId, form.userId);
message.success("登录成功,正在进入工作台");
await router.replace(getRedirectPath());
} catch (error) {
message.error(error.message || "登录失败");
@@ -41,56 +78,49 @@ async function submit() {
<template>
<main class="login-page">
<section class="login-shell" aria-label="FMS 登录">
<aside class="login-overview">
<section class="login-shell" aria-label="FMS 货代物流管理系统登录">
<aside class="login-overview" aria-label="货代物流业务概览">
<div class="overview-brand">
<div class="overview-brand__mark">F</div>
<div class="overview-brand__mark"><Ship /></div>
<div class="overview-brand__copy">
<strong>FMS</strong>
<span>Freight Management System</span>
<span>Freight &amp; Logistics</span>
</div>
</div>
<div class="overview-copy">
<span class="overview-copy__eyebrow">一体化业务工作台</span>
<h1>FMS 管理系统</h1>
<p>让业务、费用与数据在同一工作台高效流转。</p>
<span class="overview-copy__eyebrow">货代 · 物流 · 费用一体化</span>
<h1>货代物流管理系统</h1>
<p>贯通订舱委托、运输跟踪与费用结算,让每一票业务清晰可控。</p>
</div>
<div class="route-visual" aria-hidden="true">
<div class="route-line route-line--first"></div>
<div class="route-line route-line--second"></div>
<div class="route-node route-node--origin"><Building2 /></div>
<div class="route-node route-node--truck"><Truck /></div>
<div class="route-node route-node--ship"><Ship /></div>
<div class="route-node route-node--destination"><MapPin /></div>
</div>
<div class="overview-footer">
<span>多机构协同</span>
<span>业务数据一体化</span>
<div class="overview-scope">
<span><Ship /> 海运订舱</span>
<span><Truck /> 运输跟踪</span>
<span><MapPin /> 业务协同</span>
</div>
</aside>
<div class="login-panel">
<div class="mobile-brand">
<div class="mobile-brand__mark">F</div>
<strong>FMS 管理系统</strong>
</div>
<header class="login-heading">
<span>ACCOUNT ACCESS</span>
<span>业务工作台</span>
<h2>账号登录</h2>
<p>欢迎回来,请登录所属机构</p>
<p>欢迎回来,请使用您的业务账号登录</p>
</header>
<a-form layout="vertical" :model="form" class="login-form" @finish="submit">
<a-form layout="vertical" :model="form" @finish="submit">
<a-form-item
label="机构码"
name="orgId"
:rules="[{ required: true, message: '请输入机构码' }]"
>
<a-input v-model:value="form.orgId" size="large" autocomplete="organization">
<a-input
v-model:value="form.orgId"
size="large"
autocomplete="organization"
placeholder="请输入机构码"
autofocus
>
<template #prefix><Building2 /></template>
</a-input>
</a-form-item>
@@ -99,7 +129,12 @@ async function submit() {
name="userId"
:rules="[{ required: true, message: '请输入账号' }]"
>
<a-input v-model:value="form.userId" size="large" autocomplete="username">
<a-input
v-model:value="form.userId"
size="large"
autocomplete="username"
placeholder="请输入账号"
>
<template #prefix><UserRound /></template>
</a-input>
</a-form-item>
@@ -108,6 +143,7 @@ async function submit() {
v-model:value="form.password"
size="large"
autocomplete="current-password"
placeholder="请输入密码"
>
<template #prefix><LockKeyhole /></template>
</a-input-password>
@@ -117,7 +153,6 @@ async function submit() {
html-type="submit"
size="large"
block
class="login-button"
:loading="loading"
>
<template #icon><LogIn /></template>
@@ -125,7 +160,7 @@ async function submit() {
</a-button>
</a-form>
<footer class="login-footer">FMS · 机构业务管理平台</footer>
<footer class="login-footer">FMS · 货代 / 物流管理系统</footer>
</div>
</section>
</main>
@@ -133,39 +168,33 @@ async function submit() {
<style lang="scss" scoped>
.login-page {
position: relative;
display: grid;
width: 100%;
height: 100%;
min-height: 0;
overflow: auto;
place-items: center;
padding: 32px;
background: #e8edf2;
background: #edf1f3;
}
.login-shell {
position: relative;
display: grid;
width: min(100%, 920px);
min-height: 560px;
grid-template-columns: minmax(0, 1.08fr) minmax(380px, 0.92fr);
width: 880px;
min-height: 520px;
grid-template-columns: 380px 500px;
overflow: hidden;
border: 1px solid #d8e0e8;
border: 1px solid #d5dee2;
border-radius: 8px;
background: #fff;
box-shadow: 0 22px 60px rgb(31 45 61 / 14%);
box-shadow: 0 18px 48px rgb(25 47 55 / 12%);
}
.login-overview {
position: relative;
display: flex;
min-width: 0;
flex-direction: column;
padding: 38px 42px 34px;
overflow: hidden;
color: #f7fafc;
background: #22313f;
padding: 42px;
color: #f5faf9;
background: #173a42;
}
.overview-brand {
@@ -178,12 +207,15 @@ async function submit() {
width: 38px;
height: 38px;
place-items: center;
border: 1px solid rgb(255 255 255 / 24%);
border: 1px solid rgb(255 255 255 / 20%);
border-radius: 6px;
background: #168aad;
background: #15998e;
color: #fff;
font-size: 19px;
font-weight: 700;
:deep(.lucide) {
width: 18px;
height: 18px;
}
}
&__copy {
@@ -198,7 +230,7 @@ async function submit() {
span {
margin-top: 3px;
color: #aebdca;
color: #a9c0c2;
font-size: 10px;
letter-spacing: 0;
}
@@ -206,14 +238,12 @@ async function submit() {
}
.overview-copy {
position: relative;
z-index: 2;
margin-top: 76px;
margin-top: 72px;
&__eyebrow {
display: block;
margin-bottom: 10px;
color: #55c2b6;
color: #5dd2c5;
font-size: 12px;
font-weight: 600;
}
@@ -221,310 +251,74 @@ async function submit() {
h1 {
margin: 0;
color: #fff;
font-size: 30px;
line-height: 1.25;
font-size: 28px;
line-height: 1.35;
}
p {
max-width: 320px;
margin: 14px 0 0;
color: #c5d0d9;
font-size: 14px;
line-height: 1.8;
color: #c4d4d5;
font-size: 13px;
line-height: 1.75;
}
}
.route-visual {
position: relative;
flex: 1;
min-height: 150px;
margin-top: 22px;
}
.route-line {
position: absolute;
height: 1px;
border-top: 1px dashed #6f8290;
transform-origin: left center;
&--first {
top: 75px;
left: 24px;
width: 45%;
transform: rotate(-11deg);
}
&--second {
top: 62px;
left: 45%;
width: 44%;
transform: rotate(12deg);
}
}
.route-node {
position: absolute;
z-index: 2;
display: grid;
width: 34px;
height: 34px;
place-items: center;
border: 1px solid #667987;
border-radius: 50%;
color: #d7e1e8;
background: #2c3e4c;
:deep(.lucide) {
width: 16px;
height: 16px;
}
&--origin {
top: 56px;
left: 5px;
border-color: #45ad9f;
color: #66d0c2;
}
&--truck {
top: 26px;
left: 37%;
}
&--ship {
top: 58px;
right: 25%;
border-color: #d3a34c;
color: #f0bd5f;
}
&--destination {
top: 86px;
right: 3px;
border-color: #3f9bb7;
color: #62bad5;
}
}
.overview-footer {
.overview-scope {
display: flex;
align-items: center;
gap: 18px;
color: #91a4b2;
font-size: 11px;
margin-top: auto;
flex-direction: column;
gap: 13px;
color: #b8cbcc;
font-size: 12px;
span {
position: relative;
display: flex;
align-items: center;
gap: 9px;
}
span + span::before {
position: absolute;
top: 50%;
left: -10px;
width: 2px;
height: 2px;
border-radius: 50%;
background: #667987;
content: "";
:deep(.lucide) {
width: 14px;
height: 14px;
color: #62c8be;
}
}
.login-panel {
display: flex;
min-width: 0;
flex-direction: column;
padding: 54px 48px 30px;
padding: 58px 60px 30px;
background: #fff;
}
.mobile-brand {
display: none;
}
.login-heading {
margin-bottom: 30px;
span {
color: #168aad;
font-size: 10px;
color: #168f86;
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
}
h2 {
margin: 8px 0 0;
color: #1f2d3a;
font-size: 24px;
color: #19323a;
font-size: 25px;
line-height: 1.3;
}
p {
margin: 8px 0 0;
color: #7a8793;
color: #738287;
font-size: 13px;
}
}
.login-form {
:deep(.ant-form-item) {
margin-bottom: 18px;
}
:deep(.ant-form-item-label) {
padding-bottom: 5px;
}
:deep(.ant-form-item-label > label) {
color: #354452;
font-size: 13px;
font-weight: 500;
}
:deep(.ant-input-affix-wrapper) {
min-height: 44px;
border-radius: 4px;
}
:deep(.ant-input-prefix) {
margin-inline-end: 10px;
color: #83909b;
}
:deep(.ant-input-prefix .lucide) {
width: 15px;
height: 15px;
}
}
.login-button {
height: 44px;
margin-top: 4px;
border-radius: 4px;
font-weight: 600;
:deep(.lucide) {
width: 15px;
height: 15px;
}
}
.login-footer {
margin-top: auto;
padding-top: 28px;
color: #9aa4ad;
padding-top: 26px;
color: #8c999d;
font-size: 11px;
text-align: center;
}
@media (max-width: 760px) {
.login-page {
align-items: start;
padding: 20px;
}
.login-shell {
width: min(100%, 500px);
min-height: calc(100vh - 40px);
grid-template-columns: minmax(0, 1fr);
}
.login-overview {
min-height: 170px;
padding: 25px 28px;
}
.overview-brand {
display: none;
}
.overview-copy {
margin-top: 0;
h1 {
font-size: 24px;
}
p {
margin-top: 7px;
line-height: 1.6;
}
}
.route-visual,
.overview-footer {
display: none;
}
.login-panel {
padding: 30px 34px 24px;
}
.mobile-brand {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 30px;
&__mark {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border-radius: 5px;
color: #fff;
background: #168aad;
font-size: 15px;
font-weight: 700;
}
strong {
color: #263746;
font-size: 14px;
}
}
}
@media (max-width: 480px) {
.login-page {
padding: 0;
}
.login-shell {
min-height: 100%;
border: 0;
border-radius: 0;
box-shadow: none;
}
.login-overview {
min-height: 140px;
padding: 22px 24px;
}
.overview-copy {
&__eyebrow {
margin-bottom: 7px;
}
h1 {
font-size: 22px;
}
p {
font-size: 12px;
}
}
.login-panel {
padding: 26px 24px 20px;
}
.mobile-brand {
margin-bottom: 25px;
}
.login-heading {
margin-bottom: 24px;
}
}
</style>
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineConfig({
strictPort: true,
proxy: {
"/api": {
target: "http://127.0.0.1:8080",
target: "http://127.0.0.1:8088",
changeOrigin: true,
},
},
File diff suppressed because it is too large. Load diff
+267 -54
View File
@@ -1,27 +1,36 @@
# FMS 管理系统需求规范
# FMS 货代物流管理系统需求规范
## 设计原则
## 1. 项目定位
- FMS 是一个前后端分离的多机构管理系统。
- FMS 是一个前后端分离、以模块为中心的多机构货代物流管理系统。
- 一个机构对应一个独立数据库,机构码统一命名为 `orgid`。
- 登录时必须提交机构码、用户名和密码。后端根据机构码连接对应数据库并验证用户。
- 登录成功后,登录凭证与机构码绑定。后续请求只操作当前登录机构的数据库,切换机构必须重新登录。
- 后端只负责登录、机构数据库路由、通用数据接口、事务和少量特殊接口。
- 页面、查询条件、SQL、数据组织、校验和普通业务流程均由前端完成。
- 当前阶段不考虑安全性,前端可以直接拼接查询条件和 SQL。
- 后端通用接口完成后,应尽量保持稳定。增加普通业务时只修改前端和数据库,不增加后端业务接口。
- 登录时必须提交机构码、账号和密码。后端根据机构码连接对应数据库并验证用户。
- 登录成功后,登录凭证与机构码绑定。后续请求只操作当前机构的数据库,切换机构必须重新登录。
- 后端负责登录、机构数据库路由、通用数据接口、事务控制以及少量确有必要的特殊业务接口。
- 普通业务优先复用通用查询和保存能力,避免为每张表单独创建 Controller、DTO 和实体类。
- 模块负责组织查询数据源、保存对象、字段展示、查询条件、编辑方式和权限。
## 固定接口
## 2. 现有系统过渡期约定
以下约定用于当前系统的开发过渡阶段,不作为新数据库重构后的最终安全模型。
- 页面、查询条件、SQL、数据组织、校验和普通业务流程目前主要由前端完成。
- 当前阶段暂不重点考虑客户端直接提交查询条件和 SQL 带来的安全风险。
- 后端通用接口完成后应尽量保持稳定,普通业务优先修改前端配置和数据库配置。
- 文件、报表、导入、导出以及通用接口无法覆盖的功能,可以增加特殊接口。
- 新数据库启用后,前端应逐步改为只提交 `module_id` 和结构化数据,不再直接指定任意表名或 SQL。
## 3. 现有通用接口
| 方法 | 路径 | 用途 |
|------|------|------|
| --- | --- | --- |
| `POST` | `/api/auth/login` | 机构登录 |
| `POST` | `/api/data/loaddata` | 按表或视图加载数据 |
| `POST` | `/api/data/loaddatabysql` | 执行完整查询 SQL |
| `POST` | `/api/data/page` | 执行分页查询 |
| `POST` | `/api/data/saveobjt` | 多表事务保存 |
**登录**
### 3.1 登录
```json
{
@@ -33,7 +42,7 @@
登录成功后返回登录凭证和用户信息。后续请求携带登录凭证,不再重复提交 `orgid`。
**loaddata**
### 3.2 loaddata
用于表或视图的普通查询,不执行分页。
@@ -55,6 +64,7 @@
```json
{
"success": true,
"code": 0,
"message": "ok",
"data": {
@@ -64,9 +74,9 @@
}
```
**loaddatabysql**
### 3.3 loaddatabysql
用于关联、聚合、统计等完整 SQL 查询,不执行分页。SQL 由前端直接拼接。
用于关联、聚合、统计等完整 SQL 查询,不执行分页。过渡期内 SQL 由前端直接拼接。
```json
{
@@ -76,7 +86,7 @@
成功响应与 `loaddata` 相同。
**page**
### 3.4 page
所有分页查询统一使用本接口。
@@ -91,14 +101,15 @@
- `sql` 必填,不包含分页语句。
- `order_by` 必填,用于保证分页顺序稳定。
- `page_no` 从 1 开始,默认值为 1。
- `page_size` 默认值为 20。
- `page_no` 从 1 开始,默认值为 `1`。
- `page_size` 默认值为 `20`。
- 后端负责生成分页 SQL 和计算总记录数。
成功响应:
```json
{
"success": true,
"code": 0,
"message": "ok",
"data": {
@@ -111,54 +122,56 @@
}
```
**saveobjt**
### 3.5 saveobjt
用于一个或多个表的新增、修改和删除。
```json
{
"tables": [
{
"table": "bs_business",
"key_field": "b_id",
"inserts": [],
"updates": [
{
"b_id": "10001",
"b_state": "草拟"
}
],
"deletes": []
},
{
"table": "bs_business_container",
"key_field": "subid",
"inserts": [],
"updates": [],
"deletes": [
{
"subid": "20001"
}
]
}
]
}
[
{
"table": "bs_business",
"key_field": "b_id",
"inserts": [],
"updates": [
{
"b_id": "10001",
"b_state": "草拟"
}
],
"deletes": []
},
{
"table": "bs_business_container",
"key_field": "subid",
"inserts": [],
"updates": [],
"deletes": [
{
"subid": "20001"
}
]
}
]
```
- `tables` 必须是非空数组。
- 请求体必须是非空数组。
- `table` 是目标表名,`key_field` 是该表的单个主键字段。
- `inserts`、`updates` 和 `deletes` 均为可选数组,省略时按空数组处理。
- 修改和删除的数据必须包含 `key_field` 对应的主键值。
- 数字字段的空字符串不做特殊转换,按原值提交给数据库。
- 每张表按照删除、修改、新增的顺序执行,表与表之间按照请求数组顺序执行。
- 一次请求中的所有表和所有操作使用同一个数据库连接和事务。
- 全部操作成功后提交;任意操作失败时全部回滚。
- 全部操作成功后提交,任意操作失败时全部回滚。
- 保存成功只返回成功状态,不返回每张表的操作数量。
成功响应:
```json
{
"success": true,
"code": 0,
"message": "保存成功"
"message": "保存成功",
"data": null
}
```
@@ -166,9 +179,10 @@
```json
{
"success": false,
"code": 2001,
"message": "保存失败",
"details": {
"data": {
"table": "bs_business_container",
"action": "update",
"index": 2
@@ -176,15 +190,214 @@
}
```
## 统一约定
## 4. 统一约定
- JavaScript 和 Java 内部函数、方法及变量使用小驼峰命名法。
- JSON 请求与响应字段、数据库字段使用 `snake_case`,在 API 边界显式映射。
- JSON 请求与响应字段使用 `snake_case`,在 API 边界与数据库字段显式映射。
- 数据库字段保留现有命名风格:主表普通字段使用 `b_` 前缀,主键固定为 `b_id`;子表普通字段使用 `mx_` 前缀,主键固定为 `subid`。
- API 路径使用全小写名称,JSON 多单词字段使用 `snake_case`。
- `code = 0` 表示成功,非零值表示失败。
- 前端根据 `code` 生成 `success` 字段,`code = 0` 时 `success = true`。
- 查询结果统一放在 `data.rows` 中,没有数据时返回空数组。
- 不使用参数占位符,查询条件和 SQL 均由前端拼接完成。
- 过渡期查询条件和 SQL 由前端拼接,不使用参数占位符。
- 不增加功能相同但名称不同的接口。
- 不使用 `V2`、`V3`、`new`、`old`、`test` 或 `bak` 等临时名称。
- 接口发生不兼容升级时,统一通过 `/api/v2` 等版本路径升级。
- 文件、报表、导入、导出和其他通用接口无法完成的功能,可以单独定义特殊接口。
## 5. 新数据库重构方案
### 5.1 重构目标
- 保持以模块为中心的开发方式。
- 将业务数据约束与页面配置分离。
- 所有业务页面、页面布局、按钮、标签页、弹窗和业务流程仍由前端手工开发。
- 模块配置只服务于通用 Table 和 Form 组件,不根据配置自动生成完整页面、路由或业务流程。
- Table 根据模块配置渲染列、格式、查询项和排序;Form 根据模块配置渲染字段、控件、校验和分组。
- 模块定义查询对象、保存对象、主键、字段配置和保存策略。
- 前端只提交 `module_id`、结构化查询条件和待保存数据。
- 后端根据模块配置解析允许访问的对象和字段,并使用参数化 SQL。
- 通用保存负责简单单表模块,复杂业务由明确的业务处理器完成。
- 不将全部业务逻辑塞入配置表或动态 SQL。
### 5.2 数据库版本与基础规范
- 新数据库建议使用 SQL Server 2019 或 SQL Server 2022,不再使用 SQL Server 2008 R2。
- 继续采用一个机构对应一个独立业务数据库的隔离方式,业务表不重复保存 `orgid`。
- 技术主键统一使用雪花 ID,数据库类型为 `bigint`。主表主键固定为 `b_id`,子表主键固定为 `subid`。
- 中文和多语言文本使用 `nvarchar`,长文本使用 `nvarchar(max)`,不使用 `text`。
- 布尔值使用 `bit`,不再使用字符串形式的 `0`、`1`、空字符串或 `NULL` 表示多种状态。
- 时间字段使用 `datetime2`。
- 金额、单价、数量、汇率和税率不强制统一精度,根据每个字段的业务含义分别定义 `decimal` 的精度和小数位数。
- 迁移旧字段时保留原有精度;新增字段必须在需求或表结构中明确精度,不使用 `float` 代替精确金额。
- 字段命名延续现有风格:主表字段使用 `b_` 前缀,子表字段使用 `mx_` 前缀。
- 主表与子表的外键字段也遵循对应前缀,例如子表引用主表时使用 `mx_<业务对象>_id`。
- 数字字段的空字符串不自动转换成 `0`,由调用方提交正确的数据类型。
### 5.3 dbo 与表名前缀
- 新数据库的所有业务表、配置表和视图统一创建在 `dbo` 下,不创建其他 Schema。
- 领域划分继续使用现有表名前缀,避免增加对象名称和通用接口的复杂度。
- 模块配置保存数据库对象名称时统一按 `dbo.<对象名>` 处理,例如 `dbo.bs_business`、`dbo.cw_bill` 和 `dbo.v_bs_business`。
| 表名前缀 | 用途 |
| --- | --- |
| `s_` | 模块、字段、菜单、权限和其他系统配置 |
| `b_` | 用户、客户、联系人、港口、币种等基础资料 |
| `bs_` | 业务单、箱信息、提单和运输业务 |
| `cw_` | 费用、账单、发票和收付款 |
| `bc_` | 合同、价目表和价格明细 |
| `bf_` | 文件和附件 |
| `oa_` | 通知、提醒等办公功能 |
| `sh_` | 审批流程 |
| `v_` | 面向查询和模块展示的视图 |
### 5.4 核心配置表
第一阶段只建立必要的配置表,避免过早建设复杂的低代码平台。
| 表 | 用途 |
| --- | --- |
| `dbo.s_module` | 模块定义,主表字段使用 `b_` 前缀 |
| `dbo.s_module_field` | 模块字段子表,字段使用 `mx_` 前缀 |
| `dbo.s_module_group` | Form 分组子表,字段使用 `mx_` 前缀 |
| `dbo.s_option_source` | 下拉框和字典数据源主表 |
| `dbo.s_option_item` | 固定选项子表,字段使用 `mx_` 前缀 |
菜单、角色和权限仍可使用独立系统表,但不属于 Table/Form 渲染配置。主子页面关系由手写页面负责组织,第一阶段不需要通过配置表生成。
### 5.5 模块定义
`dbo.s_module` 建议包含以下核心字段:
| 字段 | 用途 |
| --- | --- |
| `b_id` | 模块唯一标识,主键 |
| `b_name` | 模块名称 |
| `b_query_object` | 查询表或稳定查询视图 |
| `b_save_object` | 保存目标表,只读模块允许为空 |
| `b_key_field` | 保存对象主键字段 |
| `b_key_strategy` | `snowflake`、`identity` 或 `manual` |
| `b_save_mode` | `generic`、`custom` 或 `readonly` |
| `b_handler_code` | 自定义处理器标识,通用模块为空 |
| `b_default_order` | 默认排序 |
| `b_enabled` | 是否启用 |
| `b_sort_order` | 模块排序 |
查询对象与保存对象必须分开:查询视图负责组合展示数据,保存表负责持久化可写字段。
### 5.6 模块字段配置
`dbo.s_module_field` 直接归属于模块,使用 `subid` 作为主键,并为 `(mx_module_id, mx_field_code)` 建立唯一索引。同一个查询视图被多个模块复用时,各模块可以拥有独立的显示和编辑配置。
| 配置分组 | 建议字段 |
| --- | --- |
| 主键与归属 | `subid`、`mx_module_id` |
| 字段映射 | `mx_field_code`、`mx_query_field`、`mx_save_field`、`mx_caption` |
| 列表显示 | `mx_list_visible`、`mx_list_order`、`mx_list_width`、`mx_sortable`、`mx_display_format` |
| 查询设置 | `mx_searchable`、`mx_search_order`、`mx_search_operator` |
| 表单编辑 | `mx_form_visible`、`mx_form_group_code`、`mx_form_order`、`mx_form_span`、`mx_editable`、`mx_required`、`mx_component_type` |
| 数据来源 | `mx_option_source_id`、`mx_default_rule`、`mx_validation_rule` |
- `mx_field_code` 是模块对前端暴露的稳定字段名。
- `mx_query_field` 对应查询对象返回的字段。
- `mx_save_field` 对应保存表字段,计算字段和只读字段可以为空。
- `mx_search_operator` 使用受控枚举,例如 `eq`、`like`、`between` 或 `in`。
- 主要配置使用明确字段保存,只有少量不稳定的组件扩展属性才考虑使用 JSON。
### 5.7 表单分组与选项来源
- `dbo.s_module_group` 使用 `subid` 作为主键,其余字段使用 `mx_` 前缀。
- 分组配置包含 `mx_module_id`、`mx_group_code`、`mx_group_name`、`mx_sort_order`、`mx_column_count`、`mx_collapsible` 和 `mx_default_collapsed`。
- `dbo.s_module_field.mx_form_group_code` 指向对应分组,Form 按分组顺序和字段顺序渲染。
- 没有分组的字段放入默认分组,简单表单不强制配置分组数据。
- 固定下拉选项保存在 `dbo.s_option_item`。
- 数据库字典选项通过 `dbo.s_option_source` 指定允许查询的对象、值字段、显示字段和排序字段。
- 复杂选项通过 `b_handler_code` 交给后端处理,不在配置表保存任意可执行 SQL。
### 5.8 通用组件与手写页面的边界
| 层级 | 负责内容 |
| --- | --- |
| 手写页面 | 页面布局、工具栏、按钮、标签页、弹窗、主子区域、业务流程和接口调用时机 |
| `ModuleTable` | 加载模块字段配置,渲染列、格式化内容、查询条件、排序、分页和选择状态 |
| `ModuleForm` | 加载模块字段配置,渲染输入控件、字段校验、表单分组和编辑状态 |
| 模块配置 | 描述数据源和字段元数据,不描述完整页面 |
推荐的组件使用方式:
```vue
<ModuleTable module-id="bs_business">
<template #toolbar>
<!-- 页面自己实现按钮和业务操作 -->
</template>
<template #column-b_state="{ record }">
<!-- 页面可以覆盖特殊列的渲染 -->
</template>
</ModuleTable>
<ModuleForm
v-model="formData"
module-id="bs_business"
mode="edit"
@validate="handleValidate"
/>
```
- `ModuleTable` 可以接收 `module-id` 或 `view-name`,优先使用 `module-id`。
- 只有一个视图唯一对应一个模块时才允许只传 `view-name`;同一视图被多个模块复用时必须传 `module-id`。
- `ModuleForm` 应以 `module-id` 为入口,以便确定保存字段、表单分组和校验规则。
- Table 和 Form 必须提供 props、slots 和事件,允许手写页面覆盖默认列、字段、工具栏和交互。
- `ModuleForm` 负责渲染、收集数据和校验,但不自动决定保存时机;页面验证业务条件后再调用保存。
- 特殊控件和复杂区域可以通过插槽由页面自行实现,不要求全部配置化。
### 5.9 查询与保存边界
模块化后的推荐调用关系:
```text
module_id
-> 加载模块定义
-> 检查用户模块权限
-> 确定查询对象、保存对象和主键
-> 加载模块字段配置
-> 生成参数化查询或保存语句
-> 返回模块定义和业务数据
```
- 前端不再提交任意 `view_name`、`table`、`key_field` 或完整 SQL。
- 查询条件使用结构化格式提交,字段和操作符必须来自模块配置白名单。
- 雪花 ID 由后端保存流程生成,不依赖前端提前申请。
- `b_save_mode = generic` 只适用于基础资料等简单单表模块。
- `b_save_mode = readonly` 用于报表、统计和日志查询。
- `b_save_mode = custom` 用于业务单、费用、账单、发票等涉及主子表、状态流转和业务校验的模块。
- 自定义处理器仍通过统一模块接口调用,不为每个页面随意增加不同形式的接口。
### 5.10 业务表设计原则
- 每张业务表必须定义真实主键:主表固定使用 `b_id`,子表固定使用 `subid`。
- 主表普通字段使用 `b_` 前缀,子表普通字段使用 `mx_` 前缀。
- 有明确关系的主子表必须建立外键,外键字段同时建立普通索引。
- 业务编号与技术主键分离,例如主表的 `b_business_no` 建立唯一索引但不作为主键。
- 唯一性、非空、默认值和可校验的状态范围优先使用数据库约束保证。
- 不使用空字符串同时表达未知、未填写和数值零。
- 重要且允许多人编辑的业务表可以增加 `b_row_version` 或 `mx_row_version`,用于乐观锁控制。
- 创建人、修改人和时间字段按业务审计要求添加,不由通用保存接口无条件补充。
- 删除策略根据业务确定;财务和审批数据通常使用作废或状态流转,不直接物理删除。
### 5.11 权限原则
- 权限至少控制模块的查看、新增、修改、删除和导出操作。
- 菜单与模块分离,同一个模块可以出现在不同菜单位置,也可以作为隐藏子模块使用。
- 后端必须根据登录用户和 `module_id` 校验权限,不能只依赖前端隐藏按钮。
- 字段级权限仅在确有业务需求时增加,第一阶段不提前复杂化。
### 5.12 重构实施顺序
1. 确定数据库版本、字段命名、表名前缀和主键策略,所有对象固定使用 `dbo`。
2. 建立模块、模块字段、表单分组和选项来源等最小配置表。
3. 先实现一个简单基础资料页面,验证 `ModuleTable`、`ModuleForm`、通用查询和单表保存。
4. 手工实现一个业务主子页面,验证组件组合、事务和自定义保存。
5. 实现一个只读报表模块,验证查询、分页和导出边界。
6. 建立配置校验工具,检查查询对象、保存对象、字段、主键、重复排序和选项来源。
7. 三类模块验证通过后,再迁移现有模块及字段配置。
8. 迁移期间保留现有通用接口,按模块逐步切换,避免一次性替换全部功能。