20260717170047
This commit is contained in:
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
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package cn.g3soft.fmsapi.utils.snowflake.contract;
|
||||
|
||||
public interface IIdGenerator {
|
||||
long newLong() throws IdGeneratorException;
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package cn.g3soft.fmsapi.utils.snowflake.contract;
|
||||
|
||||
public interface ISnowWorker {
|
||||
long nextId() throws IdGeneratorException;
|
||||
}
|
||||
+25
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
+60
@@ -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;
|
||||
}
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
}
|
||||
+229
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+90
@@ -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();
|
||||
}
|
||||
}
|
||||
+34
@@ -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
|
||||
|
||||
|
||||
+20
@@ -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);
|
||||
}
|
||||
}
|
||||
+31
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 & 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>
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
+4400
File diff suppressed because it is too large.
Load diff
+267
-54
@@ -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. 迁移期间保留现有通用接口,按模块逐步切换,避免一次性替换全部功能。
|
||||
Reference in new issue
Block a user