From 6bbd49355d29fab6e9110b25402a01cf3dd40eea Mon Sep 17 00:00:00 2001 From: lzh Date: Mon, 13 Apr 2026 23:19:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(ops):=20=E4=BF=AE=E5=A4=8D=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E7=BC=96=E5=8F=B7=E7=94=9F=E6=88=90=E5=99=A8=20Redis?= =?UTF-8?q?=20=E5=BA=8F=E5=8F=B7=E4=B8=8E=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E4=B8=8D=E5=90=8C=E6=AD=A5=E5=AF=BC=E8=87=B4=E7=9A=84=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E7=BC=96=E5=8F=B7=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:Redis 重启或 key 过期后序号从 1 重新计数,与数据库已有编号冲突。 修复方案: - 应用启动后首次生成时,从数据库查询当天最大序号校准 Redis - 使用 Lua 脚本原子操作(校准 + 自增),避免并发竞态 - 后续调用走纯 Redis INCR,无额外数据库开销 - SQL 使用 deleted = b'0' 兼容 bit(1) 列类型 - LIKE 查询转义 % 和 _ 通配符 - 校准异常向上抛出,避免静默产生重复 - calibratedKeys 跨天自动清理旧条目 同步更新单元测试,覆盖校准、纯 Redis 自增、异常处理、SQL 转义等 13 个用例。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../code/OrderCodeGenerator.java | 253 +++++++---- .../code/OrderCodeGeneratorTest.java | 410 ++++++++++-------- 2 files changed, 388 insertions(+), 275 deletions(-) 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-%") + ); + } +}