feat(base): C1 U21 能力包版本化(变更审批流)
OQ13 决策:自建 DRAFT/PENDING/APPROVED/REJECTED 状态机,不复用 ApprovalFlow。
经研究 ApprovalFlow 强耦合 Lifecycle 领域(硬编码 LifecycleTransitionEvent、
stageTransition 语义),复用需先去耦合重构,工作量比自建更大。
U21 是单审批人 3 态状态机,不需要会签/多节点/退回,自建更符合 ponytail。
V23 迁移:
- t_ability_package / t_ability_package_item 加 version + approval_status
- t_project_ability 加 version_id(关联到具体已审批版本)
- 新增 t_ability_package_version 快照表(含 @Version lock_version 乐观锁)
AbilityPackageVersionService 状态机:
- createDraft:复制当前 package+items 为 DRAFT 快照,version+1
- submitForApproval:DRAFT->PENDING,身份从 UserContext 获取
- approve:PENDING->APPROVED,职责分离校验 submitter!=approver,
同步主表 version + approval_status,从快照重建 items
- reject:PENDING->REJECTED
- delete:仅 DRAFT/REJECTED 可删;APPROVED 被项目引用时拒绝
Controller 5 端点全部 @AuditLog + @PreAuthorize + RoleGuard.assertA4()。
approve/reject recordParams=false(snapshot 脱敏)。
assignPackages 改为关联到最新 APPROVED 版本的 version_id。
Residual risks(Task 3 Report-only)已处置:
- delete 语义:DRAFT/REJECTED 可删,APPROVED 需引用检查
- createDraft createdBy:记录,从 UserContext 获取
- @AuditLog snapshot 脱敏:recordParams=false
9 个测试场景全部通过 + AuditLogCoverageTest 守卫通过。
Refs: docs/plans/2026-07-01-002-feat-multi-property-config-phase-3-plan.md (U21)
docs/plans/2026-07-03-003-deferred-items-roadmap-plan.md (C1)
This commit is contained in:
parent
641acefe7c
commit
efb67c6286
|
|
@ -0,0 +1,91 @@
|
|||
package com.pms.base.controller;
|
||||
|
||||
import com.pms.base.entity.AbilityPackageVersion;
|
||||
import com.pms.base.service.AbilityPackageVersionService;
|
||||
import com.pms.base.util.RoleGuard;
|
||||
import com.pms.common.annotation.AuditLog;
|
||||
import com.pms.common.response.Result;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 能力包版本控制器(U21 变更审批流)
|
||||
* 写操作需系统管理员(A4)权限
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/base")
|
||||
@RequiredArgsConstructor
|
||||
public class AbilityPackageController {
|
||||
|
||||
private final AbilityPackageVersionService abilityPackageVersionService;
|
||||
|
||||
/**
|
||||
* 创建版本草稿
|
||||
*/
|
||||
@PostMapping("/ability-packages/{packageId}/versions")
|
||||
@PreAuthorize("hasAuthority('base:project:manage')")
|
||||
@AuditLog(module = "ABILITY_PACKAGE", type = "CREATE", description = "创建能力包版本草稿")
|
||||
public Result<Map<String, Long>> createDraft(@PathVariable Long packageId) {
|
||||
RoleGuard.assertA4();
|
||||
AbilityPackageVersion version = abilityPackageVersionService.createDraft(packageId);
|
||||
Map<String, Long> data = new HashMap<>();
|
||||
data.put("id", version.getId());
|
||||
return Result.success(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交审批
|
||||
*/
|
||||
@PostMapping("/ability-package-versions/{versionId}/submit")
|
||||
@PreAuthorize("hasAuthority('base:project:manage')")
|
||||
@AuditLog(module = "ABILITY_PACKAGE", type = "UPDATE", description = "提交能力包版本审批")
|
||||
public Result<Void> submitForApproval(@PathVariable Long versionId) {
|
||||
RoleGuard.assertA4();
|
||||
abilityPackageVersionService.submitForApproval(versionId);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批通过(snapshot 含配置明细,recordParams=false 脱敏)
|
||||
*/
|
||||
@PostMapping("/ability-package-versions/{versionId}/approve")
|
||||
@PreAuthorize("hasAuthority('base:project:manage')")
|
||||
@AuditLog(module = "ABILITY_PACKAGE", type = "UPDATE", description = "审批通过能力包版本", recordParams = false)
|
||||
public Result<Void> approve(@PathVariable Long versionId,
|
||||
@RequestParam(required = false) String comment) {
|
||||
RoleGuard.assertA4();
|
||||
abilityPackageVersionService.approve(versionId, comment);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 驳回(recordParams=false 避免驳回原因等明细入审计)
|
||||
*/
|
||||
@PostMapping("/ability-package-versions/{versionId}/reject")
|
||||
@PreAuthorize("hasAuthority('base:project:manage')")
|
||||
@AuditLog(module = "ABILITY_PACKAGE", type = "UPDATE", description = "驳回能力包版本", recordParams = false)
|
||||
public Result<Void> reject(@PathVariable Long versionId,
|
||||
@RequestParam String reason) {
|
||||
RoleGuard.assertA4();
|
||||
abilityPackageVersionService.reject(versionId, reason);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除版本(仅 DRAFT/REJECTED;APPROVED 被引用时拒绝)
|
||||
*/
|
||||
@DeleteMapping("/ability-package-versions/{versionId}")
|
||||
@PreAuthorize("hasAuthority('base:project:manage')")
|
||||
@AuditLog(module = "ABILITY_PACKAGE", type = "DELETE", description = "删除能力包版本")
|
||||
public Result<Void> delete(@PathVariable Long versionId) {
|
||||
RoleGuard.assertA4();
|
||||
abilityPackageVersionService.delete(versionId);
|
||||
return Result.success();
|
||||
}
|
||||
}
|
||||
|
|
@ -31,4 +31,10 @@ public class AbilityPackage extends BaseEntity {
|
|||
|
||||
/** 排序 */
|
||||
private Integer sort;
|
||||
|
||||
/** 当前已审批版本号(0 表示初始无版本) */
|
||||
private Integer version;
|
||||
|
||||
/** 当前审批状态:DRAFT/PENDING/APPROVED/REJECTED */
|
||||
private String approvalStatus;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,4 +28,10 @@ public class AbilityPackageItem extends BaseEntity {
|
|||
|
||||
/** 排序 */
|
||||
private Integer sort;
|
||||
|
||||
/** 当前已审批版本号 */
|
||||
private Integer version;
|
||||
|
||||
/** 当前审批状态:DRAFT/PENDING/APPROVED/REJECTED */
|
||||
private String approvalStatus;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
package com.pms.base.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.annotation.Version;
|
||||
import com.pms.common.entity.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 能力包版本实体(变更审批轨迹)
|
||||
* 状态机:DRAFT → PENDING → APPROVED / REJECTED
|
||||
* 单审批人,无会签/多节点/退回
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("t_ability_package_version")
|
||||
public class AbilityPackageVersion extends BaseEntity {
|
||||
|
||||
/** 所属能力包ID */
|
||||
private Long abilityPackageId;
|
||||
|
||||
/** 业务版本号(递增) */
|
||||
private Integer version;
|
||||
|
||||
/** 审批状态:DRAFT/PENDING/APPROVED/REJECTED */
|
||||
private String approvalStatus;
|
||||
|
||||
/** 能力包+其 items 的完整快照(JSON) */
|
||||
private String snapshot;
|
||||
|
||||
/** 提交人 userId */
|
||||
private Long submittedBy;
|
||||
|
||||
/** 提交时间(时间戳,毫秒) */
|
||||
private Long submittedAt;
|
||||
|
||||
/** 审批人 userId */
|
||||
private Long approvedBy;
|
||||
|
||||
/** 审批时间(时间戳,毫秒) */
|
||||
private Long approvedAt;
|
||||
|
||||
/** 驳回原因 */
|
||||
private String rejectReason;
|
||||
|
||||
/** 乐观锁版本号(与业务 version 字段区分) */
|
||||
@Version
|
||||
@TableField("lock_version")
|
||||
private Integer lockVersion;
|
||||
}
|
||||
|
|
@ -19,4 +19,7 @@ public class ProjectAbility extends BaseEntity {
|
|||
|
||||
/** 主业态标记:0否 1是(全局唯一,应用层保证) */
|
||||
private Integer isPrimary;
|
||||
|
||||
/** 关联的具体版本ID(NULL 表示用 package 当前版本) */
|
||||
private Long versionId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
package com.pms.base.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.pms.base.entity.AbilityPackageVersion;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 能力包版本 Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface AbilityPackageVersionMapper extends BaseMapper<AbilityPackageVersion> {
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.pms.base.service;
|
||||
|
||||
import com.pms.base.entity.AbilityPackageVersion;
|
||||
|
||||
/**
|
||||
* 能力包版本服务接口
|
||||
* 变更审批流:DRAFT → PENDING → APPROVED / REJECTED
|
||||
*/
|
||||
public interface AbilityPackageVersionService {
|
||||
|
||||
/**
|
||||
* 创建版本草稿:复制当前 package + items 为 DRAFT 快照,version = package.version + 1
|
||||
*/
|
||||
AbilityPackageVersion createDraft(Long packageId);
|
||||
|
||||
/**
|
||||
* 提交审批:DRAFT → PENDING,记录提交人
|
||||
*/
|
||||
void submitForApproval(Long versionId);
|
||||
|
||||
/**
|
||||
* 审批通过:PENDING → APPROVED,同步主表 version + approval_status,同步 items
|
||||
* 职责分离:提交人 != 审批人
|
||||
*/
|
||||
void approve(Long versionId, String comment);
|
||||
|
||||
/**
|
||||
* 驳回:PENDING → REJECTED,记录驳回原因
|
||||
*/
|
||||
void reject(Long versionId, String reason);
|
||||
|
||||
/**
|
||||
* 删除版本:仅允许删除 DRAFT / REJECTED;APPROVED 被项目引用时拒绝
|
||||
*/
|
||||
void delete(Long versionId);
|
||||
}
|
||||
|
|
@ -3,9 +3,11 @@ package com.pms.base.service.impl;
|
|||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.pms.base.entity.AbilityPackage;
|
||||
import com.pms.base.entity.AbilityPackageItem;
|
||||
import com.pms.base.entity.AbilityPackageVersion;
|
||||
import com.pms.base.entity.ProjectAbility;
|
||||
import com.pms.base.mapper.AbilityPackageItemMapper;
|
||||
import com.pms.base.mapper.AbilityPackageMapper;
|
||||
import com.pms.base.mapper.AbilityPackageVersionMapper;
|
||||
import com.pms.base.mapper.ProjectAbilityMapper;
|
||||
import com.pms.base.service.AbilityPackageService;
|
||||
import com.pms.common.constant.CommonConstants;
|
||||
|
|
@ -34,6 +36,7 @@ public class AbilityPackageServiceImpl implements AbilityPackageService {
|
|||
private final AbilityPackageMapper abilityPackageMapper;
|
||||
private final AbilityPackageItemMapper abilityPackageItemMapper;
|
||||
private final ProjectAbilityMapper projectAbilityMapper;
|
||||
private final AbilityPackageVersionMapper abilityPackageVersionMapper;
|
||||
|
||||
@Override
|
||||
public List<AbilityPackage> listAllPackages() {
|
||||
|
|
@ -124,9 +127,17 @@ public class AbilityPackageServiceImpl implements AbilityPackageService {
|
|||
if (existingPackageIds.contains(pkg.getId())) {
|
||||
continue;
|
||||
}
|
||||
// 查最新 APPROVED 版本,关联到具体 version_id;无则 NULL(用 package 当前版本)
|
||||
AbilityPackageVersion latestApproved = abilityPackageVersionMapper.selectOne(
|
||||
new LambdaQueryWrapper<AbilityPackageVersion>()
|
||||
.eq(AbilityPackageVersion::getAbilityPackageId, pkg.getId())
|
||||
.eq(AbilityPackageVersion::getApprovalStatus, "APPROVED")
|
||||
.orderByDesc(AbilityPackageVersion::getVersion)
|
||||
.last("LIMIT 1"));
|
||||
ProjectAbility pa = new ProjectAbility();
|
||||
pa.setProjectId(projectId);
|
||||
pa.setAbilityPackageId(pkg.getId());
|
||||
pa.setVersionId(latestApproved != null ? latestApproved.getId() : null);
|
||||
pa.setCreatedAt(now);
|
||||
pa.setUpdatedAt(now);
|
||||
pa.setCreatedBy(userId);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,225 @@
|
|||
package com.pms.base.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.pms.base.entity.AbilityPackage;
|
||||
import com.pms.base.entity.AbilityPackageItem;
|
||||
import com.pms.base.entity.AbilityPackageVersion;
|
||||
import com.pms.base.entity.ProjectAbility;
|
||||
import com.pms.base.mapper.AbilityPackageItemMapper;
|
||||
import com.pms.base.mapper.AbilityPackageMapper;
|
||||
import com.pms.base.mapper.AbilityPackageVersionMapper;
|
||||
import com.pms.base.mapper.ProjectAbilityMapper;
|
||||
import com.pms.base.service.AbilityPackageVersionService;
|
||||
import com.pms.common.exception.BusinessException;
|
||||
import com.pms.common.exception.ErrorCode;
|
||||
import com.pms.common.security.UserContext;
|
||||
import com.pms.common.util.JsonUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 能力包版本服务实现
|
||||
* 自建单审批人状态机:DRAFT → PENDING → APPROVED / REJECTED
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AbilityPackageVersionServiceImpl implements AbilityPackageVersionService {
|
||||
|
||||
private final AbilityPackageVersionMapper abilityPackageVersionMapper;
|
||||
private final AbilityPackageMapper abilityPackageMapper;
|
||||
private final AbilityPackageItemMapper abilityPackageItemMapper;
|
||||
private final ProjectAbilityMapper projectAbilityMapper;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AbilityPackageVersion createDraft(Long packageId) {
|
||||
AbilityPackage pkg = abilityPackageMapper.selectById(packageId);
|
||||
if (pkg == null) {
|
||||
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "能力包不存在");
|
||||
}
|
||||
|
||||
// 查当前 items
|
||||
LambdaQueryWrapper<AbilityPackageItem> itemWrapper = new LambdaQueryWrapper<>();
|
||||
itemWrapper.eq(AbilityPackageItem::getAbilityPackageId, packageId)
|
||||
.orderByAsc(AbilityPackageItem::getSort);
|
||||
List<AbilityPackageItem> items = abilityPackageItemMapper.selectList(itemWrapper);
|
||||
|
||||
// 序列化快照
|
||||
Map<String, Object> snapshotMap = new LinkedHashMap<>();
|
||||
snapshotMap.put("package", pkg);
|
||||
snapshotMap.put("items", items);
|
||||
String snapshotJson = JsonUtils.toJson(snapshotMap);
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
Long userId = UserContext.getUserId();
|
||||
|
||||
AbilityPackageVersion version = new AbilityPackageVersion();
|
||||
version.setAbilityPackageId(packageId);
|
||||
version.setVersion((pkg.getVersion() == null ? 0 : pkg.getVersion()) + 1);
|
||||
version.setApprovalStatus("DRAFT");
|
||||
version.setSnapshot(snapshotJson);
|
||||
version.setCreatedAt(now);
|
||||
version.setUpdatedAt(now);
|
||||
version.setCreatedBy(userId);
|
||||
version.setUpdatedBy(userId);
|
||||
abilityPackageVersionMapper.insert(version);
|
||||
|
||||
log.info("创建能力包版本草稿: packageId={}, versionId={}, version={}",
|
||||
packageId, version.getId(), version.getVersion());
|
||||
return version;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void submitForApproval(Long versionId) {
|
||||
AbilityPackageVersion version = loadVersion(versionId);
|
||||
if (!"DRAFT".equals(version.getApprovalStatus())) {
|
||||
throw new BusinessException(ErrorCode.OPERATION_FAILED, "仅 DRAFT 状态可提交审批,当前状态: " + version.getApprovalStatus());
|
||||
}
|
||||
|
||||
version.setApprovalStatus("PENDING");
|
||||
version.setSubmittedBy(UserContext.getUserId());
|
||||
version.setSubmittedAt(System.currentTimeMillis());
|
||||
version.setUpdatedBy(UserContext.getUserId());
|
||||
version.setUpdatedAt(System.currentTimeMillis());
|
||||
|
||||
checkOptimisticLock(abilityPackageVersionMapper.updateById(version), versionId);
|
||||
log.info("提交能力包版本审批: versionId={}", versionId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void approve(Long versionId, String comment) {
|
||||
AbilityPackageVersion version = loadVersion(versionId);
|
||||
if (!"PENDING".equals(version.getApprovalStatus())) {
|
||||
throw new BusinessException(ErrorCode.OPERATION_FAILED, "仅 PENDING 状态可审批,当前状态: " + version.getApprovalStatus());
|
||||
}
|
||||
|
||||
Long approverId = UserContext.getUserId();
|
||||
// 职责分离:提交人不能审批自己的版本
|
||||
if (version.getSubmittedBy() != null && version.getSubmittedBy().equals(approverId)) {
|
||||
throw new BusinessException(ErrorCode.OPERATION_FAILED, "提交人不能审批自己提交的版本");
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
version.setApprovalStatus("APPROVED");
|
||||
version.setApprovedBy(approverId);
|
||||
version.setApprovedAt(now);
|
||||
version.setUpdatedBy(approverId);
|
||||
version.setUpdatedAt(now);
|
||||
|
||||
checkOptimisticLock(abilityPackageVersionMapper.updateById(version), versionId);
|
||||
|
||||
// 同步主表 version + approval_status
|
||||
AbilityPackage pkgUpdate = new AbilityPackage();
|
||||
pkgUpdate.setId(version.getAbilityPackageId());
|
||||
pkgUpdate.setVersion(version.getVersion());
|
||||
pkgUpdate.setApprovalStatus("APPROVED");
|
||||
pkgUpdate.setUpdatedAt(now);
|
||||
pkgUpdate.setUpdatedBy(approverId);
|
||||
abilityPackageMapper.updateById(pkgUpdate);
|
||||
|
||||
// 同步 items:逻辑删除现有 + 从快照重建
|
||||
syncItemsFromSnapshot(version, now, approverId);
|
||||
|
||||
log.info("审批通过能力包版本: versionId={}, packageId={}, version={}",
|
||||
versionId, version.getAbilityPackageId(), version.getVersion());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void reject(Long versionId, String reason) {
|
||||
AbilityPackageVersion version = loadVersion(versionId);
|
||||
if (!"PENDING".equals(version.getApprovalStatus())) {
|
||||
throw new BusinessException(ErrorCode.OPERATION_FAILED, "仅 PENDING 状态可驳回,当前状态: " + version.getApprovalStatus());
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
Long approverId = UserContext.getUserId();
|
||||
version.setApprovalStatus("REJECTED");
|
||||
version.setApprovedBy(approverId);
|
||||
version.setApprovedAt(now);
|
||||
version.setRejectReason(reason);
|
||||
version.setUpdatedBy(approverId);
|
||||
version.setUpdatedAt(now);
|
||||
|
||||
checkOptimisticLock(abilityPackageVersionMapper.updateById(version), versionId);
|
||||
log.info("驳回能力包版本: versionId={}, reason={}", versionId, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(Long versionId) {
|
||||
AbilityPackageVersion version = loadVersion(versionId);
|
||||
String status = version.getApprovalStatus();
|
||||
|
||||
if ("PENDING".equals(status)) {
|
||||
throw new BusinessException(ErrorCode.OPERATION_FAILED, "PENDING 状态版本不可删除,请先驳回");
|
||||
}
|
||||
if ("APPROVED".equals(status)) {
|
||||
// APPROVED 版本被项目引用时禁止删除
|
||||
Long count = projectAbilityMapper.selectCount(new LambdaQueryWrapper<ProjectAbility>()
|
||||
.eq(ProjectAbility::getVersionId, versionId));
|
||||
if (count != null && count > 0) {
|
||||
throw new BusinessException(ErrorCode.OPERATION_FAILED, "APPROVED 版本被项目引用,不可删除");
|
||||
}
|
||||
}
|
||||
// DRAFT / REJECTED / APPROVED(未被引用) 可删除
|
||||
abilityPackageVersionMapper.deleteById(versionId);
|
||||
log.info("删除能力包版本: versionId={}, status={}", versionId, status);
|
||||
}
|
||||
|
||||
// ===== 内部方法 =====
|
||||
|
||||
private AbilityPackageVersion loadVersion(Long versionId) {
|
||||
AbilityPackageVersion version = abilityPackageVersionMapper.selectById(versionId);
|
||||
if (version == null) {
|
||||
throw new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "能力包版本不存在");
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* 乐观锁失败检测:updateById 返回 0 表示 version 已被其他事务修改
|
||||
*/
|
||||
private void checkOptimisticLock(int rows, Long versionId) {
|
||||
if (rows == 0) {
|
||||
throw new BusinessException(ErrorCode.OPERATION_FAILED, "版本已变更,请刷新后重试: " + versionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从快照同步 items:逻辑删除现有 items + 从快照重建
|
||||
*/
|
||||
private void syncItemsFromSnapshot(AbilityPackageVersion version, long now, Long userId) {
|
||||
Map<String, Object> snapshotMap = JsonUtils.fromJsonToMap(version.getSnapshot());
|
||||
List<AbilityPackageItem> snapshotItems = JsonUtils.getObjectMapper().convertValue(
|
||||
snapshotMap.get("items"),
|
||||
new TypeReference<List<AbilityPackageItem>>() {});
|
||||
|
||||
// 逻辑删除现有 items
|
||||
abilityPackageItemMapper.delete(new LambdaQueryWrapper<AbilityPackageItem>()
|
||||
.eq(AbilityPackageItem::getAbilityPackageId, version.getAbilityPackageId()));
|
||||
|
||||
// 从快照重建
|
||||
for (AbilityPackageItem item : snapshotItems) {
|
||||
item.setId(null);
|
||||
item.setAbilityPackageId(version.getAbilityPackageId());
|
||||
item.setVersion(version.getVersion());
|
||||
item.setApprovalStatus("APPROVED");
|
||||
item.setCreatedAt(now);
|
||||
item.setUpdatedAt(now);
|
||||
item.setCreatedBy(userId);
|
||||
item.setUpdatedBy(userId);
|
||||
abilityPackageItemMapper.insert(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
-- ========================================
|
||||
-- U21 能力包版本化(变更审批流)
|
||||
-- 自建单审批人 3 态状态机:DRAFT → PENDING → APPROVED/REJECTED
|
||||
-- 设计要点:
|
||||
-- t_ability_package / t_ability_package_item 增加 version + approval_status
|
||||
-- t_project_ability 增加 version_id(关联到具体版本,NULL 表示用当前版本)
|
||||
-- t_ability_package_version 记录每次变更快照 + 审批轨迹
|
||||
-- ponytail: submitted_at/approved_at 用 BIGINT 时间戳,与 BaseEntity 约定一致(非 DATETIME)
|
||||
-- ========================================
|
||||
|
||||
-- 1. 能力包主表增加版本号 + 审批状态
|
||||
ALTER TABLE t_ability_package
|
||||
ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT '当前已审批版本号(0 表示初始无版本)',
|
||||
ADD COLUMN approval_status VARCHAR(20) NOT NULL DEFAULT 'APPROVED' COMMENT '当前审批状态:DRAFT/PENDING/APPROVED/REJECTED';
|
||||
|
||||
-- 2. 能力包项表增加版本号 + 审批状态
|
||||
ALTER TABLE t_ability_package_item
|
||||
ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT '当前已审批版本号',
|
||||
ADD COLUMN approval_status VARCHAR(20) NOT NULL DEFAULT 'APPROVED' COMMENT '当前审批状态:DRAFT/PENDING/APPROVED/REJECTED';
|
||||
|
||||
-- 3. 项目-能力包关联表增加版本关联
|
||||
ALTER TABLE t_project_ability
|
||||
ADD COLUMN version_id BIGINT NULL DEFAULT NULL COMMENT '关联的具体版本ID(NULL 表示用 package 当前版本)';
|
||||
|
||||
-- 4. 能力包版本表
|
||||
CREATE TABLE IF NOT EXISTS `t_ability_package_version` (
|
||||
`id` BIGINT UNSIGNED NOT NULL COMMENT '主键,雪花算法',
|
||||
`project_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '项目ID(系统级模板为NULL)',
|
||||
`ability_package_id` BIGINT UNSIGNED NOT NULL COMMENT '所属能力包ID',
|
||||
`version` INT NOT NULL COMMENT '业务版本号(递增)',
|
||||
`approval_status` VARCHAR(20) NOT NULL DEFAULT 'DRAFT' COMMENT '审批状态:DRAFT/PENDING/APPROVED/REJECTED',
|
||||
`snapshot` JSON NOT NULL COMMENT '能力包+其 items 的完整快照',
|
||||
`submitted_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '提交人 userId',
|
||||
`submitted_at` BIGINT DEFAULT NULL COMMENT '提交时间(时间戳,毫秒)',
|
||||
`approved_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '审批人 userId',
|
||||
`approved_at` BIGINT DEFAULT NULL COMMENT '审批时间(时间戳,毫秒)',
|
||||
`reject_reason` VARCHAR(500) DEFAULT NULL COMMENT '驳回原因',
|
||||
`lock_version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
`created_at` BIGINT NOT NULL COMMENT '创建时间(时间戳,毫秒)',
|
||||
`updated_at` BIGINT NOT NULL COMMENT '更新时间(时间戳,毫秒)',
|
||||
`created_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID',
|
||||
`updated_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '更新人ID',
|
||||
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0未删 1已删',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_package_version` (`ability_package_id`, `version`),
|
||||
KEY `idx_approval_status` (`approval_status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='能力包版本表(变更审批轨迹)';
|
||||
|
|
@ -34,6 +34,7 @@ class AuditLogCoverageTest {
|
|||
|
||||
/** 所有业务 Controller(不含 InternalController 内部 Feign 接口) */
|
||||
private static final List<Class<?>> CONTROLLERS = List.of(
|
||||
AbilityPackageController.class,
|
||||
ApprovalFlowConfigController.class,
|
||||
ArchiveController.class,
|
||||
BuildingController.class,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,289 @@
|
|||
package com.pms.base.service;
|
||||
|
||||
import com.pms.base.entity.AbilityPackage;
|
||||
import com.pms.base.entity.AbilityPackageItem;
|
||||
import com.pms.base.entity.AbilityPackageVersion;
|
||||
import com.pms.base.entity.ProjectAbility;
|
||||
import com.pms.base.mapper.AbilityPackageItemMapper;
|
||||
import com.pms.base.mapper.AbilityPackageMapper;
|
||||
import com.pms.base.mapper.AbilityPackageVersionMapper;
|
||||
import com.pms.base.mapper.ProjectAbilityMapper;
|
||||
import com.pms.base.service.impl.AbilityPackageVersionServiceImpl;
|
||||
import com.pms.common.exception.BusinessException;
|
||||
import com.pms.common.security.UserContext;
|
||||
import com.pms.common.util.JsonUtils;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* 能力包版本服务测试
|
||||
* 覆盖:状态机流转、职责分离、状态校验、删除引用检查、乐观锁
|
||||
*/
|
||||
@DisplayName("能力包版本服务测试")
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class AbilityPackageVersionServiceTest {
|
||||
|
||||
@Mock
|
||||
private AbilityPackageVersionMapper abilityPackageVersionMapper;
|
||||
@Mock
|
||||
private AbilityPackageMapper abilityPackageMapper;
|
||||
@Mock
|
||||
private AbilityPackageItemMapper abilityPackageItemMapper;
|
||||
@Mock
|
||||
private ProjectAbilityMapper projectAbilityMapper;
|
||||
|
||||
@InjectMocks
|
||||
private AbilityPackageVersionServiceImpl service;
|
||||
|
||||
private static final Long PACKAGE_ID = 3001L;
|
||||
private static final Long VERSION_ID = 7001L;
|
||||
private static final Long SUBMITTER_ID = 9001L;
|
||||
private static final Long APPROVER_ID = 9002L;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// 默认以审批人身份
|
||||
UserContext.set(new UserContext.CurrentUser(APPROVER_ID, "approver", null, "A4", "1"));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
UserContext.clear();
|
||||
}
|
||||
|
||||
/** 构造测试用能力包 */
|
||||
private AbilityPackage buildPackage() {
|
||||
AbilityPackage pkg = new AbilityPackage();
|
||||
pkg.setId(PACKAGE_ID);
|
||||
pkg.setPackageCode("RESIDENTIAL");
|
||||
pkg.setPackageName("住宅能力包");
|
||||
pkg.setTier("T1");
|
||||
pkg.setStatus(1);
|
||||
pkg.setSort(1);
|
||||
pkg.setVersion(0);
|
||||
pkg.setApprovalStatus("APPROVED");
|
||||
return pkg;
|
||||
}
|
||||
|
||||
/** 构造测试用能力项 */
|
||||
private List<AbilityPackageItem> buildItems() {
|
||||
AbilityPackageItem item = new AbilityPackageItem();
|
||||
item.setId(4001L);
|
||||
item.setAbilityPackageId(PACKAGE_ID);
|
||||
item.setCapabilityCode("OWNER_COMMITTEE");
|
||||
item.setCapabilityName("业委会管理");
|
||||
item.setEnabled(1);
|
||||
item.setSort(1);
|
||||
return List.of(item);
|
||||
}
|
||||
|
||||
/** 构造快照 JSON */
|
||||
private String buildSnapshot(AbilityPackage pkg, List<AbilityPackageItem> items) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("package", pkg);
|
||||
map.put("items", items);
|
||||
return JsonUtils.toJson(map);
|
||||
}
|
||||
|
||||
/** 构造 PENDING 状态版本(submittedBy 为提交人,不同于当前审批人) */
|
||||
private AbilityPackageVersion buildPendingVersion() {
|
||||
AbilityPackageVersion v = new AbilityPackageVersion();
|
||||
v.setId(VERSION_ID);
|
||||
v.setAbilityPackageId(PACKAGE_ID);
|
||||
v.setVersion(1);
|
||||
v.setApprovalStatus("PENDING");
|
||||
v.setSnapshot(buildSnapshot(buildPackage(), buildItems()));
|
||||
v.setSubmittedBy(SUBMITTER_ID);
|
||||
v.setLockVersion(0);
|
||||
return v;
|
||||
}
|
||||
|
||||
// ===== 场景 1: Happy path approve =====
|
||||
|
||||
@Test
|
||||
@DisplayName("完整审批流:createDraft → submitForApproval → approve → 主表 version 更新")
|
||||
void happyPath_approve_updatesMainTable() {
|
||||
// 1. createDraft
|
||||
when(abilityPackageMapper.selectById(PACKAGE_ID)).thenReturn(buildPackage());
|
||||
when(abilityPackageItemMapper.selectList(any())).thenReturn(buildItems());
|
||||
when(abilityPackageVersionMapper.insert(any())).thenAnswer(inv -> {
|
||||
AbilityPackageVersion v = inv.getArgument(0);
|
||||
v.setId(VERSION_ID);
|
||||
return 1;
|
||||
});
|
||||
|
||||
AbilityPackageVersion draft = service.createDraft(PACKAGE_ID);
|
||||
assertThat(draft.getApprovalStatus()).isEqualTo("DRAFT");
|
||||
assertThat(draft.getVersion()).isEqualTo(1);
|
||||
|
||||
// 2. submitForApproval(切换为提交人身份)
|
||||
UserContext.set(new UserContext.CurrentUser(SUBMITTER_ID, "submitter", null, "A4", "1"));
|
||||
when(abilityPackageVersionMapper.selectById(VERSION_ID)).thenReturn(draft);
|
||||
when(abilityPackageVersionMapper.updateById(any())).thenReturn(1);
|
||||
|
||||
service.submitForApproval(VERSION_ID);
|
||||
assertThat(draft.getApprovalStatus()).isEqualTo("PENDING");
|
||||
assertThat(draft.getSubmittedBy()).isEqualTo(SUBMITTER_ID);
|
||||
|
||||
// 3. approve(切回审批人身份)
|
||||
UserContext.set(new UserContext.CurrentUser(APPROVER_ID, "approver", null, "A4", "1"));
|
||||
when(abilityPackageItemMapper.delete(any())).thenReturn(1);
|
||||
when(abilityPackageItemMapper.insert(any())).thenReturn(1);
|
||||
|
||||
service.approve(VERSION_ID, "同意");
|
||||
|
||||
assertThat(draft.getApprovalStatus()).isEqualTo("APPROVED");
|
||||
assertThat(draft.getApprovedBy()).isEqualTo(APPROVER_ID);
|
||||
// 验证主表 version 同步
|
||||
verify(abilityPackageMapper).updateById(argThat(p ->
|
||||
p.getVersion().equals(1) && "APPROVED".equals(p.getApprovalStatus())));
|
||||
// 验证 items 重建
|
||||
verify(abilityPackageItemMapper).delete(any());
|
||||
verify(abilityPackageItemMapper).insert(argThat(item ->
|
||||
"APPROVED".equals(item.getApprovalStatus()) && item.getVersion().equals(1)));
|
||||
}
|
||||
|
||||
// ===== 场景 2: Happy path reject =====
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回流:createDraft → submitForApproval → reject → 状态 REJECTED")
|
||||
void happyPath_reject_setsRejected() {
|
||||
AbilityPackageVersion version = buildPendingVersion();
|
||||
when(abilityPackageVersionMapper.selectById(VERSION_ID)).thenReturn(version);
|
||||
when(abilityPackageVersionMapper.updateById(any())).thenReturn(1);
|
||||
|
||||
service.reject(VERSION_ID, "配置不符合规范");
|
||||
|
||||
assertThat(version.getApprovalStatus()).isEqualTo("REJECTED");
|
||||
assertThat(version.getRejectReason()).isEqualTo("配置不符合规范");
|
||||
assertThat(version.getApprovedBy()).isEqualTo(APPROVER_ID);
|
||||
}
|
||||
|
||||
// ===== 场景 3: 职责分离 =====
|
||||
|
||||
@Test
|
||||
@DisplayName("职责分离:提交人 == 审批人 → 抛异常")
|
||||
void approve_submitterEqualsApprover_throws() {
|
||||
AbilityPackageVersion version = buildPendingVersion();
|
||||
// 当前用户 = 提交人
|
||||
UserContext.set(new UserContext.CurrentUser(SUBMITTER_ID, "submitter", null, "A4", "1"));
|
||||
when(abilityPackageVersionMapper.selectById(VERSION_ID)).thenReturn(version);
|
||||
|
||||
assertThatThrownBy(() -> service.approve(VERSION_ID, "同意"))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("提交人不能审批");
|
||||
|
||||
verify(abilityPackageVersionMapper, never()).updateById(any());
|
||||
}
|
||||
|
||||
// ===== 场景 4: submitForApproval 状态校验 =====
|
||||
|
||||
@Test
|
||||
@DisplayName("状态校验:submitForApproval 非 DRAFT → 抛异常")
|
||||
void submitForApproval_notDraft_throws() {
|
||||
AbilityPackageVersion version = buildPendingVersion(); // PENDING
|
||||
when(abilityPackageVersionMapper.selectById(VERSION_ID)).thenReturn(version);
|
||||
|
||||
assertThatThrownBy(() -> service.submitForApproval(VERSION_ID))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("仅 DRAFT 状态可提交");
|
||||
|
||||
verify(abilityPackageVersionMapper, never()).updateById(any());
|
||||
}
|
||||
|
||||
// ===== 场景 5: approve 状态校验 =====
|
||||
|
||||
@Test
|
||||
@DisplayName("状态校验:approve 非 PENDING → 抛异常")
|
||||
void approve_notPending_throws() {
|
||||
AbilityPackageVersion version = buildPendingVersion();
|
||||
version.setApprovalStatus("DRAFT"); // 非 PENDING
|
||||
when(abilityPackageVersionMapper.selectById(VERSION_ID)).thenReturn(version);
|
||||
|
||||
assertThatThrownBy(() -> service.approve(VERSION_ID, "同意"))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("仅 PENDING 状态可审批");
|
||||
|
||||
verify(abilityPackageVersionMapper, never()).updateById(any());
|
||||
}
|
||||
|
||||
// ===== 场景 6: delete DRAFT =====
|
||||
|
||||
@Test
|
||||
@DisplayName("删除:DRAFT 状态可删除")
|
||||
void delete_draft_succeeds() {
|
||||
AbilityPackageVersion version = buildPendingVersion();
|
||||
version.setApprovalStatus("DRAFT");
|
||||
when(abilityPackageVersionMapper.selectById(VERSION_ID)).thenReturn(version);
|
||||
when(abilityPackageVersionMapper.deleteById(VERSION_ID)).thenReturn(1);
|
||||
|
||||
service.delete(VERSION_ID);
|
||||
|
||||
verify(abilityPackageVersionMapper).deleteById(VERSION_ID);
|
||||
}
|
||||
|
||||
// ===== 场景 7: delete APPROVED 被引用 =====
|
||||
|
||||
@Test
|
||||
@DisplayName("删除:APPROVED 被项目引用 → 抛异常")
|
||||
void delete_approvedReferenced_throws() {
|
||||
AbilityPackageVersion version = buildPendingVersion();
|
||||
version.setApprovalStatus("APPROVED");
|
||||
when(abilityPackageVersionMapper.selectById(VERSION_ID)).thenReturn(version);
|
||||
when(projectAbilityMapper.selectCount(any())).thenReturn(2L);
|
||||
|
||||
assertThatThrownBy(() -> service.delete(VERSION_ID))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("被项目引用");
|
||||
|
||||
verify(abilityPackageVersionMapper, never()).deleteById(any());
|
||||
}
|
||||
|
||||
// ===== 场景 8: delete APPROVED 未被引用 =====
|
||||
|
||||
@Test
|
||||
@DisplayName("删除:APPROVED 未被引用可删除")
|
||||
void delete_approvedNotReferenced_succeeds() {
|
||||
AbilityPackageVersion version = buildPendingVersion();
|
||||
version.setApprovalStatus("APPROVED");
|
||||
when(abilityPackageVersionMapper.selectById(VERSION_ID)).thenReturn(version);
|
||||
when(projectAbilityMapper.selectCount(any())).thenReturn(0L);
|
||||
when(abilityPackageVersionMapper.deleteById(VERSION_ID)).thenReturn(1);
|
||||
|
||||
service.delete(VERSION_ID);
|
||||
|
||||
verify(abilityPackageVersionMapper).deleteById(VERSION_ID);
|
||||
}
|
||||
|
||||
// ===== 场景 9: 乐观锁 =====
|
||||
|
||||
@Test
|
||||
@DisplayName("并发:@Version 乐观锁冲突 → 抛异常")
|
||||
void approve_optimisticLockConflict_throws() {
|
||||
AbilityPackageVersion version = buildPendingVersion();
|
||||
when(abilityPackageVersionMapper.selectById(VERSION_ID)).thenReturn(version);
|
||||
// updateById 返回 0 表示版本已被其他事务修改
|
||||
when(abilityPackageVersionMapper.updateById(any())).thenReturn(0);
|
||||
|
||||
assertThatThrownBy(() -> service.approve(VERSION_ID, "同意"))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("版本已变更");
|
||||
|
||||
// 验证主表未被同步(乐观锁失败后不应继续)
|
||||
verify(abilityPackageMapper, never()).updateById(any());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue