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:
60
.github/workflows/maven.yml
vendored
60
.github/workflows/maven.yml
vendored
@@ -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
72
Jenkinsfile
vendored
@@ -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 服务器内网地址)
|
||||
// 部署配置(默认 Prod,release/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
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-%")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user