diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/code/OrderCodeGenerator.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/code/OrderCodeGenerator.java
index f5162f4..91178ec 100644
--- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/code/OrderCodeGenerator.java
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/code/OrderCodeGenerator.java
@@ -1,93 +1,160 @@
-package com.viewsh.module.ops.infrastructure.code;
-
-import jakarta.annotation.Resource;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.data.redis.core.StringRedisTemplate;
-import org.springframework.stereotype.Service;
-
-import java.time.LocalDate;
-import java.time.format.DateTimeFormatter;
-import java.util.concurrent.TimeUnit;
-
-/**
- * 工单编号生成器
- *
- * 格式:{业务前缀}-{日期}-{序号}
- * 例如:CLEAN-20250119-0001, SECURITY-20250119-0001
- *
- * 特性:
- * - 使用 Redis 保证序号唯一性
- * - 序号每日自动重置(按日期分 key)
- * - 不同业务类型独立计数
- *
- * @author lzh
- */
-@Slf4j
-@Service
-public class OrderCodeGenerator {
-
- private static final String KEY_PREFIX = "ops:order:code:";
- private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
- private static final long DEFAULT_EXPIRE_DAYS = 7; // key 默认保留7天
-
- @Resource
- private StringRedisTemplate stringRedisTemplate;
-
- /**
- * 生成工单编号
- *
- * @param businessType 业务类型(如:CLEAN、SECURITY、FACILITIES)
- * @return 工单编号,格式:{业务类型}-{日期}-{4位序号}
- */
- public String generate(String businessType) {
- if (businessType == null || businessType.isEmpty()) {
- throw new IllegalArgumentException("Business type cannot be null or empty");
- }
-
- String dateStr = LocalDate.now().format(DATE_FORMATTER);
- String key = KEY_PREFIX + businessType + ":" + dateStr;
-
- // Redis 自增并获取新值
- Long seq = stringRedisTemplate.opsForValue().increment(key);
-
- // 首次创建时设置过期时间
- if (seq != null && seq == 1) {
- stringRedisTemplate.expire(key, DEFAULT_EXPIRE_DAYS, TimeUnit.DAYS);
- }
-
- if (seq == null) {
- throw new RuntimeException("Failed to generate order code for business type: " + businessType);
- }
-
- String orderCode = String.format("%s-%s-%04d", businessType, dateStr, seq);
-
- log.debug("生成工单编号: businessType={}, orderCode={}", businessType, orderCode);
-
- return orderCode;
- }
-
- /**
- * 获取指定业务类型当天的当前序号
- *
- * @param businessType 业务类型
- * @return 当前序号,如果不存在返回0
- */
- public long getCurrentSeq(String businessType) {
- String dateStr = LocalDate.now().format(DATE_FORMATTER);
- String key = KEY_PREFIX + businessType + ":" + dateStr;
- String value = stringRedisTemplate.opsForValue().get(key);
- return value == null ? 0 : Long.parseLong(value);
- }
-
- /**
- * 重置指定业务类型当天的序号(仅供测试或特殊场景使用)
- *
- * @param businessType 业务类型
- */
- public void reset(String businessType) {
- String dateStr = LocalDate.now().format(DATE_FORMATTER);
- String key = KEY_PREFIX + businessType + ":" + dateStr;
- stringRedisTemplate.delete(key);
- log.warn("重置工单编号序号: businessType={}, date={}", businessType, dateStr);
- }
-}
+package com.viewsh.module.ops.infrastructure.code;
+
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.Collections;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 工单编号生成器
+ *
+ * 格式:{业务前缀}-{日期}-{序号}
+ * 例如:CLEAN-20250119-0001, SECURITY-20250119-0001
+ *
+ * 特性:
+ * - 使用 Redis 保证序号唯一性
+ * - 序号每日自动重置(按日期分 key)
+ * - 不同业务类型独立计数
+ * - 应用启动后首次使用时自动从数据库校准,之后纯 Redis 自增
+ *
+ * @author lzh
+ */
+@Slf4j
+@Service
+public class OrderCodeGenerator {
+
+ private static final String KEY_PREFIX = "ops:order:code:";
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
+ private static final long DEFAULT_EXPIRE_DAYS = 7;
+
+ /**
+ * 记录本次应用生命周期内已校准过的 key,避免重复查库。
+ *
+ * ConcurrentHashMap: key = "CLEAN:20260413", value = dateStr(用于跨天清理)
+ */
+ private final ConcurrentHashMap calibratedKeys = new ConcurrentHashMap<>();
+
+ /**
+ * Lua 脚本:原子性地确保 Redis 值 >= 数据库最大序号,然后自增
+ */
+ private static final String INCR_WITH_FLOOR_SCRIPT =
+ "local floor = tonumber(ARGV[1]) " +
+ "local ttl = tonumber(ARGV[2]) " +
+ "local current = tonumber(redis.call('GET', KEYS[1]) or '0') " +
+ "if current < floor then " +
+ " redis.call('SET', KEYS[1], tostring(floor)) " +
+ "end " +
+ "local seq = redis.call('INCR', KEYS[1]) " +
+ "redis.call('EXPIRE', KEYS[1], ttl) " +
+ "return seq";
+
+ private static final DefaultRedisScript REDIS_SCRIPT = new DefaultRedisScript<>(INCR_WITH_FLOOR_SCRIPT, Long.class);
+
+ @Resource
+ private StringRedisTemplate stringRedisTemplate;
+
+ @Resource
+ private JdbcTemplate jdbcTemplate;
+
+ /**
+ * 生成工单编号
+ *
+ * @param businessType 业务类型(如:CLEAN、SECURITY、FACILITIES)
+ * @return 工单编号,格式:{业务类型}-{日期}-{4位序号}
+ */
+ public String generate(String businessType) {
+ if (businessType == null || businessType.isEmpty()) {
+ throw new IllegalArgumentException("Business type cannot be null or empty");
+ }
+
+ String dateStr = LocalDate.now().format(DATE_FORMATTER);
+ String key = KEY_PREFIX + businessType + ":" + dateStr;
+ String calibrationKey = businessType + ":" + dateStr;
+
+ Long seq;
+ if (!dateStr.equals(calibratedKeys.get(calibrationKey))) {
+ // 首次调用或跨天:查库校准 + 自增(Lua 原子操作)
+ long dbMaxSeq = getMaxSeqFromDatabase(businessType, dateStr);
+ long ttlSeconds = TimeUnit.DAYS.toSeconds(DEFAULT_EXPIRE_DAYS);
+ seq = stringRedisTemplate.execute(REDIS_SCRIPT,
+ Collections.singletonList(key),
+ String.valueOf(dbMaxSeq),
+ String.valueOf(ttlSeconds));
+ // 校准成功后记录,同时清理旧日期的条目
+ evictStaleEntries(dateStr);
+ calibratedKeys.put(calibrationKey, dateStr);
+ log.info("工单序号校准完成: key={}, dbMaxSeq={}, newSeq={}", key, dbMaxSeq, seq);
+ } else {
+ // 后续调用:纯 Redis 自增,无数据库开销
+ seq = stringRedisTemplate.opsForValue().increment(key);
+ }
+
+ if (seq == null) {
+ throw new RuntimeException("Failed to generate order code for business type: " + businessType);
+ }
+
+ String orderCode = String.format("%s-%s-%04d", businessType, dateStr, seq);
+ log.debug("生成工单编号: businessType={}, orderCode={}", businessType, orderCode);
+ return orderCode;
+ }
+
+ /**
+ * 从数据库查询当天该业务类型的最大序号
+ *
+ * @throws RuntimeException 首次校准失败时向上抛出,避免静默产生重复编号
+ */
+ long getMaxSeqFromDatabase(String businessType, String dateStr) {
+ // 转义 LIKE 通配符,防止 businessType 包含 % 或 _
+ String safeType = businessType.replace("%", "\\%").replace("_", "\\_");
+ String prefix = safeType + "-" + dateStr + "-";
+ Integer maxSeq = jdbcTemplate.queryForObject(
+ "SELECT MAX(CAST(SUBSTRING(order_code, ?) AS UNSIGNED)) FROM ops_order " +
+ "WHERE order_code LIKE ? AND deleted = b'0'",
+ Integer.class,
+ prefix.length() + 1,
+ prefix + "%"
+ );
+ return maxSeq != null ? maxSeq : 0;
+ }
+
+ /**
+ * 清理非当天的校准记录,防止长期运行内存泄漏
+ */
+ private void evictStaleEntries(String currentDateStr) {
+ calibratedKeys.entrySet().removeIf(entry -> !currentDateStr.equals(entry.getValue()));
+ }
+
+ /**
+ * 获取指定业务类型当天的当前序号
+ *
+ * @param businessType 业务类型
+ * @return 当前序号,如果不存在返回0
+ */
+ public long getCurrentSeq(String businessType) {
+ String dateStr = LocalDate.now().format(DATE_FORMATTER);
+ String key = KEY_PREFIX + businessType + ":" + dateStr;
+ String value = stringRedisTemplate.opsForValue().get(key);
+ return value == null ? 0 : Long.parseLong(value);
+ }
+
+ /**
+ * 重置指定业务类型当天的序号(仅供测试或特殊场景使用)
+ *
+ * @param businessType 业务类型
+ */
+ public void reset(String businessType) {
+ String dateStr = LocalDate.now().format(DATE_FORMATTER);
+ String key = KEY_PREFIX + businessType + ":" + dateStr;
+ stringRedisTemplate.delete(key);
+ calibratedKeys.remove(businessType + ":" + dateStr);
+ log.warn("重置工单编号序号: businessType={}, date={}", businessType, dateStr);
+ }
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/infrastructure/code/OrderCodeGeneratorTest.java b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/infrastructure/code/OrderCodeGeneratorTest.java
index 26a8cf3..0636ede 100644
--- a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/infrastructure/code/OrderCodeGeneratorTest.java
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/infrastructure/code/OrderCodeGeneratorTest.java
@@ -1,182 +1,228 @@
-package com.viewsh.module.ops.infrastructure.code;
-
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-import org.springframework.data.redis.core.StringRedisTemplate;
-import org.springframework.data.redis.core.ValueOperations;
-import org.springframework.test.util.ReflectionTestUtils;
-
-import java.time.LocalDate;
-import java.time.format.DateTimeFormatter;
-
-import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.ArgumentMatchers.*;
-import static org.mockito.Mockito.*;
-
-/**
- * 工单编号生成器测试
- *
- * @author lzh
- */
-@ExtendWith(MockitoExtension.class)
-class OrderCodeGeneratorTest {
-
- @Mock
- private StringRedisTemplate stringRedisTemplate;
-
- @Mock
- private ValueOperations valueOperations;
-
- private OrderCodeGenerator orderCodeGenerator;
-
- private static final String DATE_FORMAT = "yyyyMMdd";
- private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(DATE_FORMAT);
-
- @BeforeEach
- void setUp() {
- orderCodeGenerator = new OrderCodeGenerator();
- ReflectionTestUtils.setField(orderCodeGenerator, "stringRedisTemplate", stringRedisTemplate);
-
- // Mock stringRedisTemplate.opsForValue()
- lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
- }
-
- @Test
- void testGenerate_FirstOrderOfToday() {
- // Given
- when(valueOperations.increment(anyString())).thenReturn(1L);
-
- // When
- String orderCode = orderCodeGenerator.generate("CLEAN");
-
- // Then
- String expectedDate = LocalDate.now().format(DATE_FORMATTER);
- String expected = String.format("CLEAN-%s-0001", expectedDate);
- assertEquals(expected, orderCode);
- }
-
- @Test
- void testGenerate_SecondOrderOfToday() {
- // Given
- when(valueOperations.increment(anyString())).thenReturn(2L);
-
- // When
- String orderCode = orderCodeGenerator.generate("CLEAN");
-
- // Then
- String expectedDate = LocalDate.now().format(DATE_FORMATTER);
- String expected = String.format("CLEAN-%s-0002", expectedDate);
- assertEquals(expected, orderCode);
- }
-
- @Test
- void testGenerate_DifferentBusinessTypes() {
- // Given
- when(valueOperations.increment(anyString())).thenReturn(1L);
-
- // When
- String cleanCode = orderCodeGenerator.generate("CLEAN");
- String securityCode = orderCodeGenerator.generate("SECURITY");
-
- // Then
- assertTrue(cleanCode.startsWith("CLEAN-"));
- assertTrue(securityCode.startsWith("SECURITY-"));
- assertNotEquals(cleanCode, securityCode);
- }
-
- @Test
- void testGenerate_LargeSequenceNumber() {
- // Given
- when(valueOperations.increment(anyString())).thenReturn(9999L);
-
- // When
- String orderCode = orderCodeGenerator.generate("CLEAN");
-
- // Then
- String expectedDate = LocalDate.now().format(DATE_FORMATTER);
- String expected = String.format("CLEAN-%s-9999", expectedDate);
- assertEquals(expected, orderCode);
- }
-
- @Test
- void testGenerate_NullBusinessType_ThrowsException() {
- // When & Then
- assertThrows(IllegalArgumentException.class, () -> orderCodeGenerator.generate(null));
- }
-
- @Test
- void testGenerate_EmptyBusinessType_ThrowsException() {
- // When & Then
- assertThrows(IllegalArgumentException.class, () -> orderCodeGenerator.generate(""));
- }
-
- @Test
- void testGenerate_SetsExpirationOnFirstUse() {
- // Given
- when(valueOperations.increment(anyString())).thenReturn(1L);
-
- // When
- orderCodeGenerator.generate("CLEAN");
-
- // Then
- verify(stringRedisTemplate).expire(anyString(), eq(7L), any());
- }
-
- @Test
- void testGetCurrentSeq_NoExistingRecords_ReturnsZero() {
- // Given
- when(valueOperations.get(anyString())).thenReturn(null);
-
- // When
- long seq = orderCodeGenerator.getCurrentSeq("CLEAN");
-
- // Then
- assertEquals(0, seq);
- }
-
- @Test
- void testGetCurrentSeq_ExistingRecords_ReturnsValue() {
- // Given
- when(valueOperations.get(anyString())).thenReturn("5");
-
- // When
- long seq = orderCodeGenerator.getCurrentSeq("CLEAN");
-
- // Then
- assertEquals(5, seq);
- }
-
- @Test
- void testReset_DeletesKey() {
- // When
- orderCodeGenerator.reset("CLEAN");
-
- // Then
- verify(stringRedisTemplate).delete(contains("CLEAN"));
- }
-
- @Test
- void testGenerate_FormatConsistency() {
- // Given
- when(valueOperations.increment(anyString())).thenReturn(1L);
-
- // When
- String orderCode = orderCodeGenerator.generate("CLEAN");
-
- // Then: 验证格式为 {TYPE}-{DATE}-{SEQUENCE}
- String[] parts = orderCode.split("-");
- assertEquals(3, parts.length);
- assertEquals("CLEAN", parts[0]);
-
- // 验证日期部分是8位数字
- assertEquals(8, parts[1].length());
- assertTrue(parts[1].matches("\\d{8}"));
-
- // 验证序号部分是4位数字
- assertEquals(4, parts[2].length());
- assertTrue(parts[2].matches("\\d{4}"));
- }
-}
+package com.viewsh.module.ops.infrastructure.code;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import org.springframework.data.redis.core.script.RedisScript;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * 工单编号生成器测试
+ *
+ * @author lzh
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class OrderCodeGeneratorTest {
+
+ @Mock
+ private StringRedisTemplate stringRedisTemplate;
+
+ @Mock
+ private ValueOperations valueOperations;
+
+ @Mock
+ private JdbcTemplate jdbcTemplate;
+
+ private OrderCodeGenerator orderCodeGenerator;
+
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
+
+ @BeforeEach
+ void setUp() {
+ orderCodeGenerator = new OrderCodeGenerator();
+ ReflectionTestUtils.setField(orderCodeGenerator, "stringRedisTemplate", stringRedisTemplate);
+ ReflectionTestUtils.setField(orderCodeGenerator, "jdbcTemplate", jdbcTemplate);
+
+ when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
+ }
+
+ /**
+ * 统一 mock Lua 脚本调用
+ */
+ private void mockLuaScript(Long returnValue) {
+ when(stringRedisTemplate.execute(any(RedisScript.class), anyList(), anyString(), anyString()))
+ .thenReturn(returnValue);
+ }
+
+ private void mockDbMaxSeq(Integer maxSeq) {
+ when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), anyInt(), anyString()))
+ .thenReturn(maxSeq);
+ }
+
+ // ==================== 首次调用(校准)测试 ====================
+
+ @Test
+ void testGenerate_FirstCall_CalibratesFromDatabase() {
+ // Given: 数据库中已有 3 条记录
+ mockDbMaxSeq(3);
+ mockLuaScript(4L);
+
+ // When
+ String orderCode = orderCodeGenerator.generate("CLEAN");
+
+ // Then
+ String expectedDate = LocalDate.now().format(DATE_FORMATTER);
+ assertEquals(String.format("CLEAN-%s-0004", expectedDate), orderCode);
+ verify(stringRedisTemplate).execute(any(RedisScript.class), anyList(), eq("3"), anyString());
+ }
+
+ @Test
+ void testGenerate_FirstCall_NoDatabaseRecords() {
+ mockDbMaxSeq(null);
+ mockLuaScript(1L);
+
+ String orderCode = orderCodeGenerator.generate("CLEAN");
+
+ String expectedDate = LocalDate.now().format(DATE_FORMATTER);
+ assertEquals(String.format("CLEAN-%s-0001", expectedDate), orderCode);
+ verify(stringRedisTemplate).execute(any(RedisScript.class), anyList(), eq("0"), anyString());
+ }
+
+ // ==================== 后续调用(纯 Redis)测试 ====================
+
+ @Test
+ void testGenerate_SubsequentCall_PureRedisIncrement() {
+ // 首次调用:校准
+ mockDbMaxSeq(0);
+ mockLuaScript(1L);
+ orderCodeGenerator.generate("CLEAN");
+
+ // 第二次调用:纯 Redis
+ when(valueOperations.increment(anyString())).thenReturn(2L);
+
+ String orderCode = orderCodeGenerator.generate("CLEAN");
+
+ String expectedDate = LocalDate.now().format(DATE_FORMATTER);
+ assertEquals(String.format("CLEAN-%s-0002", expectedDate), orderCode);
+ // Lua 脚本只调用一次
+ verify(stringRedisTemplate, times(1)).execute(any(RedisScript.class), anyList(), anyString(), anyString());
+ verify(valueOperations, times(1)).increment(anyString());
+ }
+
+ // ==================== 不同业务类型独立计数 ====================
+
+ @Test
+ void testGenerate_DifferentBusinessTypes_IndependentCalibration() {
+ mockDbMaxSeq(0);
+ mockLuaScript(1L);
+
+ String cleanCode = orderCodeGenerator.generate("CLEAN");
+ String securityCode = orderCodeGenerator.generate("SECURITY");
+
+ assertTrue(cleanCode.startsWith("CLEAN-"));
+ assertTrue(securityCode.startsWith("SECURITY-"));
+ // 各业务类型各校准一次
+ verify(stringRedisTemplate, times(2)).execute(any(RedisScript.class), anyList(), anyString(), anyString());
+ }
+
+ // ==================== 异常处理测试 ====================
+
+ @Test
+ void testGenerate_NullBusinessType_ThrowsException() {
+ assertThrows(IllegalArgumentException.class, () -> orderCodeGenerator.generate(null));
+ }
+
+ @Test
+ void testGenerate_EmptyBusinessType_ThrowsException() {
+ assertThrows(IllegalArgumentException.class, () -> orderCodeGenerator.generate(""));
+ }
+
+ @Test
+ void testGenerate_DatabaseError_ThrowsException() {
+ when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), anyInt(), anyString()))
+ .thenThrow(new RuntimeException("DB connection failed"));
+
+ assertThrows(RuntimeException.class, () -> orderCodeGenerator.generate("CLEAN"));
+ }
+
+ // ==================== 格式验证 ====================
+
+ @Test
+ void testGenerate_FormatConsistency() {
+ mockDbMaxSeq(0);
+ mockLuaScript(1L);
+
+ String orderCode = orderCodeGenerator.generate("CLEAN");
+
+ String[] parts = orderCode.split("-");
+ assertEquals(3, parts.length);
+ assertEquals("CLEAN", parts[0]);
+ assertEquals(8, parts[1].length());
+ assertTrue(parts[1].matches("\\d{8}"));
+ assertEquals(4, parts[2].length());
+ assertTrue(parts[2].matches("\\d{4}"));
+ }
+
+ @Test
+ void testGenerate_LargeSequenceNumber() {
+ mockDbMaxSeq(9998);
+ mockLuaScript(9999L);
+
+ String orderCode = orderCodeGenerator.generate("CLEAN");
+
+ String expectedDate = LocalDate.now().format(DATE_FORMATTER);
+ assertEquals(String.format("CLEAN-%s-9999", expectedDate), orderCode);
+ }
+
+ // ==================== 辅助方法测试 ====================
+
+ @Test
+ void testGetCurrentSeq_NoExistingRecords_ReturnsZero() {
+ when(valueOperations.get(anyString())).thenReturn(null);
+ assertEquals(0, orderCodeGenerator.getCurrentSeq("CLEAN"));
+ }
+
+ @Test
+ void testGetCurrentSeq_ExistingRecords_ReturnsValue() {
+ when(valueOperations.get(anyString())).thenReturn("5");
+ assertEquals(5, orderCodeGenerator.getCurrentSeq("CLEAN"));
+ }
+
+ @Test
+ void testReset_DeletesKeyAndClearsCalibration() {
+ // 先校准
+ mockDbMaxSeq(0);
+ mockLuaScript(1L);
+ orderCodeGenerator.generate("CLEAN");
+
+ // 重置
+ orderCodeGenerator.reset("CLEAN");
+
+ // 再次 generate 应重新校准
+ orderCodeGenerator.generate("CLEAN");
+
+ // Lua 脚本被调用 2 次
+ verify(stringRedisTemplate, times(2)).execute(any(RedisScript.class), anyList(), anyString(), anyString());
+ }
+
+ // ==================== SQL 安全测试 ====================
+
+ @Test
+ void testGetMaxSeqFromDatabase_EscapesLikeWildcards() {
+ mockDbMaxSeq(null);
+
+ orderCodeGenerator.getMaxSeqFromDatabase("CLEAN%TEST", "20260413");
+
+ verify(jdbcTemplate).queryForObject(
+ anyString(),
+ eq(Integer.class),
+ anyInt(),
+ eq("CLEAN\\%TEST-20260413-%")
+ );
+ }
+}