From a32a4375bc5be41de40ca0a6c752daa1b410677c Mon Sep 17 00:00:00 2001 From: lzh Date: Mon, 13 Apr 2026 14:44:20 +0800 Subject: [PATCH 1/4] =?UTF-8?q?build(ci):=20CI/CD=20=E6=94=AF=E6=8C=81=20r?= =?UTF-8?q?elease/next=20=E9=A2=84=E5=8F=91=E5=B8=83=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Jenkinsfile: Deploy 和 Health Check 阶段支持 release/next 分支 - release/next 部署到 staging 服务器(172.17.16.7),master 部署到 prod - 仅 master 分支推送 latest 镜像标签,避免预发布覆盖生产镜像 - GitHub Actions 添加 release/next 分支触发构建 Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/maven.yml | 60 ++++++++++++++++++------------------- Jenkinsfile | 35 ++++++++++++++++++---- 2 files changed, 59 insertions(+), 36 deletions(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 7c765927..aa21a34d 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -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 diff --git a/Jenkinsfile b/Jenkinsfile index ae8fe5f1..768d0746 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -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 @@ -63,6 +67,15 @@ pipeline { echo "Start Time: ${new Date()}" 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() } @@ -261,7 +274,10 @@ pipeline { when { allOf { expression { env.SERVICES_TO_BUILD != '' } - branch 'master' + anyOf { + branch 'master' + branch 'release/next' + } } } steps { @@ -312,7 +328,10 @@ pipeline { when { allOf { expression { env.SERVICES_TO_BUILD != '' } - branch 'master' + anyOf { + branch 'master' + branch 'release/next' + } } } steps { @@ -790,12 +809,16 @@ def buildService(String service) { --build-arg SKIP_TESTS=true \\ --build-arg MAVEN_OPTS="${env.MAVEN_OPTS}" \\ -t ${env.REGISTRY}/${service}:${env.IMAGE_TAG} \\ - -t ${env.REGISTRY}/${service}:latest \\ . - # 推送镜像 + # 推送带版本号的镜像 docker push ${env.REGISTRY}/${service}:${env.IMAGE_TAG} - docker push ${env.REGISTRY}/${service}:latest + + # 仅 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 + fi set +x """ From ea374d131a2f79658afe62a8fbcd3984b9a26dad Mon Sep 17 00:00:00 2001 From: lzh Date: Mon, 13 Apr 2026 23:18:30 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(ops):=20=E5=B7=A5=E7=89=8C=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E8=BF=94=E5=9B=9E=E6=98=B5=E7=A7=B0=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=EF=BC=8C=E6=89=8B=E5=8A=A8=E6=B4=BE=E5=8D=95=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=BC=A0=E5=85=A5=E8=AE=BE=E5=A4=87=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BadgeStatusRespDTO 新增 nickname 字段,透传设备昵称 - CleanManualDispatchReqDTO 新增 assigneeName,派单时携带设备显示名 - CleanWorkOrderServiceImpl 将 assigneeName 传递给派单引擎 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ops/environment/service/badge/CleanBadgeServiceImpl.java | 1 + .../service/cleanorder/CleanWorkOrderServiceImpl.java | 1 + .../service/cleanorder/dto/CleanManualDispatchReqDTO.java | 5 +++++ .../com/viewsh/module/ops/api/clean/BadgeStatusRespDTO.java | 3 +++ 4 files changed, 10 insertions(+) diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java index 5ef2481f..3ad32532 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java @@ -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())) diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanWorkOrderServiceImpl.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanWorkOrderServiceImpl.java index fd8e3d7a..5f7557e7 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanWorkOrderServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanWorkOrderServiceImpl.java @@ -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()); } diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/dto/CleanManualDispatchReqDTO.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/dto/CleanManualDispatchReqDTO.java index ca5f3fa9..902daf3c 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/dto/CleanManualDispatchReqDTO.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/dto/CleanManualDispatchReqDTO.java @@ -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; diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeStatusRespDTO.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeStatusRespDTO.java index d4884521..dd1da956 100644 --- a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeStatusRespDTO.java +++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeStatusRespDTO.java @@ -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; From 7707455a2418386e318b5ba30f8f7feaff7698f0 Mon Sep 17 00:00:00 2001 From: lzh Date: Mon, 13 Apr 2026 23:18:54 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(ops):=20=E6=89=8B=E5=8A=A8=E6=B4=BE?= =?UTF-8?q?=E5=8D=95=E6=94=BE=E5=AE=BD=E6=A0=A1=E9=AA=8C=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=B7=A8=E5=8C=BA=E5=9F=9F=E5=92=8C=E5=90=91=E5=BF=99?= =?UTF-8?q?=E7=A2=8C=E8=AE=BE=E5=A4=87=E6=B4=BE=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 canAcceptNewOrder、区域绑定和区域匹配校验,仅保留在线检查。 手动派单由调度员人工判断合理性,自动派单的校验仍在 BadgeDeviceAreaAssignStrategy 中完成。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../manual/CleanOrderBusinessStrategy.java | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/manual/CleanOrderBusinessStrategy.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/manual/CleanOrderBusinessStrategy.java index 74e5aa3b..95d9b611 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/manual/CleanOrderBusinessStrategy.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/manual/CleanOrderBusinessStrategy.java @@ -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()) + } } From 6bbd49355d29fab6e9110b25402a01cf3dd40eea Mon Sep 17 00:00:00 2001 From: lzh Date: Mon, 13 Apr 2026 23:19:20 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(ops):=20=E4=BF=AE=E5=A4=8D=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E7=BC=96=E5=8F=B7=E7=94=9F=E6=88=90=E5=99=A8=20Redis?= =?UTF-8?q?=20=E5=BA=8F=E5=8F=B7=E4=B8=8E=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E4=B8=8D=E5=90=8C=E6=AD=A5=E5=AF=BC=E8=87=B4=E7=9A=84=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E7=BC=96=E5=8F=B7=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: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) --- .../code/OrderCodeGenerator.java | 253 +++++++---- .../code/OrderCodeGeneratorTest.java | 410 ++++++++++-------- 2 files changed, 388 insertions(+), 275 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/code/OrderCodeGenerator.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/code/OrderCodeGenerator.java index f5162f41..91178ec1 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/code/OrderCodeGenerator.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/code/OrderCodeGenerator.java @@ -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; - -/** - * 工单编号生成器 - *

- * 格式:{业务前缀}-{日期}-{序号} - * 例如:CLEAN-20250119-0001, SECURITY-20250119-0001 - *

- * 特性: - * - 使用 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; + +/** + * 工单编号生成器 + *

+ * 格式:{业务前缀}-{日期}-{序号} + * 例如:CLEAN-20250119-0001, SECURITY-20250119-0001 + *

+ * 特性: + * - 使用 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,避免重复查库。 + *

+ * ConcurrentHashMap: key = "CLEAN:20260413", value = dateStr(用于跨天清理) + */ + private final ConcurrentHashMap 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 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); + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/infrastructure/code/OrderCodeGeneratorTest.java b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/infrastructure/code/OrderCodeGeneratorTest.java index 26a8cf3f..0636ede1 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/infrastructure/code/OrderCodeGeneratorTest.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/infrastructure/code/OrderCodeGeneratorTest.java @@ -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 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 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-%") + ); + } +}