20260729173317

This commit is contained in:
oneao committed 2026-07-29 17:33:21 +08:00
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");