fix(ops): 修复工单编号生成器 Redis 序号与数据库不同步导致的重复编号问题
Some checks failed
Java CI with Maven / build (11) (push) Has been cancelled
Java CI with Maven / build (17) (push) Has been cancelled
Java CI with Maven / build (8) (push) Has been cancelled

问题: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) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-13 23:19:20 +08:00
parent 7707455a24
commit 6bbd49355d
2 changed files with 388 additions and 275 deletions

View File

@@ -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;
/**
* 工单编号生成器
* <p>
* 格式:{业务前缀}-{日期}-{序号}
* 例如CLEAN-20250119-0001, SECURITY-20250119-0001
* <p>
* 特性:
* - 使用 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;
/**
* 工单编号生成器
* <p>
* 格式:{业务前缀}-{日期}-{序号}
* 例如CLEAN-20250119-0001, SECURITY-20250119-0001
* <p>
* 特性:
* - 使用 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避免重复查库。
* <p>
* ConcurrentHashMap: key = "CLEAN:20260413", value = dateStr用于跨天清理
*/
private final ConcurrentHashMap<String, String> 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<Long> 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);
}
}

View File

@@ -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<String, String> 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<String, String> 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-%")
);
}
}