20260729173317
This commit is contained in:
1 parent
f6b79ea09a
commit
1c839b707a
26 files changed
+1902
-29
No files matched your search
@@ -69,6 +69,18 @@ public class DataController {
|
||||
return ApiResponse.success(ids);
|
||||
}
|
||||
|
||||
@GetMapping("/nextcode")
|
||||
public ApiResponse<?> nextCode(
|
||||
@RequestParam String moduleId,
|
||||
@RequestParam(required = false, defaultValue = "1") int count
|
||||
) throws SQLException {
|
||||
List<String> codes = dataSaveService.nextCode(moduleId, count);
|
||||
if (codes.size() == 1) {
|
||||
return ApiResponse.success(codes.get(0));
|
||||
}
|
||||
return ApiResponse.success(codes);
|
||||
}
|
||||
|
||||
@PostMapping("/describe")
|
||||
public ApiResponse<List<Map<String, Object>>> describe(
|
||||
@RequestBody(required = false) Map<String, Object> params
|
||||
|
||||
@@ -9,11 +9,18 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.StringJoiner;
|
||||
|
||||
@Service
|
||||
public class DataSaveService {
|
||||
@@ -50,6 +57,327 @@ public class DataSaveService {
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> nextCode(String moduleId, int count) throws SQLException {
|
||||
if (moduleId == null || moduleId.isBlank()) {
|
||||
throw new BusinessException("moduleId 不能为空");
|
||||
}
|
||||
if (count < 1 || count > 1000) {
|
||||
throw new BusinessException("count 必须是 1 到 1000 之间的整数");
|
||||
}
|
||||
|
||||
final long moduleIdValue;
|
||||
try {
|
||||
moduleIdValue = Long.parseLong(moduleId);
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new BusinessException("moduleId 必须是有效的数字");
|
||||
}
|
||||
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
boolean originalAutoCommit = connection.getAutoCommit();
|
||||
connection.setAutoCommit(false);
|
||||
try {
|
||||
AutoCodeRule rule = loadAutoCodeRule(connection, moduleIdValue);
|
||||
DbUtils.TableMetadata targetTable = dbUtils.loadTableMetadata(connection, rule.saveTable());
|
||||
DbUtils.ColumnMetadata targetColumn = dbUtils.getColumn(targetTable, rule.fieldName());
|
||||
if (targetColumn == null) {
|
||||
throw new BusinessException(
|
||||
"自动编码字段不存在: " + rule.saveTable() + "." + rule.fieldName()
|
||||
);
|
||||
}
|
||||
if (!isTextColumn(targetColumn.typeName())) {
|
||||
throw new BusinessException("自动编码字段必须是文本类型: " + rule.fieldName());
|
||||
}
|
||||
ensureUniqueCodeIndex(connection, targetTable, targetColumn);
|
||||
if (rule.startValue() < 0) {
|
||||
throw new BusinessException("自动编码起始值不能小于 0");
|
||||
}
|
||||
|
||||
AutoCodeFormat format = createAutoCodeFormat(
|
||||
rule.prefix(),
|
||||
rule.dateFormat(),
|
||||
rule.separator(),
|
||||
rule.sequenceWidth()
|
||||
);
|
||||
validateResetFormat(rule.resetType(), format.dateFormat());
|
||||
LocalDate today = LocalDate.now(ZoneId.of("Asia/Shanghai"));
|
||||
String period = buildPeriodKey(today, rule.resetType());
|
||||
long nextValue = shouldReset(rule.currentPeriod(), period)
|
||||
? rule.startValue()
|
||||
: increment(rule.currentValue());
|
||||
List<String> codes = new ArrayList<>(count);
|
||||
for (int index = 0; index < count; index++) {
|
||||
CodeCandidate candidate = nextAvailableCode(
|
||||
connection,
|
||||
targetTable,
|
||||
targetColumn,
|
||||
format,
|
||||
today,
|
||||
nextValue
|
||||
);
|
||||
codes.add(candidate.code());
|
||||
nextValue = increment(candidate.value());
|
||||
}
|
||||
|
||||
long lastValue = nextValue - 1;
|
||||
try (PreparedStatement statement = connection.prepareStatement(
|
||||
"UPDATE dbo.s_module_auto_code "
|
||||
+ "SET b_current_period = ?, b_current_value = ? "
|
||||
+ "WHERE b_id = ?"
|
||||
)) {
|
||||
statement.setString(1, period);
|
||||
statement.setLong(2, lastValue);
|
||||
statement.setLong(3, rule.id());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
connection.commit();
|
||||
return codes;
|
||||
} catch (SQLException | RuntimeException exception) {
|
||||
rollback(connection, exception);
|
||||
throw exception;
|
||||
} finally {
|
||||
if (originalAutoCommit) {
|
||||
connection.setAutoCommit(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private AutoCodeRule loadAutoCodeRule(Connection connection, long moduleId) throws SQLException {
|
||||
String sql = "SELECT TOP 1 r.b_id, r.b_prefix, r.b_date_format, "
|
||||
+ "r.b_separator, r.b_sequence_width, r.b_reset_type, "
|
||||
+ "r.b_start_value, r.b_current_period, r.b_current_value, "
|
||||
+ "m.b_savetable, f.b_field "
|
||||
+ "FROM dbo.s_module_auto_code r WITH (UPDLOCK, HOLDLOCK, ROWLOCK) "
|
||||
+ "INNER JOIN dbo.s_module m ON m.b_id = r.b_module_id "
|
||||
+ "INNER JOIN dbo.s_module_field f ON f.b_id = r.b_field_id "
|
||||
+ "WHERE r.b_module_id = ? AND r.b_canuse = 1 "
|
||||
+ "AND m.b_module_type = 'data' AND f.b_canuse = 1";
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setLong(1, moduleId);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (!resultSet.next()) {
|
||||
throw new BusinessException("当前模块未配置启用的自动编码规则");
|
||||
}
|
||||
String saveTable = resultSet.getString("b_savetable");
|
||||
String fieldName = resultSet.getString("b_field");
|
||||
if (saveTable == null || saveTable.isBlank()) {
|
||||
throw new BusinessException("模块未配置保存表,无法生成自动编号");
|
||||
}
|
||||
return new AutoCodeRule(
|
||||
resultSet.getLong("b_id"),
|
||||
resultSet.getString("b_prefix"),
|
||||
resultSet.getString("b_date_format"),
|
||||
resultSet.getString("b_separator"),
|
||||
resultSet.getInt("b_sequence_width"),
|
||||
resultSet.getString("b_reset_type"),
|
||||
resultSet.getLong("b_start_value"),
|
||||
resultSet.getString("b_current_period"),
|
||||
resultSet.getLong("b_current_value"),
|
||||
saveTable,
|
||||
fieldName
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private CodeCandidate nextAvailableCode(
|
||||
Connection connection,
|
||||
DbUtils.TableMetadata targetTable,
|
||||
DbUtils.ColumnMetadata targetColumn,
|
||||
AutoCodeFormat format,
|
||||
LocalDate today,
|
||||
long value
|
||||
) throws SQLException {
|
||||
long candidate = value;
|
||||
for (int attempt = 0; attempt < 1000; attempt++) {
|
||||
String code = formatCode(format, today, candidate);
|
||||
if (targetColumn.size() > 0 && code.length() > targetColumn.size()) {
|
||||
throw new BusinessException("自动编号长度超过目标字段长度");
|
||||
}
|
||||
if (!codeExists(connection, targetTable, targetColumn, code)) {
|
||||
return new CodeCandidate(code, candidate);
|
||||
}
|
||||
candidate = increment(candidate);
|
||||
}
|
||||
throw new BusinessException("自动编号连续冲突,无法分配新的编号");
|
||||
}
|
||||
|
||||
private boolean codeExists(
|
||||
Connection connection,
|
||||
DbUtils.TableMetadata targetTable,
|
||||
DbUtils.ColumnMetadata targetColumn,
|
||||
String code
|
||||
) throws SQLException {
|
||||
String sql = "SELECT TOP 1 1 FROM " + targetTable.quotedName()
|
||||
+ " WHERE " + dbUtils.quoteQualifiedIdentifier(targetColumn.name()) + " = ?";
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, code);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureUniqueCodeIndex(
|
||||
Connection connection,
|
||||
DbUtils.TableMetadata targetTable,
|
||||
DbUtils.ColumnMetadata targetColumn
|
||||
) throws SQLException {
|
||||
String sql = "SELECT TOP 1 1 "
|
||||
+ "FROM sys.indexes i "
|
||||
+ "INNER JOIN sys.index_columns ic "
|
||||
+ "ON ic.object_id = i.object_id AND ic.index_id = i.index_id "
|
||||
+ "INNER JOIN sys.columns c "
|
||||
+ "ON c.object_id = ic.object_id AND c.column_id = ic.column_id "
|
||||
+ "WHERE i.object_id = OBJECT_ID(?) AND i.is_unique = 1 AND i.is_disabled = 0 "
|
||||
+ "AND ic.key_ordinal = 1 AND c.name = ? "
|
||||
+ "AND NOT EXISTS ("
|
||||
+ "SELECT 1 FROM sys.index_columns other "
|
||||
+ "WHERE other.object_id = i.object_id AND other.index_id = i.index_id "
|
||||
+ "AND other.key_ordinal > 1)";
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, targetTable.requestedName());
|
||||
statement.setString(2, targetColumn.name());
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (!resultSet.next()) {
|
||||
throw new BusinessException(
|
||||
"自动编码字段必须建立单字段唯一索引: "
|
||||
+ targetTable.requestedName() + "." + targetColumn.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private AutoCodeFormat createAutoCodeFormat(
|
||||
String prefix,
|
||||
String dateFormat,
|
||||
String separator,
|
||||
int sequenceWidth
|
||||
) {
|
||||
String normalizedPrefix = prefix == null ? "" : prefix;
|
||||
String normalizedDateFormat = dateFormat == null ? "" : dateFormat.trim();
|
||||
String normalizedSeparator = separator == null ? "" : separator;
|
||||
if (sequenceWidth < 1 || sequenceWidth > 18) {
|
||||
throw new BusinessException("流水号位数必须是 1 到 18 之间的整数");
|
||||
}
|
||||
if (!normalizedDateFormat.isEmpty()) {
|
||||
dateFormatter(normalizedDateFormat);
|
||||
}
|
||||
if (normalizedSeparator.length() > 10) {
|
||||
throw new BusinessException("分隔符长度不能超过 10 个字符");
|
||||
}
|
||||
if (normalizedPrefix.length() > 100) {
|
||||
throw new BusinessException("固定前缀长度不能超过 100 个字符");
|
||||
}
|
||||
return new AutoCodeFormat(
|
||||
normalizedPrefix,
|
||||
normalizedDateFormat,
|
||||
normalizedSeparator,
|
||||
sequenceWidth
|
||||
);
|
||||
}
|
||||
|
||||
private String formatCode(AutoCodeFormat format, LocalDate today, long sequence) {
|
||||
String sequenceText = Long.toString(sequence);
|
||||
if (sequenceText.length() > format.sequenceWidth()) {
|
||||
throw new BusinessException("流水号超过配置位数");
|
||||
}
|
||||
sequenceText = "0".repeat(format.sequenceWidth() - sequenceText.length()) + sequenceText;
|
||||
StringJoiner parts = new StringJoiner(format.separator());
|
||||
if (!format.prefix().isEmpty()) parts.add(format.prefix());
|
||||
if (!format.dateFormat().isEmpty()) parts.add(today.format(dateFormatter(format.dateFormat())));
|
||||
parts.add(sequenceText);
|
||||
return parts.toString();
|
||||
}
|
||||
|
||||
private DateTimeFormatter dateFormatter(String format) {
|
||||
if (format.length() > 30) {
|
||||
throw new BusinessException("日期格式长度不能超过 30 个字符");
|
||||
}
|
||||
String literals = format
|
||||
.replace("yyyy", "")
|
||||
.replace("yy", "")
|
||||
.replace("MM", "")
|
||||
.replace("dd", "");
|
||||
if (literals.matches(".*[A-Za-z].*")) {
|
||||
throw new BusinessException("日期格式只支持 yyyy、yy、MM 和 dd");
|
||||
}
|
||||
try {
|
||||
return DateTimeFormatter.ofPattern(format);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new BusinessException("日期格式不正确: " + format);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildPeriodKey(LocalDate date, String resetType) {
|
||||
return switch (resetType == null ? "month" : resetType) {
|
||||
case "none" -> "*";
|
||||
case "year" -> date.format(DateTimeFormatter.ofPattern("yyyy"));
|
||||
case "month" -> date.format(DateTimeFormatter.ofPattern("yyyyMM"));
|
||||
case "day" -> date.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
|
||||
default -> throw new BusinessException("不支持的重置周期: " + resetType);
|
||||
};
|
||||
}
|
||||
|
||||
private void validateResetFormat(String resetType, String dateFormat) {
|
||||
String normalizedResetType = resetType == null ? "month" : resetType;
|
||||
boolean hasYear = dateFormat.contains("yyyy") || dateFormat.contains("yy");
|
||||
boolean hasMonth = dateFormat.contains("MM");
|
||||
boolean hasDay = dateFormat.contains("dd");
|
||||
if ("year".equals(normalizedResetType) && !hasYear) {
|
||||
throw new BusinessException("按年重置时,日期格式必须包含年份");
|
||||
}
|
||||
if ("month".equals(normalizedResetType) && (!hasYear || !hasMonth)) {
|
||||
throw new BusinessException("按月重置时,日期格式必须包含年份和月份");
|
||||
}
|
||||
if ("day".equals(normalizedResetType) && (!hasYear || !hasMonth || !hasDay)) {
|
||||
throw new BusinessException("按日重置时,日期格式必须包含年月日");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldReset(String previousPeriod, String currentPeriod) {
|
||||
return previousPeriod == null || !previousPeriod.equals(currentPeriod);
|
||||
}
|
||||
|
||||
private long increment(long value) {
|
||||
if (value == Long.MAX_VALUE) {
|
||||
throw new BusinessException("自动编号流水值已达到最大值");
|
||||
}
|
||||
return value + 1;
|
||||
}
|
||||
|
||||
private boolean isTextColumn(String typeName) {
|
||||
String normalized = typeName == null ? "" : typeName.toLowerCase(Locale.ROOT);
|
||||
return normalized.contains("char") || normalized.contains("text");
|
||||
}
|
||||
|
||||
private record AutoCodeRule(
|
||||
long id,
|
||||
String prefix,
|
||||
String dateFormat,
|
||||
String separator,
|
||||
int sequenceWidth,
|
||||
String resetType,
|
||||
long startValue,
|
||||
String currentPeriod,
|
||||
long currentValue,
|
||||
String saveTable,
|
||||
String fieldName
|
||||
) {
|
||||
}
|
||||
|
||||
private record AutoCodeFormat(
|
||||
String prefix,
|
||||
String dateFormat,
|
||||
String separator,
|
||||
int sequenceWidth
|
||||
) {
|
||||
}
|
||||
|
||||
private record CodeCandidate(String code, long value) {
|
||||
}
|
||||
|
||||
private void saveTable(
|
||||
Connection connection,
|
||||
Map<String, Object> request,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cn.g3soft.fmsapi.service;
|
||||
|
||||
import cn.g3soft.fmsapi.exception.SaveObjectException;
|
||||
import cn.g3soft.fmsapi.exception.BusinessException;
|
||||
import cn.g3soft.fmsapi.utils.DbUtils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
@@ -12,6 +13,7 @@ import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -24,6 +26,44 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
class DataSaveServiceTests {
|
||||
|
||||
@Test
|
||||
void formatsConfigWithCustomDateAndOptionalSeparator() {
|
||||
DataSaveService service = new DataSaveService();
|
||||
Object format = ReflectionTestUtils.invokeMethod(
|
||||
service,
|
||||
"createAutoCodeFormat",
|
||||
"SO",
|
||||
"yyyy.MM.dd",
|
||||
"-",
|
||||
5
|
||||
);
|
||||
|
||||
String code = ReflectionTestUtils.invokeMethod(
|
||||
service,
|
||||
"formatCode",
|
||||
format,
|
||||
LocalDate.of(2026, 7, 29),
|
||||
1L
|
||||
);
|
||||
|
||||
assertThat(code).isEqualTo("SO-2026.07.29-00001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedDateTokens() {
|
||||
DataSaveService service = new DataSaveService();
|
||||
|
||||
assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(
|
||||
service,
|
||||
"createAutoCodeFormat",
|
||||
"SO",
|
||||
"yyyyHH",
|
||||
"",
|
||||
5
|
||||
)).isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("日期格式只支持");
|
||||
}
|
||||
|
||||
@Test
|
||||
void savesDirectArrayInOneTransactionAndKeepsEmptyString() throws Exception {
|
||||
JdbcFixture fixture = new JdbcFixture("b_id", "b_amount");
|
||||
|
||||
Reference in new issue
Block a user