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:
lzh
2026-04-16 18:08:14 +08:00
9 changed files with 470 additions and 358 deletions

View File

@@ -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);
}
}