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:
ether 2026-07-03 21:25:31 +08:00
parent 641acefe7c
commit efb67c6286
12 changed files with 778 additions and 0 deletions

View File

@ -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/REJECTEDAPPROVED 被引用时拒绝
*/
@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();
}
}

View File

@ -31,4 +31,10 @@ public class AbilityPackage extends BaseEntity {
/** 排序 */
private Integer sort;
/** 当前已审批版本号0 表示初始无版本) */
private Integer version;
/** 当前审批状态DRAFT/PENDING/APPROVED/REJECTED */
private String approvalStatus;
}

View File

@ -28,4 +28,10 @@ public class AbilityPackageItem extends BaseEntity {
/** 排序 */
private Integer sort;
/** 当前已审批版本号 */
private Integer version;
/** 当前审批状态DRAFT/PENDING/APPROVED/REJECTED */
private String approvalStatus;
}

View File

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

View File

@ -19,4 +19,7 @@ public class ProjectAbility extends BaseEntity {
/** 主业态标记0否 1是全局唯一应用层保证 */
private Integer isPrimary;
/** 关联的具体版本IDNULL 表示用 package 当前版本) */
private Long versionId;
}

View File

@ -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> {
}

View File

@ -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 / REJECTEDAPPROVED 被项目引用时拒绝
*/
void delete(Long versionId);
}

View File

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

View File

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

View File

@ -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 '关联的具体版本IDNULL 表示用 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='能力包版本表(变更审批轨迹)';

View File

@ -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,

View File

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