diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpServerDeploy.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpServerDeploy.java index 0380bada..d507bba8 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpServerDeploy.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpServerDeploy.java @@ -1,10 +1,6 @@ package org.dromara.oa.erp.domain; -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableField; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableLogic; -import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import lombok.EqualsAndHashCode; import org.dromara.common.tenant.core.TenantEntity; @@ -13,6 +9,10 @@ import java.io.Serial; /** * 服务器部署信息对象 erp_server_deploy + *

+ * 记录文件/应用在指定服务器上的部署详情,支持多版本管理。 + * 每次审批通过后会生成新版本,旧版本保留为历史快照,便于追溯和回滚。 + * 通过 deployCode 关联同一部署项的不同版本,isCurrent 标识当前生效版本。 * * @author zch * @date 2026-06-03 @@ -26,68 +26,75 @@ public class ErpServerDeploy extends TenantEntity { private static final long serialVersionUID = 1L; /** - * 部署主键ID + * 部署主键ID(雪花算法生成) */ @TableId(value = "deploy_id", type = IdType.ASSIGN_ID) private Long deployId; /** - * 部署业务编码 + * 部署业务编码(通过编码规则自动生成,同一部署项的所有版本共享此编码) */ private String deployCode; /** - * 关联服务器业务编码 + * 关联服务器业务编码(指向 erp_server_info 中的 serverCode,须为已审批通过的服务器) */ private String serverCode; /** - * 版本号 + * 版本号(同一 deployCode 下递增,从1开始,每次审批通过后变更时自动递增) */ private Integer version; /** * 是否当前生效版本(1是 0否) + *

+ * 审批通过后自动置为1,同时将同 deployCode 的其他版本置为0; + * 草稿、审批中、作废等状态下为0。 */ private String isCurrent; /** - * 文件/应用部署位置 + * 文件/应用部署位置(服务器上的绝对路径或相对路径,最长500字符,禁止包含命令连接符等危险字符) */ private String deployPath; /** - * 部署端口 + * 部署端口(支持单端口如8080、逗号分隔如8080,8081、端口范围如8080-8090,端口值范围1-65535) */ private String deployPort; /** - * 端口是否暴露公网(1是 0否) + * 端口是否暴露公网(1是 0否,用于安全审计,标识该部署端口是否可从外网直接访问) */ private String portExposed; /** - * 文件/应用用途说明 + * 文件/应用用途说明(描述本次部署的业务目的,如"部署CRM后端服务"等) */ private String deployPurpose; /** - * 负责人ID + * 负责人ID(关联 sys_user 表的用户ID,标识该部署项的运维负责人) */ private Long personId; /** - * 附件OSS ID + * 附件OSS ID(逗号分隔的多个文件ID,存储部署相关文档如配置文件、部署脚本等) */ private String ossId; /** - * 业务状态(字典 deploy_status) + * 业务状态(字典 deploy_status:草稿/审批中/已生效/已作废) + *

+ * 与 flowStatus 联动:草稿对应DRAFT,审批中对应APPROVING,审批通过对应COMPLETED,作废对应INVALID。 */ private String deployStatus; /** - * 流程状态(字典 wf_business_status) + * 流程状态(字典 wf_business_status:草稿/待审批/已完成/已退回/已撤销/已失效/已终止) + *

+ * 由 Warm-Flow 工作流引擎回调驱动更新,业务代码不应直接修改此字段。 */ private String flowStatus; @@ -97,13 +104,13 @@ public class ErpServerDeploy extends TenantEntity { private String remark; /** - * 删除标志(0代表存在 1代表删除) + * 删除标志(0代表存在 1代表删除,MyBatis-Plus 逻辑删除字段,查询时自动过滤已删除记录) */ @TableLogic private String delFlag; /** - * 负责人名称 + * 负责人名称(非数据库字段,通过 MPJ 连表查询 sys_user 表获取,用于前端展示) */ @TableField(exist = false) private String personName; diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpServerDeployLog.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpServerDeployLog.java index 3f88ba07..0c02287e 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpServerDeployLog.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpServerDeployLog.java @@ -12,6 +12,9 @@ import java.io.Serial; /** * 服务器部署操作日志对象 erp_server_deploy_log + *

+ * 记录服务器部署信息每次版本变更时的字段差异,以JSON数组形式存储变更内容。 + * 用于审计追踪,帮助运维人员了解每次部署变更的具体修改项。 * * @author zch * @date 2026-06-03 @@ -25,28 +28,28 @@ public class ErpServerDeployLog extends TenantEntity { private static final long serialVersionUID = 1L; /** - * 日志主键ID + * 日志主键ID(数据库自增) */ @TableId(value = "log_id", type = IdType.AUTO) private Long logId; /** - * 关联部署快照ID + * 关联部署快照ID(指向 erp_server_deploy 表的 deploy_id,标识该变更日志所属的部署版本) */ private Long deployId; /** - * 变更内容(JSON数组) + * 变更内容(JSON数组格式,每个元素包含 field/label/oldValue/newValue 四个字段,记录具体字段的变更详情) */ private String changeContent; /** - * 变更摘要 + * 变更摘要(人类可读的变更描述,如"部署项 SD20260603001 第 2 版变更") */ private String changeSummary; /** - * 删除标志(0代表存在 1代表删除) + * 删除标志(0代表存在 1代表删除,MyBatis-Plus 逻辑删除字段,查询时自动过滤已删除记录) */ @TableLogic private String delFlag; diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpServerInfo.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpServerInfo.java index 7d9b5241..ca541c42 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpServerInfo.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/domain/ErpServerInfo.java @@ -14,6 +14,10 @@ import java.util.Date; /** * 服务器基础信息对象 erp_server_info + *

+ * 记录企业服务器的登记信息,包括物理/虚拟服务器的基础属性、获取方式、租赁信息等。 + * 支持多版本管理:每次审批通过后变更会生成新版本,旧版本保留为历史快照。 + * 通过 serverCode 关联同一服务器的不同版本,isCurrent 标识当前生效版本。 * * @author zch * @date 2026-06-03 @@ -27,123 +31,132 @@ public class ErpServerInfo extends TenantEntity { private static final long serialVersionUID = 1L; /** - * 服务器主键ID + * 服务器主键ID(雪花算法生成) */ @TableId(value = "server_id", type = IdType.ASSIGN_ID) private Long serverId; /** - * 服务器业务编码 + * 服务器业务编码(通过编码规则自动生成,同一服务器的所有版本共享此编码) */ private String serverCode; /** - * 服务器名称 + * 服务器名称(用户可读的服务器标识,如"CRM生产服务器"、"测试环境服务器"等) */ private String serverName; /** - * 版本号 + * 版本号(同一 serverCode 下递增,从1开始,每次审批通过后变更时自动递增) */ private Integer version; /** * 是否当前生效版本(1是 0否) + *

+ * 审批通过后自动置为1,同时将同 serverCode 的其他版本置为0; + * 草稿、审批中、作废等状态下为0。 */ private String isCurrent; /** - * 服务器类型(字典 server_type) + * 服务器类型(字典 server_type:local=本地服务器,cloud=云服务器) */ private String serverType; /** - * 获取方式(字典 acquisition_type) + * 获取方式(字典 acquisition_type:purchase=购买,lease=租赁) */ private String acquisitionType; /** - * 操作系统类型(字典 os_type) + * 操作系统类型(字典 os_type:如 Linux/Windows 等) */ private String osType; /** - * 是否有公网IP(1是 0否) + * 是否有公网IP(1是 0否,用于安全分类,标识该服务器是否可直接从外网访问) */ private String hasPublicIp; /** - * IP地址 + * IP地址(支持多个IP,逗号或分号分隔;支持IPv4和IPv6格式) */ private String ipAddress; /** - * 服务器物理位置 + * 服务器物理位置(如机房名称、机柜编号等,云服务器可填区域信息) */ private String location; /** - * 购买金额(元) + * 购买金额(元,不能为负数,获取方式为购买时填写) */ private BigDecimal purchaseAmount; /** - * 品牌(本地服务器专用) + * 品牌(本地服务器专用,如 Dell/HP/Lenovo 等,serverType 为 local 时必填) */ private String brand; /** - * 型号(本地服务器专用) + * 型号(本地服务器专用,如 PowerEdge R740 等,serverType 为 local 时必填) */ private String model; /** - * 硬件参数(本地服务器专用) + * 硬件参数(本地服务器专用,描述CPU/内存/磁盘等配置信息) */ private String hardwareSpec; /** - * 租赁起始日期 + * 租赁起始日期(获取方式为租赁时必填,不能晚于结束日期) */ private Date leaseStartDate; /** - * 租赁结束日期 + * 租赁结束日期(获取方式为租赁时必填,不能早于起始日期) */ private Date leaseEndDate; /** - * 套餐年数 + * 套餐年数(租赁套餐的年限,用于计算租赁总费用和到期提醒) */ private Integer leasePackageYears; /** - * 租赁金额(元) + * 租赁金额(元,不能为负数,获取方式为租赁时填写) */ private BigDecimal leaseAmount; /** - * 业务状态(字典 server_status) + * 业务状态(字典 server_status:草稿/审批中/已生效/已作废) + *

+ * 与 flowStatus 联动:草稿对应DRAFT,审批中对应APPROVING,审批通过对应COMPLETED,作废对应INVALID。 */ private String serverStatus; /** - * 流程状态(字典 wf_business_status) + * 流程状态(字典 wf_business_status:草稿/待审批/已完成/已退回/已撤销/已失效/已终止) + *

+ * 由 Warm-Flow 工作流引擎回调驱动更新,业务代码不应直接修改此字段。 */ private String flowStatus; /** - * 扩展属性(JSON) + * 扩展属性(JSON格式,仅允许保存白名单字段如cpu/memory/disk/bandwidth/region等运维描述信息) + *

+ * 安全策略:禁止保存密码、密钥、Token等敏感凭据,保存前会进行敏感词检测和白名单校验。 */ private String extAttr; /** - * 备注 + * 备注(补充说明信息) */ private String remark; /** - * 删除标志(0代表存在 1代表删除) + * 删除标志(0代表存在 1代表删除,MyBatis-Plus 逻辑删除字段,查询时自动过滤已删除记录) */ @TableLogic private String delFlag; diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpServerDeployServiceImpl.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpServerDeployServiceImpl.java index d165d1bc..08623468 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpServerDeployServiceImpl.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpServerDeployServiceImpl.java @@ -37,17 +37,18 @@ import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; +import java.util.*; import java.util.regex.Pattern; /** * 服务器部署信息Service业务层处理 + *

+ * 核心职责: + * 1. 管理服务器部署信息的增删改查,支持多版本快照管理; + * 2. 对接 Warm-Flow 工作流引擎,实现草稿→提交审批→审批通过/退回/作废的全生命周期; + * 3. 版本控制:审批通过的记录不可直接修改,变更时自动创建新版本; + * 4. 变更日志:新版本创建时自动对比旧版本字段差异,生成变更日志; + * 5. 安全校验:部署路径防注入校验、端口格式校验、服务器状态校验等。 * * @author zch * @date 2026-06-03 @@ -57,7 +58,7 @@ import java.util.regex.Pattern; @Service public class ErpServerDeployServiceImpl implements IErpServerDeployService { - /** 服务器部署操作流程编码 */ + /** 服务器部署操作流程编码(对应 Warm-Flow 中的流程定义编码) */ public static final String FLOW_CODE_DEPLOY = "OASD"; /** @@ -65,35 +66,57 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { */ private static final String DEPLOY_CODE_RULE = "1036"; - private static final String YES = "1"; - private static final String NO = "0"; + private static final String YES = "1"; // 是 + private static final String NO = "0"; // 否 + /** 端口格式正则:支持单端口(8080)、逗号分隔(8080,8081)、端口范围(8080-8090) */ private static final Pattern PORT_PATTERN = Pattern.compile("^\\d{1,5}(-\\d{1,5})?(,\\d{1,5}(-\\d{1,5})?)*$"); + /** 危险路径字符正则:防止命令注入,禁止分号、管道符、反引号、美元符号、重定向符、换行等 */ private static final Pattern DANGEROUS_PATH_PATTERN = Pattern.compile("[;&|`$<>\\r\\n]"); - private final ErpServerDeployMapper baseMapper; - private final ErpServerDeployLogMapper deployLogMapper; - private final ErpServerInfoMapper serverInfoMapper; + private final ErpServerDeployMapper baseMapper; // 部署信息 Mapper + private final ErpServerDeployLogMapper deployLogMapper; // 部署变更日志 Mapper + private final ErpServerInfoMapper serverInfoMapper; // 服务器基础信息 Mapper(用于校验服务器状态) + /** 远程工作流服务(Dubbo 调用,用于发起流程、删除流程实例) */ @DubboReference(timeout = 30000) private RemoteWorkflowService remoteWorkflowService; + /** 远程编码规则服务(Dubbo 调用,用于自动生成部署编号) */ @DubboReference(timeout = 30000) private RemoteCodeRuleService remoteCodeRuleService; + /** + * 根据部署ID查询详情 + *

+ * 返回数据包含:部署基本信息、变更日志列表、同编码的历史版本列表。 + * + * @param deployId 部署主键ID + * @return 部署详情VO(含变更日志和版本列表),不存在则返回null + */ @Override public ErpServerDeployVo queryById(Long deployId) { + // 查询部署基本信息 ErpServerDeployVo vo = queryOneById(deployId); if (vo == null) { return null; } + // 补充变更日志列表 vo.setServerDeployLogList(queryLogs(deployId)); + // 补充同编码的历史版本列表 if (StringUtils.isNotBlank(vo.getDeployCode())) { vo.setVersionList(queryVersions(vo.getDeployCode())); } return vo; } + /** + * 根据部署ID查询单条记录(不含关联数据) + * + * @param deployId 部署主键ID + * @return 部署VO,不存在则返回null + */ private ErpServerDeployVo queryOneById(Long deployId) { + // 使用 MPJ 构造器查询,过滤已删除记录 MPJLambdaWrapper lqw = JoinWrappers.lambda(ErpServerDeploy.class) .selectAll(ErpServerDeploy.class) .eq(ErpServerDeploy::getDelFlag, NO) @@ -102,14 +125,28 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { return list.isEmpty() ? null : list.get(0); } + /** + * 分页查询服务器部署信息列表 + * + * @param bo 查询条件 + * @param pageQuery 分页参数 + * @return 分页结果 + */ @Override public TableDataInfo queryPageList(ErpServerDeployBo bo, PageQuery pageQuery) { + // 构建查询条件并按创建时间倒序 MPJLambdaWrapper lqw = buildQueryWrapper(bo); lqw.orderByDesc(ErpServerDeploy::getCreateTime); Page result = baseMapper.selectCustomErpServerDeployVoList(pageQuery.build(), lqw); return TableDataInfo.build(result); } + /** + * 不分页查询服务器部署信息列表 + * + * @param bo 查询条件 + * @return 部署信息列表 + */ @Override public List queryList(ErpServerDeployBo bo) { MPJLambdaWrapper lqw = buildQueryWrapper(bo); @@ -117,6 +154,14 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { return baseMapper.selectCustomErpServerDeployVoList(lqw); } + /** + * 查询同一部署编码的所有历史版本 + *

+ * 按版本号倒序排列,用于前端版本切换下拉。 + * + * @param deployCode 部署业务编码 + * @return 版本列表,编码为空时返回空列表 + */ @Override public List queryVersions(String deployCode) { if (StringUtils.isBlank(deployCode)) { @@ -130,6 +175,12 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { return baseMapper.selectCustomErpServerDeployVoList(lqw); } + /** + * 查询指定部署的变更日志列表 + * + * @param deployId 部署主键ID + * @return 变更日志列表,按创建时间倒序 + */ private List queryLogs(Long deployId) { return deployLogMapper.selectVoList(new LambdaQueryWrapper() .eq(ErpServerDeployLog::getDeployId, deployId) @@ -137,6 +188,14 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { .orderByDesc(ErpServerDeployLog::getCreateTime)); } + /** + * 构建查询条件包装器 + *

+ * 根据查询BO中的非空字段动态拼接 like/eq 条件,始终过滤已删除记录。 + * + * @param bo 查询条件BO + * @return MPJ 查询条件包装器 + */ private MPJLambdaWrapper buildQueryWrapper(ErpServerDeployBo bo) { return JoinWrappers.lambda(ErpServerDeploy.class) .selectAll(ErpServerDeploy.class) @@ -151,38 +210,71 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { .eq(StringUtils.isNotBlank(bo.getIsCurrent()), ErpServerDeploy::getIsCurrent, bo.getIsCurrent()); } + /** + * 保存草稿 + *

+ * 不会发起工作流,仅保存数据。状态设为草稿,不设为当前版本。 + * 若已有审批通过的版本,会自动创建新版本;否则直接更新现有记录。 + * + * @param bo 部署信息BO + * @return 保存后的部署详情VO + */ @Override @Transactional(rollbackFor = Exception.class) public ErpServerDeployVo draft(ErpServerDeployBo bo) { + // 准备数据:校验服务器状态、生成编号、计算版本号 prepareForSave(bo, false); + // 行级锁锁定同编码的所有版本记录,防止并发版本号冲突 lockVersionRows(bo.getDeployCode()); + // 设置草稿状态 bo.setDeployStatus(OAStatusEnum.DRAFT.getStatus()); bo.setFlowStatus(BusinessStatusEnum.DRAFT.getStatus()); bo.setIsCurrent(NO); + // 保存(新建版本或更新现有记录) saveDraftOrVersion(bo); return queryById(bo.getDeployId()); } + /** + * 提交并发起工作流 + *

+ * 保存数据后调用远程工作流服务发起审批流程。 + * 包含安全校验:服务器状态检查、旧流程实例清理、同编码审批中版本冲突检测。 + * 使用 @GlobalTransactional 保证跨服务(OA模块 + 工作流模块)的数据一致性。 + * + * @param bo 部署信息BO + * @return 提交后的部署详情VO + * @throws ServiceException 服务器不可用/编号生成失败/流程发起失败等异常 + */ @Override @Transactional(rollbackFor = Exception.class) @GlobalTransactional(rollbackFor = Exception.class) public ErpServerDeployVo submitAndStartFlow(ErpServerDeployBo bo) { + // 准备数据并设置流程编码 prepareForSave(bo, true); + // 行级锁防止并发版本冲突 lockVersionRows(bo.getDeployCode()); + // 清理被退回或撤销的旧流程实例,避免流程引擎中残留数据 deleteReturnedOrCanceledInstance(bo.getDeployId()); + // 校验同一部署编码下没有其他审批中的版本 checkNoApprovingVersion(bo.getDeployCode(), bo.getDeployId()); + // 设置审批中状态 bo.setDeployStatus(OAStatusEnum.APPROVING.getStatus()); bo.setFlowStatus(BusinessStatusEnum.WAITING.getStatus()); bo.setIsCurrent(NO); + // 保存数据(新建版本或更新现有记录) saveDraftOrVersion(bo); + // 填充工作流变量和业务扩展信息 fillWorkflowParams(bo); + // 构建远程流程启动请求 RemoteStartProcess startProcess = new RemoteStartProcess(); startProcess.setBusinessId(String.valueOf(bo.getDeployId())); startProcess.setFlowCode(bo.getFlowCode()); startProcess.setVariables(bo.getVariables()); startProcess.setBizExt(bo.getBizExt()); bo.getBizExt().setBusinessId(startProcess.getBusinessId()); + // 调用远程工作流服务发起流程 boolean started = remoteWorkflowService.startCompleteTask(startProcess); if (!started) { throw new ServiceException("服务器部署流程发起异常"); @@ -190,37 +282,68 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { return queryById(bo.getDeployId()); } + /** + * 根据BO直接更新部署信息(不走版本控制和工作流) + *

+ * 适用于管理后台直接修改场景,会校验服务器状态和字段合法性。 + * + * @param bo 部署信息BO + * @return 更新是否成功 + */ @Override @Transactional(rollbackFor = Exception.class) public Boolean updateByBo(ErpServerDeployBo bo) { if (bo.getDeployId() == null) { throw new ServiceException("部署ID不能为空"); } + // 校验关联服务器是否已审批通过 checkServerActive(bo.getServerCode()); + // BO转Entity ErpServerDeploy update = MapstructUtils.convert(bo, ErpServerDeploy.class); + // 保存前字段校验 validEntityBeforeSave(update); return baseMapper.updateById(update) > 0; } + /** + * 保存草稿或创建新版本的核心逻辑 + *

+ * 判断逻辑: + * - 如果记录不存在或已生效/已审批通过/当前版本,则创建新版本; + * - 否则直接更新现有记录。 + * 创建新版本时会自动生成变更日志(与旧当前版本对比)。 + * + * @param bo 部署信息BO + */ private void saveDraftOrVersion(ErpServerDeployBo bo) { + // 查询现有记录 ErpServerDeploy existing = bo.getDeployId() == null ? null : baseMapper.selectById(bo.getDeployId()); + // 判断是否需要创建新版本 boolean createNewVersion = existing == null || shouldCreateNewVersion(existing); ErpServerDeploy oldCurrent = null; + // 如果创建新版本且有部署编码,先获取旧当前版本(用于变更日志对比) if (createNewVersion && StringUtils.isNotBlank(bo.getDeployCode())) { oldCurrent = getCurrentVersion(bo.getDeployCode()); } if (createNewVersion) { + // 清空ID,让数据库生成新记录 bo.setDeployId(null); + // 计算下一个版本号 bo.setVersion(calcNextVersion(bo.getDeployCode())); + // BO转Entity ErpServerDeploy add = MapstructUtils.convert(bo, ErpServerDeploy.class); + // 保存前校验 validEntityBeforeSave(add); if (baseMapper.insert(add) <= 0) { throw new ServiceException("保存部署信息失败"); } + // 回写生成的ID到BO bo.setDeployId(add.getDeployId()); + // 如果有旧当前版本,生成变更日志 saveChangeLogIfNeeded(add, oldCurrent); return; } + // 不需要新版本:直接更新现有记录 if (bo.getVersion() == null) { bo.setVersion(existing.getVersion()); } @@ -229,20 +352,37 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { if (baseMapper.updateById(update) <= 0) { throw new ServiceException("更新部署信息失败"); } + // 替换变更日志(先删后建) replaceChangeLogIfNeeded(update, getCurrentVersion(update.getDeployCode())); } + /** + * 判断是否需要创建新版本 + *

+ * 已生效(COMPLETED)、流程已完成(FINISH)、当前版本(isCurrent=1)的记录不可直接修改,必须创建新版本。 + * + * @param existing 现有记录 + * @return true=需要创建新版本,false=可直接更新 + */ private boolean shouldCreateNewVersion(ErpServerDeploy existing) { return OAStatusEnum.COMPLETED.getStatus().equals(existing.getDeployStatus()) || BusinessStatusEnum.FINISH.getStatus().equals(existing.getFlowStatus()) || YES.equals(existing.getIsCurrent()); } + /** + * 保存前的数据准备:校验服务器状态、生成部署编号、计算版本号、设置流程编码 + * + * @param bo 部署信息BO + * @param submit 是否为提交审批(true=设置流程编码,false=仅草稿) + */ private void prepareForSave(ErpServerDeployBo bo, boolean submit) { if (bo == null) { throw new ServiceException("部署信息不能为空"); } + // 校验关联服务器是否存在且已审批通过 checkServerActive(bo.getServerCode()); + // 如果没有部署编码,调用远程编码规则服务自动生成 if (StringUtils.isBlank(bo.getDeployCode())) { String code = remoteCodeRuleService.selectCodeRuleCode(DEPLOY_CODE_RULE); if (StringUtils.isBlank(code)) { @@ -250,14 +390,24 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { } bo.setDeployCode(code); } + // 新记录且未指定版本号时,自动计算下一个版本号 if (bo.getVersion() == null && bo.getDeployId() == null) { bo.setVersion(calcNextVersion(bo.getDeployCode())); } + // 提交审批时设置流程编码 if (submit) { bo.setFlowCode(FLOW_CODE_DEPLOY); } } + /** + * 计算下一个版本号 + *

+ * 查询同一部署编码下所有未删除记录的最大版本号,+1 返回。 + * + * @param deployCode 部署业务编码 + * @return 下一个版本号(从1开始) + */ private Integer calcNextVersion(String deployCode) { if (StringUtils.isBlank(deployCode)) { return 1; @@ -265,6 +415,7 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { List list = baseMapper.selectList(new LambdaQueryWrapper() .eq(ErpServerDeploy::getDeployCode, deployCode) .eq(ErpServerDeploy::getDelFlag, NO)); + // 取最大版本号 +1,如果没有记录则返回1 return list.stream() .map(ErpServerDeploy::getVersion) .filter(Objects::nonNull) @@ -272,6 +423,13 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { .orElse(0) + 1; } + /** + * 行级锁锁定同一部署编码的所有版本记录 + *

+ * 使用 SELECT ... FOR UPDATE 防止并发场景下版本号重复或状态冲突。 + * + * @param deployCode 部署业务编码 + */ private void lockVersionRows(String deployCode) { if (StringUtils.isBlank(deployCode)) { return; @@ -283,6 +441,14 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { .last("for update")); } + /** + * 删除被退回或撤销的旧流程实例 + *

+ * 如果该部署记录的流程状态为已退回(BACK)或已撤销(CANCEL), + * 调用远程工作流服务删除残留的流程实例,避免重新提交时流程引擎报错。 + * + * @param deployId 部署主键ID + */ private void deleteReturnedOrCanceledInstance(Long deployId) { if (deployId == null) { return; @@ -291,6 +457,7 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { if (existing == null) { return; } + // 仅在退回或撤销状态下才需要清理 if (BusinessStatusEnum.BACK.getStatus().equals(existing.getFlowStatus()) || BusinessStatusEnum.CANCEL.getStatus().equals(existing.getFlowStatus())) { boolean deleted = remoteWorkflowService.deleteInstance(Collections.singletonList(deployId)); @@ -300,6 +467,15 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { } } + /** + * 校验同一部署编码下没有其他审批中的版本 + *

+ * 防止同一部署项存在多个审批中的版本,避免审批冲突。 + * + * @param deployCode 部署业务编码 + * @param currentDeployId 当前部署ID(排除自身) + * @throws ServiceException 已有审批中版本时抛出异常 + */ private void checkNoApprovingVersion(String deployCode, Long currentDeployId) { Long count = baseMapper.selectCount(new LambdaQueryWrapper() .eq(ErpServerDeploy::getDeployCode, deployCode) @@ -311,10 +487,19 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { } } + /** + * 校验关联服务器是否有效且已审批通过 + *

+ * 部署操作要求关联的服务器必须存在、为当前生效版本且业务状态为已生效(COMPLETED)。 + * + * @param serverCode 服务器业务编码 + * @throws ServiceException 服务器不存在/未生效/未审批通过时抛出异常 + */ private void checkServerActive(String serverCode) { if (StringUtils.isBlank(serverCode)) { throw new ServiceException("请选择关联服务器"); } + // 查询当前生效版本的服务器 ErpServerInfo server = serverInfoMapper.selectOne(new LambdaQueryWrapper() .eq(ErpServerInfo::getServerCode, serverCode) .eq(ErpServerInfo::getIsCurrent, YES) @@ -323,11 +508,20 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { if (server == null) { throw new ServiceException("关联服务器不存在或尚未生效"); } + // 服务器必须已审批通过才能发起部署 if (!OAStatusEnum.COMPLETED.getStatus().equals(server.getServerStatus())) { throw new ServiceException("关联服务器尚未审批通过,无法发起部署操作"); } } + /** + * 保存前的实体字段校验 + *

+ * 校验必填字段(服务器编码、部署位置、端口、用途)和格式合法性(路径防注入、端口格式)。 + * + * @param entity 部署信息实体 + * @throws ServiceException 任一校验不通过时抛出 + */ private void validEntityBeforeSave(ErpServerDeploy entity) { if (entity == null) { throw new ServiceException("部署信息不能为空"); @@ -344,24 +538,44 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { if (StringUtils.isBlank(entity.getDeployPurpose())) { throw new ServiceException("部署用途不能为空"); } + // 部署路径安全校验(防命令注入) validDeployPath(entity.getDeployPath()); + // 部署端口格式校验 validDeployPort(entity.getDeployPort()); } + /** + * 校验部署路径安全性 + *

+ * 限制长度不超过500字符,禁止包含命令连接符、管道符、反引号等危险字符(防止命令注入攻击)。 + * + * @param deployPath 部署路径 + */ private void validDeployPath(String deployPath) { if (deployPath.length() > 500) { throw new ServiceException("部署位置长度不能超过500个字符"); } + // 检测危险字符,防止命令注入 if (DANGEROUS_PATH_PATTERN.matcher(deployPath).find()) { throw new ServiceException("部署位置不能包含命令连接符或换行等危险字符"); } } + /** + * 校验部署端口格式 + *

+ * 支持格式:单端口(8080)、逗号分隔(8080,8081)、端口范围(8080-8090)。 + * 端口值必须在1-65535之间,范围起始值不能大于结束值。 + * + * @param deployPort 部署端口字符串 + */ private void validDeployPort(String deployPort) { + // 去除空格后进行格式匹配 String normalized = deployPort.replace(" ", ""); if (!PORT_PATTERN.matcher(normalized).matches()) { throw new ServiceException("部署端口格式不正确,支持单端口、逗号分隔端口或端口范围"); } + // 逐个校验端口范围的有效性 for (String item : normalized.split(",")) { String[] range = item.split("-"); int start = parsePort(range[0]); @@ -372,6 +586,13 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { } } + /** + * 解析单个端口值并校验范围 + * + * @param port 端口字符串 + * @return 端口整数值 + * @throws ServiceException 非数字或超出1-65535范围时抛出 + */ private int parsePort(String port) { int value; try { @@ -385,6 +606,12 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { return value; } + /** + * 获取指定部署编码的当前生效版本 + * + * @param deployCode 部署业务编码 + * @return 当前生效版本实体,不存在则返回null + */ private ErpServerDeploy getCurrentVersion(String deployCode) { return baseMapper.selectOne(new LambdaQueryWrapper() .eq(ErpServerDeploy::getDeployCode, deployCode) @@ -393,14 +620,25 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { .last("limit 1")); } + /** + * 如果需要,保存变更日志 + *

+ * 对比新旧版本字段差异,有差异时生成变更日志记录。 + * 跳过条件:没有旧版本、版本号≤1(首次创建无需对比)。 + * + * @param newVersion 新版本实体 + * @param oldVersion 旧当前版本实体(可能为null) + */ private void saveChangeLogIfNeeded(ErpServerDeploy newVersion, ErpServerDeploy oldVersion) { if (oldVersion == null || newVersion.getVersion() == null || newVersion.getVersion() <= 1) { return; } + // 对比字段差异 List> changes = buildChanges(oldVersion, newVersion); if (changes.isEmpty()) { return; } + // 构建变更日志并保存 ErpServerDeployLog log = new ErpServerDeployLog(); log.setDeployId(newVersion.getDeployId()); log.setChangeContent(JSONUtil.toJsonStr(changes)); @@ -408,12 +646,31 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { deployLogMapper.insert(log); } + /** + * 替换变更日志(先删除旧日志再重新生成) + *

+ * 适用于直接更新现有记录的场景,确保日志与最新数据一致。 + * + * @param newVersion 新版本实体 + * @param oldVersion 旧当前版本实体 + */ private void replaceChangeLogIfNeeded(ErpServerDeploy newVersion, ErpServerDeploy oldVersion) { + // 先删除该部署的所有旧变更日志 deployLogMapper.delete(new LambdaQueryWrapper() .eq(ErpServerDeployLog::getDeployId, newVersion.getDeployId())); + // 重新生成变更日志 saveChangeLogIfNeeded(newVersion, oldVersion); } + /** + * 对比新旧版本字段差异,构建变更记录列表 + *

+ * 对比字段包括:关联服务器、部署位置、端口、端口暴露、用途、负责人、附件。 + * + * @param oldVersion 旧版本实体 + * @param newVersion 新版本实体 + * @return 变更记录列表,每个元素包含 field/label/oldValue/newValue + */ private List> buildChanges(ErpServerDeploy oldVersion, ErpServerDeploy newVersion) { List> changes = new ArrayList<>(); addChange(changes, "server_code", "关联服务器", oldVersion.getServerCode(), newVersion.getServerCode()); @@ -426,6 +683,17 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { return changes; } + /** + * 添加单条变更记录到变更列表 + *

+ * 仅当新旧值不相同时才添加。 + * + * @param changes 变更记录列表 + * @param field 字段名(英文) + * @param label 字段标签(中文,用于前端展示) + * @param oldValue 旧值 + * @param newValue 新值 + */ private void addChange(List> changes, String field, String label, Object oldValue, Object newValue) { if (Objects.equals(oldValue, newValue)) { return; @@ -438,8 +706,16 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { changes.add(change); } + /** + * 填充工作流参数和业务扩展信息 + *

+ * 设置流程变量(部署编码、服务器编码、负责人ID等)和业务扩展信息(标题、编码)。 + * + * @param bo 部署信息BO + */ private void fillWorkflowParams(ErpServerDeployBo bo) { bo.setFlowCode(FLOW_CODE_DEPLOY); + // 设置流程变量(putIfAbsent 避免覆盖前端传入的自定义变量) Map vars = bo.getVariables(); vars.putIfAbsent("deployCode", bo.getDeployCode()); vars.putIfAbsent("serverCode", bo.getServerCode()); @@ -447,6 +723,7 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { vars.put("ignore", true); bo.setVariables(vars); + // 设置业务扩展信息(用于工作流待办列表展示) RemoteFlowInstanceBizExt bizExt = bo.getBizExt(); if (StringUtils.isBlank(bizExt.getBusinessTitle())) { bizExt.setBusinessTitle(bo.getDeployCode() + "服务器部署审批"); @@ -457,10 +734,21 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { bo.setBizExt(bizExt); } + /** + * 批量删除服务器部署信息(带校验) + *

+ * 仅允许删除草稿状态(业务状态和流程状态均为DRAFT)的记录。 + * 删除部署记录时同步删除关联的变更日志。 + * + * @param ids 部署ID集合 + * @param isValid 是否执行状态校验 + * @return 删除是否成功 + */ @Override @Transactional(rollbackFor = Exception.class) public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { if (isValid) { + // 逐条校验:仅草稿状态可删除 for (Long id : ids) { ErpServerDeploy deploy = baseMapper.selectById(id); if (deploy == null) { @@ -472,46 +760,84 @@ public class ErpServerDeployServiceImpl implements IErpServerDeployService { } } } + // 先删除关联的变更日志 deployLogMapper.delete(new LambdaQueryWrapper().in(ErpServerDeployLog::getDeployId, ids)); + // 再删除部署记录(逻辑删除) return baseMapper.deleteByIds(ids) > 0; } + /** + * 工作流事件监听器(流程编码 OASD) + *

+ * 监听 Warm-Flow 工作流引擎发布的 ProcessEvent 事件, + * 在正确的租户上下文中处理流程状态变更。 + * + * @param processEvent 工作流事件 + */ @EventListener(condition = "#processEvent.flowCode == 'OASD'") @Transactional(rollbackFor = Exception.class) public void processHandler(ProcessEvent processEvent) { + // 在事件对应的租户上下文中执行,确保多租户数据隔离 TenantHelper.dynamic(processEvent.getTenantId(), () -> handleProcessEvent(processEvent)); } + /** + * 处理工作流事件的核心逻辑 + *

+ * 根据流程状态更新部署记录的业务状态和当前版本标识: + * - WAITING(待审批)→ APPROVING(审批中) + * - FINISH(已完成)→ COMPLETED(已生效),同时激活当前版本 + * - INVALID/TERMINATION(失效/终止)→ INVALID(已作废),取消当前版本 + * - BACK/CANCEL(退回/撤销)→ DRAFT(草稿),取消当前版本 + * + * @param processEvent 工作流事件 + */ public void handleProcessEvent(ProcessEvent processEvent) { log.info("服务器部署流程事件: {}", processEvent); ErpServerDeploy deploy = baseMapper.selectById(Long.valueOf(processEvent.getBusinessId())); if (deploy == null) { return; } + // 更新流程状态 deploy.setFlowStatus(processEvent.getStatus()); + // 根据流程状态联动更新业务状态 if (Objects.equals(processEvent.getStatus(), BusinessStatusEnum.WAITING.getStatus())) { + // 审批中 deploy.setDeployStatus(OAStatusEnum.APPROVING.getStatus()); } else if (Objects.equals(processEvent.getStatus(), BusinessStatusEnum.FINISH.getStatus())) { + // 审批通过:设为已生效并激活当前版本 deploy.setDeployStatus(OAStatusEnum.COMPLETED.getStatus()); activateCurrentVersion(deploy); } else if (Objects.equals(processEvent.getStatus(), BusinessStatusEnum.INVALID.getStatus()) || Objects.equals(processEvent.getStatus(), BusinessStatusEnum.TERMINATION.getStatus())) { + // 失效或终止:设为已作废 deploy.setDeployStatus(OAStatusEnum.INVALID.getStatus()); deploy.setIsCurrent(NO); } else if (Objects.equals(processEvent.getStatus(), BusinessStatusEnum.BACK.getStatus()) || Objects.equals(processEvent.getStatus(), BusinessStatusEnum.CANCEL.getStatus())) { + // 退回或撤销:回退为草稿状态 deploy.setDeployStatus(OAStatusEnum.DRAFT.getStatus()); deploy.setIsCurrent(NO); } baseMapper.updateById(deploy); } + /** + * 激活当前版本 + *

+ * 将同一部署编码下其他版本的 isCurrent 置为0,将当前版本置为1。 + * 确保同一部署项只有一个生效版本。 + * + * @param deploy 审批通过的部署记录 + */ private void activateCurrentVersion(ErpServerDeploy deploy) { + // 将同编码的其他版本全部置为非当前版本 baseMapper.update(null, new LambdaUpdateWrapper() .set(ErpServerDeploy::getIsCurrent, NO) .eq(ErpServerDeploy::getDeployCode, deploy.getDeployCode()) .eq(ErpServerDeploy::getDelFlag, NO) .ne(ErpServerDeploy::getDeployId, deploy.getDeployId())); + // 当前版本置为生效 deploy.setIsCurrent(YES); } } diff --git a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpServerInfoServiceImpl.java b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpServerInfoServiceImpl.java index 2bb90986..903d6d64 100644 --- a/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpServerInfoServiceImpl.java +++ b/ruoyi-modules/ruoyi-oa/src/main/java/org/dromara/oa/erp/service/impl/ErpServerInfoServiceImpl.java @@ -33,18 +33,18 @@ import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Set; +import java.util.*; import java.util.regex.Pattern; /** * 服务器基础信息Service业务层处理 + *

+ * 核心职责: + * 1. 管理服务器基础信息的增删改查,支持多版本快照管理; + * 2. 对接 Warm-Flow 工作流引擎,实现草稿→提交审批→审批通过/退回/作废的全生命周期; + * 3. 版本控制:审批通过的记录不可直接修改,变更时自动创建新版本; + * 4. 安全校验:IP地址格式校验(IPv4/IPv6)、金额非负校验、扩展属性敏感词检测和白名单校验; + * 5. 条件校验:本地服务器必填品牌/型号、租赁服务器必填租赁起止日期。 * * @author zch * @date 2026-06-03 @@ -54,7 +54,7 @@ import java.util.regex.Pattern; @Service public class ErpServerInfoServiceImpl implements IErpServerInfoService { - /** 服务器登记流程编码 */ + /** 服务器登记流程编码(对应 Warm-Flow 中的流程定义编码) */ public static final String FLOW_CODE_SERVER = "OASR"; /** @@ -62,43 +62,71 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { */ private static final String SERVER_CODE_RULE = "1035"; - private static final String YES = "1"; - private static final String NO = "0"; - private static final String LOCAL_SERVER = "local"; - private static final String LEASE = "lease"; + private static final String YES = "1"; // 是 + private static final String NO = "0"; // 否 + private static final String LOCAL_SERVER = "local"; // 本地服务器类型标识 + private static final String LEASE = "lease"; // 租赁获取方式标识 + /** IPv4地址正则 */ private static final Pattern IPV4_PATTERN = Pattern.compile( "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"); + /** IPv6候选正则(仅做初步筛选,后续再结合冒号和长度判断) */ private static final Pattern IPV6_CANDIDATE_PATTERN = Pattern.compile("^[0-9a-fA-F:]+$"); + /** 扩展属性允许的白名单字段集合 */ private static final Set EXT_ATTR_ALLOWED_KEYS = Set.of( "cpu", "memory", "disk", "bandwidth", "region", "zone", "instanceId", "vendor", "consoleUrl", "assetNo", "warrantyNo", "cabinetNo", "rackNo", "networkArea", "ownerDept", "backupPolicy", "monitorPolicy", "description"); - private final ErpServerInfoMapper baseMapper; + private final ErpServerInfoMapper baseMapper; // 服务器基础信息 Mapper + /** 远程工作流服务(Dubbo 调用,用于发起流程、删除流程实例) */ @DubboReference(timeout = 30000) private RemoteWorkflowService remoteWorkflowService; + /** 远程编码规则服务(Dubbo 调用,用于自动生成服务器编号) */ @DubboReference(timeout = 30000) private RemoteCodeRuleService remoteCodeRuleService; + /** + * 根据服务器ID查询详情 + *

+ * 返回数据包含:服务器基本信息、同编码的历史版本列表。 + * + * @param serverId 服务器主键ID + * @return 服务器详情VO(含版本列表),不存在则返回null + */ @Override public ErpServerInfoVo queryById(Long serverId) { ErpServerInfoVo vo = baseMapper.selectVoById(serverId); + // 补充同编码的历史版本列表 if (vo != null && StringUtils.isNotBlank(vo.getServerCode())) { vo.setVersionList(queryVersions(vo.getServerCode())); } return vo; } + /** + * 分页查询服务器基础信息列表 + * + * @param bo 查询条件 + * @param pageQuery 分页参数 + * @return 分页结果 + */ @Override public TableDataInfo queryPageList(ErpServerInfoBo bo, PageQuery pageQuery) { + // 构建查询条件并按创建时间倒序 MPJLambdaWrapper lqw = buildQueryWrapper(bo); lqw.orderByDesc(ErpServerInfo::getCreateTime); Page result = baseMapper.selectCustomErpServerInfoVoList(pageQuery.build(), lqw); return TableDataInfo.build(result); } + /** + * 不分页查询服务器基础信息列表 + * + * @param bo 查询条件 + * @return 服务器信息列表 + */ @Override public List queryList(ErpServerInfoBo bo) { MPJLambdaWrapper lqw = buildQueryWrapper(bo); @@ -106,6 +134,14 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { return baseMapper.selectCustomErpServerInfoVoList(lqw); } + /** + * 查询同一服务器编码的所有历史版本 + *

+ * 按版本号倒序排列,用于前端版本切换下拉。 + * + * @param serverCode 服务器业务编码 + * @return 版本列表,编码为空时返回空列表 + */ @Override public List queryVersions(String serverCode) { if (StringUtils.isBlank(serverCode)) { @@ -119,6 +155,14 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { return baseMapper.selectCustomErpServerInfoVoList(lqw); } + /** + * 构建查询条件包装器 + *

+ * 根据查询BO中的非空字段动态拼接 like/eq 条件,始终过滤已删除记录。 + * + * @param bo 查询条件BO + * @return MPJ 查询条件包装器 + */ private MPJLambdaWrapper buildQueryWrapper(ErpServerInfoBo bo) { return JoinWrappers.lambda(ErpServerInfo.class) .selectAll(ErpServerInfo.class) @@ -133,38 +177,71 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { .eq(StringUtils.isNotBlank(bo.getIsCurrent()), ErpServerInfo::getIsCurrent, bo.getIsCurrent()); } + /** + * 保存草稿 + *

+ * 不会发起工作流,仅保存数据。状态设为草稿,不设为当前版本。 + * 若已有审批通过的版本,会自动创建新版本;否则直接更新现有记录。 + * + * @param bo 服务器信息BO + * @return 保存后的服务器详情VO + */ @Override @Transactional(rollbackFor = Exception.class) public ErpServerInfoVo draft(ErpServerInfoBo bo) { + // 准备数据:生成编号、计算版本号 prepareForSave(bo, false); + // 行级锁锁定同编码的所有版本记录,防止并发版本号冲突 lockVersionRows(bo.getServerCode()); + // 设置草稿状态 bo.setServerStatus(OAStatusEnum.DRAFT.getStatus()); bo.setFlowStatus(BusinessStatusEnum.DRAFT.getStatus()); bo.setIsCurrent(NO); + // 保存(新建版本或更新现有记录) saveDraftOrVersion(bo); return queryById(bo.getServerId()); } + /** + * 提交并发起工作流 + *

+ * 保存数据后调用远程工作流服务发起审批流程。 + * 包含安全校验:旧流程实例清理、同编码审批中版本冲突检测。 + * 使用 @GlobalTransactional 保证跨服务(OA模块 + 工作流模块)的数据一致性。 + * + * @param bo 服务器信息BO + * @return 提交后的服务器详情VO + * @throws ServiceException 编号生成失败/流程发起失败等异常 + */ @Override @Transactional(rollbackFor = Exception.class) @GlobalTransactional(rollbackFor = Exception.class) public ErpServerInfoVo submitAndStartFlow(ErpServerInfoBo bo) { + // 准备数据并设置流程编码 prepareForSave(bo, true); + // 行级锁防止并发版本冲突 lockVersionRows(bo.getServerCode()); + // 清理被退回或撤销的旧流程实例 deleteReturnedOrCanceledInstance(bo.getServerId()); + // 校验同一服务器编码下没有其他审批中的版本 checkNoApprovingVersion(bo.getServerCode(), bo.getServerId()); + // 设置审批中状态 bo.setServerStatus(OAStatusEnum.APPROVING.getStatus()); bo.setFlowStatus(BusinessStatusEnum.WAITING.getStatus()); bo.setIsCurrent(NO); + // 保存数据(新建版本或更新现有记录) saveDraftOrVersion(bo); + // 填充工作流变量和业务扩展信息 fillWorkflowParams(bo); + // 构建远程流程启动请求 RemoteStartProcess startProcess = new RemoteStartProcess(); startProcess.setBusinessId(String.valueOf(bo.getServerId())); startProcess.setFlowCode(bo.getFlowCode()); startProcess.setVariables(bo.getVariables()); startProcess.setBizExt(bo.getBizExt()); bo.getBizExt().setBusinessId(startProcess.getBusinessId()); + // 调用远程工作流服务发起流程 boolean started = remoteWorkflowService.startCompleteTask(startProcess); if (!started) { throw new ServiceException("服务器登记流程发起异常"); @@ -172,31 +249,58 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { return queryById(bo.getServerId()); } + /** + * 根据BO直接更新服务器信息(不走版本控制和工作流) + *

+ * 适用于管理后台直接修改场景,会校验字段合法性。 + * + * @param bo 服务器信息BO + * @return 更新是否成功 + */ @Override @Transactional(rollbackFor = Exception.class) public Boolean updateByBo(ErpServerInfoBo bo) { if (bo.getServerId() == null) { throw new ServiceException("服务器ID不能为空"); } + // BO转Entity ErpServerInfo entity = MapstructUtils.convert(bo, ErpServerInfo.class); + // 保存前字段校验 validEntityBeforeSave(entity); return baseMapper.updateById(entity) > 0; } + /** + * 保存草稿或创建新版本的核心逻辑 + *

+ * 判断逻辑: + * - 如果记录不存在或已生效/已审批通过/当前版本,则创建新版本; + * - 否则直接更新现有记录。 + * + * @param bo 服务器信息BO + */ private void saveDraftOrVersion(ErpServerInfoBo bo) { + // 查询现有记录 ErpServerInfo existing = bo.getServerId() == null ? null : baseMapper.selectById(bo.getServerId()); + // 判断是否需要创建新版本 boolean createNewVersion = existing == null || shouldCreateNewVersion(existing); if (createNewVersion) { + // 清空ID,让数据库生成新记录 bo.setServerId(null); + // 计算下一个版本号 bo.setVersion(calcNextVersion(bo.getServerCode())); + // BO转Entity ErpServerInfo add = MapstructUtils.convert(bo, ErpServerInfo.class); + // 保存前校验 validEntityBeforeSave(add); if (baseMapper.insert(add) <= 0) { throw new ServiceException("保存服务器信息失败"); } + // 回写生成的ID到BO bo.setServerId(add.getServerId()); return; } + // 不需要新版本:直接更新现有记录 if (bo.getVersion() == null) { bo.setVersion(existing.getVersion()); } @@ -207,6 +311,14 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { } } + /** + * 判断是否需要创建新版本 + *

+ * 已生效(COMPLETED)、流程已完成(FINISH)、当前版本(isCurrent=1)的记录不可直接修改,必须创建新版本。 + * + * @param existing 现有记录 + * @return true=需要创建新版本,false=可直接更新 + */ private boolean shouldCreateNewVersion(ErpServerInfo existing) { // 已生效版本是线上可用快照,变更必须新增版本审批,不能直接覆盖原记录。 return OAStatusEnum.COMPLETED.getStatus().equals(existing.getServerStatus()) @@ -214,10 +326,17 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { || YES.equals(existing.getIsCurrent()); } + /** + * 保存前的数据准备:生成服务器编号、计算版本号、设置流程编码 + * + * @param bo 服务器信息BO + * @param submit 是否为提交审批(true=设置流程编码,false=仅草稿) + */ private void prepareForSave(ErpServerInfoBo bo, boolean submit) { if (bo == null) { throw new ServiceException("服务器信息不能为空"); } + // 如果没有服务器编码,调用远程编码规则服务自动生成 if (StringUtils.isBlank(bo.getServerCode())) { String code = remoteCodeRuleService.selectCodeRuleCode(SERVER_CODE_RULE); if (StringUtils.isBlank(code)) { @@ -225,14 +344,24 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { } bo.setServerCode(code); } + // 新记录且未指定版本号时,自动计算下一个版本号 if (bo.getVersion() == null && bo.getServerId() == null) { bo.setVersion(calcNextVersion(bo.getServerCode())); } + // 提交审批时设置流程编码 if (submit) { bo.setFlowCode(FLOW_CODE_SERVER); } } + /** + * 计算下一个版本号 + *

+ * 查询同一服务器编码下所有未删除记录的最大版本号,+1 返回。 + * + * @param serverCode 服务器业务编码 + * @return 下一个版本号(从1开始) + */ private Integer calcNextVersion(String serverCode) { if (StringUtils.isBlank(serverCode)) { return 1; @@ -240,6 +369,7 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { List list = baseMapper.selectList(new LambdaQueryWrapper() .eq(ErpServerInfo::getServerCode, serverCode) .eq(ErpServerInfo::getDelFlag, NO)); + // 取最大版本号 +1,如果没有记录则返回1 return list.stream() .map(ErpServerInfo::getVersion) .filter(Objects::nonNull) @@ -247,6 +377,13 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { .orElse(0) + 1; } + /** + * 行级锁锁定同一服务器编码的所有版本记录 + *

+ * 使用 SELECT ... FOR UPDATE 防止并发场景下版本号重复或状态冲突。 + * + * @param serverCode 服务器业务编码 + */ private void lockVersionRows(String serverCode) { if (StringUtils.isBlank(serverCode)) { return; @@ -258,6 +395,14 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { .last("for update")); } + /** + * 删除被退回或撤销的旧流程实例 + *

+ * 如果该服务器记录的流程状态为已退回(BACK)或已撤销(CANCEL), + * 调用远程工作流服务删除残留的流程实例,避免重新提交时流程引擎报错。 + * + * @param serverId 服务器主键ID + */ private void deleteReturnedOrCanceledInstance(Long serverId) { if (serverId == null) { return; @@ -266,6 +411,7 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { if (existing == null) { return; } + // 仅在退回或撤销状态下才需要清理 if (BusinessStatusEnum.BACK.getStatus().equals(existing.getFlowStatus()) || BusinessStatusEnum.CANCEL.getStatus().equals(existing.getFlowStatus())) { boolean deleted = remoteWorkflowService.deleteInstance(Collections.singletonList(serverId)); @@ -275,6 +421,15 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { } } + /** + * 校验同一服务器编码下没有其他审批中的版本 + *

+ * 防止同一服务器存在多个审批中的版本,避免审批冲突。 + * + * @param serverCode 服务器业务编码 + * @param currentServerId 当前服务器ID(排除自身) + * @throws ServiceException 已有审批中版本时抛出异常 + */ private void checkNoApprovingVersion(String serverCode, Long currentServerId) { Long count = baseMapper.selectCount(new LambdaQueryWrapper() .eq(ErpServerInfo::getServerCode, serverCode) @@ -286,6 +441,17 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { } } + /** + * 保存前的实体字段校验 + *

+ * 校验必填字段(名称、类型、获取方式)和条件必填字段: + * - 本地服务器必填品牌和型号; + * - 租赁服务器必填租赁起止日期且结束日期不能早于起始日期。 + * 同时校验IP地址格式、金额非负、扩展属性安全。 + * + * @param entity 服务器信息实体 + * @throws ServiceException 任一校验不通过时抛出 + */ private void validEntityBeforeSave(ErpServerInfo entity) { if (entity == null) { throw new ServiceException("服务器信息不能为空"); @@ -299,6 +465,7 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { if (StringUtils.isBlank(entity.getAcquisitionType())) { throw new ServiceException("请选择获取方式"); } + // 本地服务器必填品牌和型号 if (LOCAL_SERVER.equals(entity.getServerType())) { if (StringUtils.isBlank(entity.getBrand())) { throw new ServiceException("本地服务器必须填写品牌"); @@ -307,6 +474,7 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { throw new ServiceException("本地服务器必须填写型号"); } } + // 租赁服务器必填租赁起止日期 if (LEASE.equals(entity.getAcquisitionType())) { if (entity.getLeaseStartDate() == null || entity.getLeaseEndDate() == null) { throw new ServiceException("租赁服务器必须填写租赁起止日期"); @@ -315,21 +483,34 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { throw new ServiceException("租赁结束日期不能早于起始日期"); } } + // IP地址格式校验 validIpAddress(entity.getIpAddress()); + // 金额非负校验 validNonNegativeAmount(entity.getPurchaseAmount(), "购买金额不能小于0"); validNonNegativeAmount(entity.getLeaseAmount(), "租赁金额不能小于0"); + // 扩展属性安全校验(敏感词检测 + 白名单校验) validExtAttr(entity.getExtAttr()); } + /** + * 校验IP地址格式 + *

+ * 支持多个IP地址(逗号、分号、空格分隔),每个IP可以是IPv4或IPv6格式。 + * IPv4使用标准四段点分十进制正则校验;IPv6通过冒号存在性、长度限制和字符集初步校验。 + * + * @param ipAddress IP地址字符串 + */ private void validIpAddress(String ipAddress) { if (StringUtils.isBlank(ipAddress)) { return; } + // 按逗号、分号、空格分隔多个IP String[] items = ipAddress.split("[,;\\s]+"); for (String item : items) { if (StringUtils.isBlank(item)) { continue; } + // 先尝试IPv4匹配,不匹配再尝试IPv6候选校验 if (!IPV4_PATTERN.matcher(item).matches() && !(item.contains(":") && item.length() <= 45 && IPV6_CANDIDATE_PATTERN.matcher(item).matches())) { throw new ServiceException("IP地址格式不正确"); @@ -337,16 +518,33 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { } } + /** + * 校验金额不能为负数 + * + * @param amount 金额值 + * @param message 校验失败时的错误提示 + */ private void validNonNegativeAmount(java.math.BigDecimal amount, String message) { if (amount != null && amount.signum() < 0) { throw new ServiceException(message); } } + /** + * 校验扩展属性安全性和格式 + *

+ * 安全策略: + * 1. 敏感词检测:禁止保存 password/secret/token/密钥/密码 等敏感凭据; + * 2. JSON格式校验:必须是合法的JSON对象; + * 3. 白名单校验:仅允许保存预定义的运维描述类字段。 + * + * @param extAttr 扩展属性JSON字符串 + */ private void validExtAttr(String extAttr) { if (StringUtils.isBlank(extAttr)) { return; } + // 敏感词检测:将key转为小写并去除分隔符后匹配 String lower = extAttr.toLowerCase(Locale.ROOT); String normalized = lower.replace("_", "").replace("-", "").replace(" ", ""); String[] sensitiveKeys = {"password", "passwd", "pwd", "secret", "privatekey", "accesskey", "apikey", @@ -356,9 +554,11 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { throw new ServiceException("扩展属性禁止保存密码、密钥、Token 等敏感凭据"); } } + // JSON格式校验 if (!JSONUtil.isTypeJSONObject(extAttr)) { throw new ServiceException("扩展属性必须是JSON对象"); } + // 白名单字段校验 JSONObject jsonObject = JSONUtil.parseObj(extAttr); for (String key : jsonObject.keySet()) { if (!EXT_ATTR_ALLOWED_KEYS.contains(key)) { @@ -367,8 +567,16 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { } } + /** + * 填充工作流参数和业务扩展信息 + *

+ * 设置流程变量(服务器编码、名称、类型、获取方式等)和业务扩展信息(标题、编码)。 + * + * @param bo 服务器信息BO + */ private void fillWorkflowParams(ErpServerInfoBo bo) { bo.setFlowCode(FLOW_CODE_SERVER); + // 设置流程变量(putIfAbsent 避免覆盖前端传入的自定义变量) Map vars = bo.getVariables(); vars.putIfAbsent("serverCode", bo.getServerCode()); vars.putIfAbsent("serverName", bo.getServerName()); @@ -377,6 +585,7 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { vars.put("ignore", true); bo.setVariables(vars); + // 设置业务扩展信息(用于工作流待办列表展示) RemoteFlowInstanceBizExt bizExt = bo.getBizExt(); if (StringUtils.isBlank(bizExt.getBusinessTitle())) { bizExt.setBusinessTitle(bo.getServerName() + "服务器登记审批"); @@ -387,9 +596,19 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { bo.setBizExt(bizExt); } + /** + * 批量删除服务器基础信息(带校验) + *

+ * 仅允许删除草稿状态(业务状态和流程状态均为DRAFT)的记录。 + * + * @param ids 服务器ID集合 + * @param isValid 是否执行状态校验 + * @return 删除是否成功 + */ @Override public Boolean deleteWithValidByIds(Collection ids, Boolean isValid) { if (isValid) { + // 逐条校验:仅草稿状态可删除 for (Long id : ids) { ErpServerInfo info = baseMapper.selectById(id); if (info == null) { @@ -404,42 +623,78 @@ public class ErpServerInfoServiceImpl implements IErpServerInfoService { return baseMapper.deleteByIds(ids) > 0; } + /** + * 工作流事件监听器(流程编码 OASR) + *

+ * 监听 Warm-Flow 工作流引擎发布的 ProcessEvent 事件, + * 在正确的租户上下文中处理流程状态变更。 + * + * @param processEvent 工作流事件 + */ @EventListener(condition = "#processEvent.flowCode == 'OASR'") @Transactional(rollbackFor = Exception.class) public void processHandler(ProcessEvent processEvent) { + // 在事件对应的租户上下文中执行,确保多租户数据隔离 TenantHelper.dynamic(processEvent.getTenantId(), () -> handleProcessEvent(processEvent)); } + /** + * 处理工作流事件的核心逻辑 + *

+ * 根据流程状态更新服务器记录的业务状态和当前版本标识: + * - WAITING(待审批)→ APPROVING(审批中) + * - FINISH(已完成)→ COMPLETED(已生效),同时激活当前版本 + * - INVALID/TERMINATION(失效/终止)→ INVALID(已作废),取消当前版本 + * - BACK/CANCEL(退回/撤销)→ DRAFT(草稿),取消当前版本 + * + * @param processEvent 工作流事件 + */ public void handleProcessEvent(ProcessEvent processEvent) { log.info("服务器登记流程事件: {}", processEvent); ErpServerInfo server = baseMapper.selectById(Long.valueOf(processEvent.getBusinessId())); if (server == null) { return; } + // 更新流程状态 server.setFlowStatus(processEvent.getStatus()); + // 根据流程状态联动更新业务状态 if (Objects.equals(processEvent.getStatus(), BusinessStatusEnum.WAITING.getStatus())) { + // 审批中 server.setServerStatus(OAStatusEnum.APPROVING.getStatus()); } else if (Objects.equals(processEvent.getStatus(), BusinessStatusEnum.FINISH.getStatus())) { + // 审批通过:设为已生效并激活当前版本 server.setServerStatus(OAStatusEnum.COMPLETED.getStatus()); activateCurrentVersion(server); } else if (Objects.equals(processEvent.getStatus(), BusinessStatusEnum.INVALID.getStatus()) || Objects.equals(processEvent.getStatus(), BusinessStatusEnum.TERMINATION.getStatus())) { + // 失效或终止:设为已作废 server.setServerStatus(OAStatusEnum.INVALID.getStatus()); server.setIsCurrent(NO); } else if (Objects.equals(processEvent.getStatus(), BusinessStatusEnum.BACK.getStatus()) || Objects.equals(processEvent.getStatus(), BusinessStatusEnum.CANCEL.getStatus())) { + // 退回或撤销:回退为草稿状态 server.setServerStatus(OAStatusEnum.DRAFT.getStatus()); server.setIsCurrent(NO); } baseMapper.updateById(server); } + /** + * 激活当前版本 + *

+ * 将同一服务器编码下其他版本的 isCurrent 置为0,将当前版本置为1。 + * 确保同一服务器只有一个生效版本。 + * + * @param server 审批通过的服务器记录 + */ private void activateCurrentVersion(ErpServerInfo server) { + // 将同编码的其他版本全部置为非当前版本 baseMapper.update(null, new LambdaUpdateWrapper() .set(ErpServerInfo::getIsCurrent, NO) .eq(ErpServerInfo::getServerCode, server.getServerCode()) .eq(ErpServerInfo::getDelFlag, NO) .ne(ErpServerInfo::getServerId, server.getServerId())); + // 当前版本置为生效 server.setIsCurrent(YES); } }