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,30 +1,30 @@
# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time
# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven
name: Java CI with Maven
on:
push:
branches: [ master ]
# pull_request:
# branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
java: [ '8', '11', '17' ]
steps:
- uses: actions/checkout@v2
- name: Set up JDK ${{ matrix.Java }}
uses: actions/setup-java@v2
with:
java-version: ${{ matrix.java }}
distribution: 'temurin'
cache: maven
- name: Build with Maven
run: mvn -B package --file pom.xml -Dmaven.test.skip=true
# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time
# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven
name: Java CI with Maven
on:
push:
branches: [ master, release/next ]
# pull_request:
# branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
java: [ '8', '11', '17' ]
steps:
- uses: actions/checkout@v2
- name: Set up JDK ${{ matrix.Java }}
uses: actions/setup-java@v2
with:
java-version: ${{ matrix.java }}
distribution: 'temurin'
cache: maven
- name: Build with Maven
run: mvn -B package --file pom.xml -Dmaven.test.skip=true

72
Jenkinsfile vendored
View File

@@ -27,11 +27,15 @@ pipeline {
// 服务配置
CORE_SERVICES = 'viewsh-gateway,viewsh-module-system-server,viewsh-module-infra-server,viewsh-module-iot-server,viewsh-module-iot-gateway,viewsh-module-ops-server'
// 部署配置Prod 服务器内网地址
// 部署配置(默认 Prodrelease/next 分支会在 Initialize 阶段覆盖为 Staging
DEPLOY_HOST = '172.17.16.14'
DEPLOY_PATH = '/opt/aiot-platform-cloud'
SSH_KEY = '/var/jenkins_home/.ssh/id_rsa'
// Staging 服务器配置
STAGING_DEPLOY_HOST = '172.17.16.7'
STAGING_DEPLOY_PATH = '/opt/aiot-platform-cloud'
// 性能配置 - 将动态调整
BUILD_TIMEOUT = 45
DEPLOY_TIMEOUT = 10
@@ -61,25 +65,17 @@ pipeline {
echo "Build: #${env.BUILD_NUMBER}"
echo "Workspace: ${env.WORKSPACE}"
echo "Start Time: ${new Date()}"
if (env.BRANCH_NAME == 'master') {
env.DEPLOY_ENV = 'prod'
env.DEPLOY_HOST = '172.17.16.14'
env.DEPLOY_PATH = '/opt/aiot-platform-cloud'
env.DEPLOY_COMPOSE_ARGS = '-f docker-compose.core.yml'
} else if (env.BRANCH_NAME == 'stage/multi-tenant-isolation') {
env.DEPLOY_ENV = 'stage'
env.DEPLOY_HOST = '172.17.16.7'
env.DEPLOY_PATH = '/opt/aiot-platform-cloud-stage'
env.DEPLOY_COMPOSE_ARGS = '-f docker-compose.core.yml -f docker-compose.stage.override.yml'
} else {
env.DEPLOY_ENV = 'build-only'
env.DEPLOY_COMPOSE_ARGS = '-f docker-compose.core.yml'
}
echo "Deploy Env: ${env.DEPLOY_ENV}"
echo "Deploy Host: ${env.DEPLOY_HOST ?: 'N/A'}"
echo "Deploy Path: ${env.DEPLOY_PATH ?: 'N/A'}"
echo "=========================================="
// 根据分支选择部署目标
if (env.BRANCH_NAME == 'release/next') {
env.DEPLOY_HOST = env.STAGING_DEPLOY_HOST
env.DEPLOY_PATH = env.STAGING_DEPLOY_PATH
echo "📦 Deploy target: STAGING (${env.DEPLOY_HOST})"
} else {
echo "📦 Deploy target: PRODUCTION (${env.DEPLOY_HOST})"
}
// 【优化2】动态检测系统资源
detectSystemResources()
}
@@ -278,7 +274,10 @@ pipeline {
when {
allOf {
expression { env.SERVICES_TO_BUILD != '' }
expression { env.BRANCH_NAME == 'master' || env.BRANCH_NAME == 'stage/multi-tenant-isolation' }
anyOf {
branch 'master'
branch 'release/next'
}
}
}
steps {
@@ -298,12 +297,9 @@ pipeline {
backupCurrentDeployment(sortedServices)
}
// 【新增】同步最新的 compose 文件到部署服务器
echo "📂 Syncing compose files to deploy host..."
// 【新增】同步最新的 docker-compose.core.yml 到部署服务器
echo "📂 Syncing docker-compose.core.yml to deploy host..."
sh "scp -o StrictHostKeyChecking=no -i ${env.SSH_KEY} docker-compose.core.yml root@${env.DEPLOY_HOST}:${env.DEPLOY_PATH}/"
if (env.DEPLOY_ENV == 'stage') {
sh "scp -o StrictHostKeyChecking=no -i ${env.SSH_KEY} docker-compose.stage.override.yml root@${env.DEPLOY_HOST}:${env.DEPLOY_PATH}/"
}
try {
// 串行部署(保证依赖关系)
@@ -332,7 +328,10 @@ pipeline {
when {
allOf {
expression { env.SERVICES_TO_BUILD != '' }
expression { env.BRANCH_NAME == 'master' || env.BRANCH_NAME == 'stage/multi-tenant-isolation' }
anyOf {
branch 'master'
branch 'release/next'
}
}
}
steps {
@@ -672,7 +671,7 @@ def getPreviousImageTag() {
script: """
ssh ${sshOpts} root@${env.DEPLOY_HOST} '
cd ${env.DEPLOY_PATH}
docker compose ${env.DEPLOY_COMPOSE_ARGS} images --format json | \
docker compose -f docker-compose.core.yml images --format json | \
jq -r ".[0].Tag" | head -1
' 2>/dev/null || echo "latest"
""",
@@ -696,16 +695,12 @@ def backupCurrentDeployment(def services) {
sh """
ssh ${sshOpts} root@${env.DEPLOY_HOST} '
cd ${env.DEPLOY_PATH}
export IMAGE_TAG=${env.IMAGE_TAG}
# 保存当前 docker-compose 配置
cp docker-compose.core.yml docker-compose.core.yml.backup-${env.BUILD_NUMBER}
if [ -f docker-compose.stage.override.yml ]; then
cp docker-compose.stage.override.yml docker-compose.stage.override.yml.backup-${env.BUILD_NUMBER}
fi
# 记录当前运行的镜像
docker compose ${env.DEPLOY_COMPOSE_ARGS} images > deployment-state-${env.BUILD_NUMBER}.txt
docker compose -f docker-compose.core.yml images > deployment-state-${env.BUILD_NUMBER}.txt
echo "✅ Backup completed: deployment-state-${env.BUILD_NUMBER}.txt"
'
@@ -742,10 +737,10 @@ def rollbackDeployment(def services) {
export IMAGE_TAG=${env.PREVIOUS_IMAGE_TAG}
# 拉取旧版本镜像
docker compose ${env.DEPLOY_COMPOSE_ARGS} pull ${service}
docker compose -f docker-compose.core.yml pull ${service}
# 重启服务
docker compose ${env.DEPLOY_COMPOSE_ARGS} up -d ${service}
docker compose -f docker-compose.core.yml up -d ${service}
echo "✅ ${service} rolled back to ${env.PREVIOUS_IMAGE_TAG}"
'
@@ -816,8 +811,10 @@ def buildService(String service) {
-t ${env.REGISTRY}/${service}:${env.IMAGE_TAG} \\
.
# 推送镜像
# 推送带版本号的镜像
docker push ${env.REGISTRY}/${service}:${env.IMAGE_TAG}
# 仅 master 分支推送 latest 标签
if [ "${env.BRANCH_NAME}" = "master" ]; then
docker tag ${env.REGISTRY}/${service}:${env.IMAGE_TAG} ${env.REGISTRY}/${service}:latest
docker push ${env.REGISTRY}/${service}:latest
@@ -831,7 +828,7 @@ def buildService(String service) {
// 获取镜像大小
def imageSize = sh(
script: "docker images ${env.REGISTRY}/${service}:${env.IMAGE_TAG} --format '{{.Size}}'",
script: "docker images ${env.REGISTRY}/${service}:latest --format '{{.Size}}'",
returnStdout: true
).trim()
@@ -877,12 +874,11 @@ def deployService(String service) {
set -e
cd ${env.DEPLOY_PATH}
export IMAGE_TAG=${env.IMAGE_TAG}
echo "📥 Pulling ${service}..."
docker compose ${env.DEPLOY_COMPOSE_ARGS} pull ${service}
docker compose -f docker-compose.core.yml pull ${service}
echo "🔄 Restarting ${service}..."
docker compose ${env.DEPLOY_COMPOSE_ARGS} up -d ${service}
docker compose -f docker-compose.core.yml up -d ${service}
echo "⏳ Waiting for container to start..."
sleep 5

View File

@@ -275,6 +275,7 @@ public class CleanBadgeServiceImpl implements CleanBadgeService {
return BadgeStatusRespDTO.builder()
.deviceId(status.getDeviceId())
.deviceKey(status.getDeviceCode())
.nickname(status.getNickname())
.status(status.getStatusCode())
.batteryLevel(status.getBatteryLevel())
.lastHeartbeatTime(formatTimestamp(status.getLastHeartbeatTime()))

View File

@@ -347,6 +347,7 @@ public class CleanWorkOrderServiceImpl implements CleanWorkOrderService {
.orderId(req.getOrderId())
.operator(OperatorContext.ofAdmin(req.getOperatorId(), resolveUserName(req.getOperatorId())))
.assigneeId(req.getAssigneeId())
.assigneeName(req.getAssigneeName())
.reason(req.getRemark())
.build());
}

View File

@@ -2,6 +2,7 @@ package com.viewsh.module.ops.environment.service.cleanorder.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
@@ -21,6 +22,10 @@ public class CleanManualDispatchReqDTO {
@NotNull(message = "目标设备ID不能为空")
private Long assigneeId;
@Schema(description = "目标设备名称(昵称或设备编码)", example = "男卫-01")
@Size(max = 100, message = "设备名称不能超过100字符")
private String assigneeName;
@Schema(description = "派单备注", example = "紧急情况,指定该设备处理")
private String remark;

View File

@@ -55,15 +55,9 @@ public class CleanOrderBusinessStrategy implements OrderBusinessStrategy {
if (!badge.isOnline()) {
throw new IllegalStateException("目标保洁设备当前离线,不能手动派单");
}
if (!badge.canAcceptNewOrder()) {
throw new IllegalStateException("目标保洁设备当前不可接单");
}
if (order.getAreaId() != null && badge.getCurrentAreaId() == null) {
throw new IllegalStateException("目标保洁设备当前未绑定区域,不能手动派单");
}
if (order.getAreaId() != null && !order.getAreaId().equals(badge.getCurrentAreaId())) {
throw new IllegalStateException("目标保洁设备不在当前工单所属区域");
}
// 注意:以下校验已按产品需求移除,由调度员人工判断合理性:
// 1. canAcceptNewOrder() — 允许向 BUSY/PAUSED 工牌手动派单,工单进入 QUEUED 排队
// 2. 区域一致性校验 — 允许跨区域分配,支持灵活调度场景
}
@Override
@@ -87,4 +81,11 @@ public class CleanOrderBusinessStrategy implements OrderBusinessStrategy {
log.info("[CleanStrategy] 升级优先级后置完成: orderId={}, newPriority={}, queueId={}",
cmd.getOrderId(), newPriority, queueDTO.getId());
}
@Override
public void afterDispatch(DispatchOrderCommand cmd, OpsOrderDO order) {
// TODO: 转派场景下order.getAssigneeId() != null && !order.getAssigneeId().equals(cmd.getAssigneeId())
// 应向旧工牌发送震动/语音通知告知任务已转移,避免旧工牌持有者继续前往已无效的区域。
// 实现参考cleanOrderNotificationService.sendReassignNotification(oldBadgeId, order.getOrderCode())
}
}

View File

@@ -26,6 +26,9 @@ public class BadgeStatusRespDTO {
@Schema(description = "设备编码", example = "badge_001")
private String deviceKey;
@Schema(description = "设备昵称(用户可读的显示名称)", example = "张三的工牌")
private String nickname;
@Schema(description = "状态IDLE/BUSY/OFFLINE/PAUSED", example = "IDLE")
private String status;

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

View File

@@ -1,190 +1,228 @@
package com.viewsh.module.ops.infrastructure.code;
import com.viewsh.framework.tenant.core.context.TenantContextHolder;
import org.junit.jupiter.api.AfterEach;
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() {
TenantContextHolder.setTenantId(1L);
orderCodeGenerator = new OrderCodeGenerator();
ReflectionTestUtils.setField(orderCodeGenerator, "stringRedisTemplate", stringRedisTemplate);
// Mock stringRedisTemplate.opsForValue()
lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
}
@AfterEach
void tearDown() {
TenantContextHolder.clear();
}
@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-%")
);
}
}