Merge branch 'master' into feat/multi-tenant
Resolve conflicts by accepting master changes for: - Jenkinsfile (CI/CD release/next branch support) - OrderCodeGenerator (Redis seq sync fix) - OrderCodeGeneratorTest (updated tests) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,93 +1,160 @@
|
||||
package com.viewsh.module.ops.infrastructure.code;
|
||||
|
||||
import com.viewsh.module.ops.infrastructure.redis.OpsRedisKeyBuilder;
|
||||
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 {
|
||||
|
||||
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 默认保留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 = OpsRedisKeyBuilder.orderCode(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 = OpsRedisKeyBuilder.orderCode(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 = OpsRedisKeyBuilder.orderCode(businessType, dateStr);
|
||||
stringRedisTemplate.delete(key);
|
||||
log.warn("重置工单编号序号: businessType={}, date={}", businessType, dateStr);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user