13 Commits

Author SHA1 Message Date
dk
36752d1d15 fix(产品需求): 修复产品需求使用状态和终止态字典的问题 2026-05-07 17:07:52 +08:00
dk
73360d70ce fix(产品需求): 修复产品需求查询的问题、修复产品需求树返回数据的问题 2026-05-07 11:10:21 +08:00
dk
7913c210cd feat(产品需求): 产品需求相关代码 2026-05-06 17:49:30 +08:00
dk
06d29210ba Merge branch 'main' of http://192.168.1.22:3000/Microservice/cn-rdms
# Conflicts:
#	rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java
2026-04-28 16:58:35 +08:00
dk
b4e1aae062 feat(产品需求): 产品和产品需求相关的测试类 2026-04-28 16:53:33 +08:00
dk
9ad7e063c0 feat(产品需求): 产品和产品需求相关的测试类 2026-04-28 16:50:04 +08:00
dk
846348e1aa feat(user): 支持前端用用户昵称字段进行模糊搜索
fix(post): 使岗位排序能按照sort字段来排序。
2026-04-28 16:43:38 +08:00
ae90dcec24 feat(project): 为项目活动时间线添加成员角色名称显示功能
- 在 ObjectActivityConstants 中添加 MEMBER_ACTION_UPDATE 类型支持
- 为 ProductActivityQueryService 和 ProductActivityTimelineQueryService
  添加角色名称加载和缓存功能
- 实现角色名称解析和 JSON 数据结构扩展
- 添加相关单元测试验证角色名称显示逻辑
- 集成 ObjectPermissionApi 获取角色信息并实现缓存机制
2026-04-24 16:22:23 +08:00
ee732b97bf feat(project): 新增产品动态时间线接口并重构活动查询逻辑
- 新增 GET /project/product/{id}/activities/page 接口用于产品动态时间线分页查询
- 添加 ProductActivityTimelinePageReqVO 和 ProductActivityTimelineRespVO 数据传输对象
- 实现 ProductActivityTimelineQueryService 服务处理动态时间线查询逻辑
- 在 BizAuditLogMapper 中新增按业务类型和动作类型查询的方法
- 在 ProductStatusLogMapper 中新增按产品ID和动作类型查询的方法
- 将硬编码的活动类型常量抽取到 ObjectActivityConstants 统一管理
- 重构 ProductActivityQueryService 使用统一的常量和查询方法
- 更新 ProductMemberServiceImpl 和 ProductServiceImpl 使用新的活动常量
- 添加相应的单元测试验证新接口和查询逻辑的正确性
- 新增产品对象首页改版设计文档和产品动态时间线接口需求说明文档
2026-04-24 15:43:38 +08:00
0a6d70f7cf feat(permission): 新增对象权限API接口及实现
- 定义ObjectPermissionApi接口提供对象作用域权限查询功能
- 实现ObjectPermissionApiImpl提供角色权限查询和转换逻辑
- 添加ObjectMenuRespDTO、ObjectRoleRespDTO和ObjectRolePermissionRespDTO数据传输对象
- 实现按角色ID、角色编码查询对象作用域角色及权限的功能
- 提供获取对象作用域角色菜单与权限聚合结果的方法
- 添加完整单元测试覆盖对象权限API的主要业务场景
2026-04-23 09:23:33 +08:00
156728b1b9 feat(permission): 重构权限系统实现对象级别权限控制
- 在PermissionService中新增getScopedMenusByRoleId和getScopedPermissionsByRoleId方法
- 实现getScopedMenusByRoleId方法用于获取角色的对象范围菜单列表
- 实现getScopedPermissionsByRoleId方法用于获取角色的对象范围权限集合
- 添加getEnabledScopedRole私有方法确保只处理启用状态的角色对象
- 在ProductMemberServiceImpl中替换SystemRoleMapper为ObjectPermissionApi调用
- 将验证产品角色的方法改为调用远程权限接口验证
- 更新ProductObjectPermissionService使用远程权限接口替代本地查询
- 修改ProductServiceImpl中权限获取逻辑使用新的对象权限API
- 移除原有的系统菜单和角色相关的数据对象依赖
- 在测试类中更新模拟对象和断言逻辑适配新的权限接口调用
2026-04-23 09:22:43 +08:00
2943a6255b docs(product): 删除产品管理SQL口径和业务设计文档
- 移除02-产品管理SQL已确认口径文档
- 移除02-产品管理业务设计文档
- 清理产品管理模块的详细设计说明
- 删除产品需求状态字段口径定义
- 移除来源承接与需求拆分口径说明
- 清理需求终态原因承接口径内容
- 删除产品生命周期管理设计
- 移除产品团队权限管理规范
- 清理产品与项目关系约束说明
- 删除轻量需求管理业务规则
- 移除产品状态机与流程设计
- 清理权限与动作矩阵定义
2026-04-22 18:18:38 +08:00
dk
f8231c2d51 feat(user): 支持前端用用户昵称字段进行模糊搜索
fix(post): 使岗位排序能按照sort字段来排序。
2026-04-22 14:38:41 +08:00
112 changed files with 9299 additions and 2852 deletions

View File

@@ -0,0 +1,4 @@
#NOTE: This is a Maven Resolver internal implementation file, its format can be changed without prior notice.
#Tue Apr 21 08:50:58 CST 2026
https\://maven.aliyun.com/repository/public/.lastUpdated=1776732658408
https\://maven.aliyun.com/repository/public/.error=

View File

@@ -23,6 +23,13 @@
- 根模块打包类型:`pom`
- Spring Boot 版本:`3.5.9`
## 本地环境约定
- 本机 Maven 安装路径:`C:\software\apache-maven-3.8.9`
- 如需执行 Maven 命令,优先使用完整路径:`C:\software\apache-maven-3.8.9\bin\mvn.cmd`
- 不要假设 `mvn` 已加入 PATH
- 只有在用户已明确同意执行编译、测试、打包等 Maven 命令时,才使用上述路径执行
顶层模块:
1. `rdms-system`
@@ -196,6 +203,7 @@ rdms-xxx
- 新增或修改代码时,关键字段、关键分支、关键约束和非直观实现应补充简洁中文注释。
- 不要为了省事删除原有有效注释,也不要添加无信息量的注释。
- 写入中文内容时必须保持 UTF-8 编码,并自行检查中文显示是否正常;不要用“改成英文”规避乱码问题。
- 使用 superpowers 产出的功能文档时例如设计文档、实施计划、联调说明除非用户明确要求否则默认用中文落地代码标识、文件路径、接口路径、SQL、命令保持原始技术标识不做意译。
## 工作规则

View File

@@ -10,6 +10,8 @@ import java.util.List;
public class InDictCollectionValidator implements ConstraintValidator<InDict, Collection<?>> {
private static final String MISSING_DICT_DATA_MESSAGE = "字典数据缺失,请联系管理员维护";
private String dictType;
@Override
@@ -25,6 +27,12 @@ public class InDictCollectionValidator implements ConstraintValidator<InDict, Co
}
// 校验全部通过
List<String> dbValues = DictFrameworkUtils.getDictDataValueList(dictType);
if (dbValues.isEmpty()) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(MISSING_DICT_DATA_MESSAGE)
.addConstraintViolation();
return false;
}
boolean match = list.stream().allMatch(v -> dbValues.stream()
.anyMatch(dbValue -> dbValue.equalsIgnoreCase(v.toString())));
if (match) {
@@ -40,4 +48,3 @@ public class InDictCollectionValidator implements ConstraintValidator<InDict, Co
}
}

View File

@@ -9,6 +9,8 @@ import java.util.List;
public class InDictValidator implements ConstraintValidator<InDict, Object> {
private static final String MISSING_DICT_DATA_MESSAGE = "字典数据缺失,请联系管理员维护";
private String dictType;
@Override
@@ -24,6 +26,12 @@ public class InDictValidator implements ConstraintValidator<InDict, Object> {
}
// 校验通过
final List<String> values = DictFrameworkUtils.getDictDataValueList(dictType);
if (values.isEmpty()) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(MISSING_DICT_DATA_MESSAGE)
.addConstraintViolation();
return false;
}
boolean match = values.stream().anyMatch(v -> StrUtil.equalsIgnoreCase(v, value.toString()));
if (match) {
return true;
@@ -38,4 +46,3 @@ public class InDictValidator implements ConstraintValidator<InDict, Object> {
}
}

View File

@@ -0,0 +1,270 @@
# 产品动态时间线后端接口需求说明
日期2026-04-23
## 1. 背景
当前产品对象首页中的“产品动态时间线”模块,用户期望不是展示几条前端拼装的摘要,而是一个可以在首页内直接查询的正式动态模块。
本轮已确认的目标能力包括:
- 首页内直接展示产品动态时间线
- 默认查询最近 `30 天`
- 支持自定义起止时间筛选
- 支持事件类型多选筛选
- 支持分页
- 第一版事件范围收敛在产品对象与团队关系,不混入需求池事件
## 2. 当前前端现状
当前前端可直接使用的真实接口只有:
- `GET /project/product/get`
- `GET /project/product/{id}/settings`
- `GET /project/product/{id}/members`
这些接口目前只能提供:
- 产品当前状态
- 最近一次状态原因
- 产品经理
- 成员加入时间
- 成员退出时间
- 当前成员角色
它们可以勉强拼出少量“最近动态摘要”,但不足以支撑正式时间线查询模块。
## 3. 当前接口为什么不够
如果继续只依赖现有接口,前端存在以下硬缺口:
1. 没有统一的产品动态分页接口
2. 没有事件类型维度,无法支持类型筛选
3. 没有统一发生时间字段集合,无法稳定支持时间筛选
4. 没有事件操作人字段,无法明确展示“谁做了什么”
5. 没有统一的事件摘要字段,前端只能自己硬拼文案
6. 没有产品状态变更前后值,无法展示“从什么状态变更为什么状态”
7. 没有产品经理变更前后值,无法展示交接关系
8. 没有分页总数字段,无法做首页内翻页
因此,现有接口只适合做“概览摘要”,不适合做“可查询产品动态时间线”。
## 4. 后端接口交付要求
后端必须新增一条专用分页接口:
`GET /project/product/{id}/activities/page`
这条接口只服务“产品动态时间线”能力,不承担需求池动态,不承担首页其它概览指标。
前端要求后端单独提供这条接口,原因如下:
- 语义清晰,前后端都容易维护
- 首页时间线可直接消费
- 后续如果要做独立的产品动态页,也可以继续复用这条接口
- 不需要继续让前端从多个接口里拼装事件
## 5. 第一版事件范围
第一版事件类型确认收敛为 5 类:
- `product_created`:产品创建
- `product_status_changed`:产品状态变更
- `product_manager_changed`:产品经理变更
- `product_member_joined`:成员加入
- `product_member_removed`:成员移出
第一版明确不纳入:
- 成员角色调整
- 需求新增
- 需求状态流转
- 需求关闭
- 里程碑事件
- 风险点事件
## 6. 查询参数要求
接口必须支持以下查询参数:
- `pageNo`:页码
- `pageSize`:每页数量
- `startTime`:开始时间
- `endTime`:结束时间
- `types`:事件类型数组,多选
补充要求:
- 当前端未传时间范围时,后端默认按最近 `30 天` 返回
- 返回结果按 `occurredAt desc` 倒序排列
- `types` 支持多选,不要求前端单选
示例:
```http
GET /project/product/1001/activities/page?pageNo=1&pageSize=10&startTime=2026-03-24 00:00:00&endTime=2026-04-23 23:59:59&types=product_status_changed&types=product_manager_changed
```
## 7. 返回结构要求
接口返回必须支持分页,分页结构至少应满足:
```json
{
"total": 128,
"list": [
{
"id": "act_001",
"type": "product_status_changed",
"title": "产品状态变更",
"operatorId": "10001",
"operatorName": "张敏",
"occurredAt": "2026-04-23 10:32:15",
"summary": "产品状态由暂停变更为启用",
"reason": "测试恢复",
"beforeStatus": "paused",
"afterStatus": "active"
}
]
}
```
分页顶层字段至少包括:
- `total`
- `list`
如果后端已有统一分页模型,可以沿用现有分页结构,但前端必须能稳定拿到总数和列表。
## 8. 事件通用字段要求
无论哪种事件类型,后端都应统一返回以下字段:
- `id`:事件唯一 ID
- `type`:事件类型编码
- `title`:事件标题
- `operatorId`:操作人 ID
- `operatorName`:操作人名称
- `occurredAt`:发生时间
- `summary`:事件摘要
- `reason`:原因或备注,可为空
这些字段是首页时间线最小可展示集合。
## 9. 各事件类型的专属字段要求
### 9.1 产品创建 `product_created`
必须补充:
- `creatorUserId`
- `creatorUserName`
### 9.2 产品状态变更 `product_status_changed`
这是当前最关键的一类事件,后端必须返回:
- `beforeStatus`
- `afterStatus`
- `reason`
前端需要用这组字段明确表达:
- 变更前状态
- 变更后状态
- 本次变更原因
例如:
- `暂停 -> 启用`
- `启用 -> 归档`
### 9.3 产品经理变更 `product_manager_changed`
必须返回:
- `beforeManagerUserId`
- `beforeManagerUserName`
- `afterManagerUserId`
- `afterManagerUserName`
- `reason`
否则前端无法准确展示交接关系,只能看到当前经理,不能看到变更前后关系。
### 9.4 成员加入 `product_member_joined`
必须返回:
- `memberUserId`
- `memberUserName`
- `roleId`
- `roleName`
- `remark`
### 9.5 成员移出 `product_member_removed`
必须返回:
- `memberUserId`
- `memberUserName`
- `roleId`
- `roleName`
- `reason`
## 10. 前端展示口径
前端首页时间线模块第一版会直接基于这条接口支持:
- 默认最近 `30 天`
- 自定义时间范围筛选
- 事件类型多选筛选
- 分页切换
每条记录最少展示:
- 事件类型
- 事件标题
- 操作人
- 发生时间
- 变更摘要
- 原因/备注
其中“产品状态变更”需要明确体现:
- 变更前状态
- 变更后状态
- 变更原因
## 11. 为什么不建议继续让前端拼装
如果继续沿用当前前端拼装方案,会有这些问题:
- 产品状态变更前后值无法补齐
- 产品经理变更前后值无法补齐
- 无法支持分页
- 无法支持统一时间筛选
- 无法支持统一类型筛选
- 不同事件文案会在前端散落拼装,长期维护成本高
因此这里的前后端边界应明确为:
- 后端提供统一产品动态分页接口
- 前端负责筛选条件组织、分页交互和时间线展示
## 12. 本轮需求结论
本轮给后端的结论可以直接收敛为:
1. 当前前端已有接口不满足正式产品动态时间线需求
2. 后端新增 `GET /project/product/{id}/activities/page`
3. 接口必须支持分页、默认最近 `30 天`、自定义时间范围、事件类型多选
4. 第一版事件类型只做:
- 产品创建
- 产品状态变更
- 产品经理变更
- 成员加入
- 成员移出
5. 产品状态变更必须提供前后状态和原因
6. 产品经理变更必须提供前后经理信息
这条接口交付后,前端才能把当前“产品动态时间线”从拼装摘要升级成正式可查询模块。

View File

@@ -0,0 +1,292 @@
# 产品对象首页改版设计说明
日期2026-04-23
## 1. 目标
本设计用于收敛 RDMS 产品对象上下文默认首页的改版方向。
本轮目标不是继续做“说明型占位页”,而是明确把当前 `/product/dashboard?objectId=...` 改成一个真正可用的产品对象首页:
- 第一眼先让用户知道当前看的是什么产品
- 第二眼能快速判断对象最近发生了什么
- 第三眼能看出需求池现在的经营状态和最近变化
- 底部为后续业务模块保留正式挂载位,而不是临时拼接入口
## 2. 已确认诉求
基于本轮对话,已确认以下用户诉求:
1. 首页顶部必须先展示产品基础概述,而不是先铺统计卡片
2. 基础概述至少包含:名称、编号、团队、产品经理等对象基础信息
3. 页面需要一块明显的时间线,用于承接产品对象与团队变更动态
4. 页面需要承接需求池管理情况,重点看总量、状态、待处理等统计信息
5. 需求相关事件不要混入对象时间线,应单独作为需求池最近变化区域
6. 快捷入口不要保留
7. 底部允许保留后续扩展区,重点预留给里程碑、风险点管理、产品资料等模块
8. 能接真实接口就接真实接口,当前没有稳定接口的区域允许先用假数据,但结构必须按正式首页来设计
## 3. 首页定位结论
本页定位不是:
- 纯报表看板
- 纯审计日志页
- 设置页搬运版
- 导航入口集合页
本页定位应当是:
- 产品对象首页
- 偏统计,也带审计
- 但页面主语始终是“当前产品对象”
换句话说,这个页面要同时回答三个问题:
1. 我现在看的是什么产品?
2. 这个产品对象最近发生了什么?
3. 这个产品的需求池现在处于什么状态?
## 4. 页面结构
### 4.1 桌面端结构
桌面端建议采用三层结构:
1. 顶部 `对象基础概述横幅`
2. 中部 `左时间线 + 右需求池双模块`
3. 底部 `扩展信息区`
推荐布局比例:
- 顶部横幅:`24 / 24`
- 中部主区:左 `16 / 24`,右 `8 / 24`
- 底部扩展区:`24 / 24`
中部左侧时间线高度应明显高于右侧任一单模块,形成首页主阅读区。
### 4.2 移动端结构
移动端统一退化为单列纵向布局,顺序为:
1. 对象基础概述横幅
2. 对象 / 团队动态时间线
3. 需求池管理概览
4. 需求池最近变化
5. 扩展信息区
移动端不强撑左右栏并排,不做卡片墙式压缩。
## 5. 模块设计
### 5.1 对象基础概述横幅
顶部采用“档案横幅型”,不采用纯指标卡片型。
横幅左侧承接对象身份信息:
- 产品名称
- 产品编号
- 当前状态标签
- 产品经理
- 团队规模
- 团队角色摘要
- 简短描述或备注
横幅右侧承接 4 个摘要指标:
- 团队人数
- 需求总量
- 待处理需求
- 最近动态时间
设计原则:
- 左侧负责建立对象识别
- 右侧负责快速判断当前概况
- 右侧指标只保留 4 项,不堆成报表卡片墙
### 5.2 对象 / 团队动态时间线
该区域位于中部左侧,是首页的主阅读区。
这条时间线只承接对象与团队变化,不承接需求事件。
第一版事件范围收敛为:
- 产品创建
- 产品状态变更
- 产品经理变更
- 成员加入
- 成员移出
- 成员角色调整
每条时间线建议展示:
- 事件标题
- 事件类型标签
- 发生时间
- 操作摘要
- 必要时展示原因或备注
表达目标是“业务时间线”,不是后台审计表格。
### 5.3 需求池管理概览
该区域位于中部右侧上半块,用于表达需求池的经营状态。
第一版首页需要优先看到的内容:
- 需求总量
- 各状态数量
- 待处理数量
- 高优先级待处理数量
展示方式建议为“摘要指标 + 状态分布列表”,不直接在首页展开完整需求表格。
这一块回答的是:
- 需求池是否健康
- 当前待处理压力大不大
- 是否存在需要优先关注的积压
### 5.4 需求池最近变化
该区域位于中部右侧下半块,与需求池管理概览上下分层,但属于同一侧栏语义。
该区域不重复展示总量,而是展示需求池最近发生的变化。
第一版建议承接:
- 最近新增需求
- 最近状态流转
- 最近关闭或完成
每条记录建议至少展示:
- 需求标题
- 动作类型
- 时间
- 当前状态或状态变更摘要
若当前没有真实数据,仍保留正式模块壳,不退化成“待开发”一句话。
### 5.5 扩展信息区
底部不再保留快捷入口,改为正式扩展信息区。
当前优先预留 3 类模块位:
- 里程碑
- 风险点管理
- 产品资料
这一层的作用是:
- 为后续对象级信息继续扩展留下稳定挂载位
- 不把中部主结构挤成信息大杂烩
- 避免为了未来模块提前做假导航入口
如果当前没有稳定接口,可先保留正式卡片结构与空态说明。
## 6. 数据策略
### 6.1 真实接口优先
当前首页优先消费现有真实接口:
- `fetchGetProduct`
- `fetchGetProductSettings`
- `fetchGetProductMembers`
这些接口足以支撑:
- 对象基础概述中的名称、编号、状态、产品经理、描述
- 团队人数与角色摘要
- 最近动态中的产品创建、状态变化、成员加入/移出
### 6.2 假数据使用边界
当前没有稳定真实接口的区域,允许先用假数据,但边界必须明确:
- 需求池管理概览
- 需求池最近变化
- 扩展信息区中的里程碑、风险点管理、产品资料摘要
假数据的使用原则:
1. 只补“当前没有稳定接口”的区域
2. 不反向污染对象基础信息
3. 不把假数据混入对象上下文 store
4. 数据源要集中放在概览页自己的 mock 模块中,方便后续替换
### 6.3 不推荐的做法
以下做法应避免:
- 把需求假数据散落写进页面组件
- 用对象 demo 数据冒充真实产品详情
- 把对象时间线和需求时间线混成一条
- 用快捷入口伪装成首页内容
## 7. 空态规则
首页至少要区分三种状态:
1. 能力未接入,只能先显示正式占位信息
2. 能力已接入,但当前该产品暂无业务数据
3. 当前用户无权限查看该模块
这三种状态不能共用一套模糊文案。
对需求池和扩展信息区,当前阶段更推荐“正式空态”而不是“待开发”。
## 8. 页面边界
首页明确不承接以下内容:
- 快捷入口导航区
- 完整团队成员表格
- 完整需求列表表格
- 设置页重表单
- 完整审计日志明细页
首页要做的是概述、判断与阅读,不是重操作页。
## 9. 实施建议
第一阶段建议先完成结构性改造:
1. 重做顶部横幅,建立对象档案感
2. 保留中部左高右双块结构
3. 用真实接口接通对象概述与对象 / 团队时间线
4. 用局部 mock 数据先接通需求池两块和底部扩展区
第二阶段再逐步替换需求池与扩展区数据源:
- 接真实需求池统计接口
- 接真实需求动态接口
- 接里程碑、风险点、产品资料摘要接口
## 10. 验证标准
本设计是否成立,可按以下标准判断:
1. 进入首页后,第一眼能认出当前产品对象
2. 用户能自然读到对象 / 团队最近发生了什么
3. 右侧能快速判断需求池当前压力与最近变化
4. 页面看起来像“对象首页”,而不是“普通后台卡片堆叠页”
5. 当前没有真实接口的区域也保留正式结构,不显得像临时占位
6. 后续新增里程碑、风险点管理、产品资料等能力时,不需要推翻整页结构
## 11. 本轮设计结论
本轮最终设计结论如下:
- 首页定位为“产品对象首页”,偏统计,也带审计,但不做纯报表页
- 顶部采用档案横幅型,先立住对象身份信息
- 中部左侧是高权重的对象 / 团队动态时间线
- 中部右侧拆为“需求池管理概览 + 需求池最近变化”上下两块
- 底部去掉快捷入口,改为正式扩展信息区
- 当前有真实接口的模块优先接真实接口
- 当前没有稳定接口的区域允许先用假数据,但必须隔离在概览页局部 mock 数据源中

View File

@@ -18,6 +18,39 @@ public interface ErrorCodeConstants {
ErrorCode PRODUCT_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_001_005, "动作【{}】必须填写原因");
ErrorCode PRODUCT_DELETE_NAME_MISMATCH = new ErrorCode(1_008_001_006, "删除确认名称与当前产品名称不一致");
ErrorCode PRODUCT_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_001_007, "当前产品状态不允许编辑");
ErrorCode PRODUCT_PAUSED_ONLY_ALLOW_LIMITED_UPDATE = new ErrorCode(1_008_001_008, "产品暂停后仅允许变更产品经理、描述和备注");
ErrorCode PRODUCT_PAUSED_ONLY_ALLOW_LIMITED_UPDATE = new ErrorCode(1_008_001_008, "产品暂停后仅允许修正描述,产品经理请通过产品团队维护");
ErrorCode PRODUCT_MEMBER_NOT_EXISTS = new ErrorCode(1_008_001_009, "产品团队成员不存在");
ErrorCode PRODUCT_MEMBER_ALREADY_EXISTS = new ErrorCode(1_008_001_010, "该用户已是当前产品的有效团队成员");
ErrorCode PRODUCT_MEMBER_ROLE_INVALID = new ErrorCode(1_008_001_011, "角色不存在或不属于产品对象角色");
ErrorCode PRODUCT_MANAGER_TRANSFER_INFO_REQUIRED = new ErrorCode(1_008_001_013, "切换产品经理时必须同时传入原产品经理用户和交接后角色");
ErrorCode PRODUCT_MANAGER_MEMBER_NOT_ALLOW_REMOVE = new ErrorCode(1_008_001_014, "当前产品经理不能移出产品团队,请先完成经理转交");
ErrorCode PRODUCT_MANAGER_MEMBER_NOT_ALLOW_DOWNGRADE = new ErrorCode(1_008_001_015, "当前产品经理不能直接调整为非经理角色,请先完成经理转交");
ErrorCode PRODUCT_MEMBER_NOT_ACTIVE = new ErrorCode(1_008_001_016, "当前产品团队成员已失效");
ErrorCode PRODUCT_MANAGER_TRANSFER_SOURCE_INVALID = new ErrorCode(1_008_001_017, "原产品经理信息与当前产品经理不一致");
ErrorCode PRODUCT_MANAGER_TRANSFER_ROLE_INVALID = new ErrorCode(1_008_001_018, "原产品经理交接后的角色不能仍为产品经理");
ErrorCode PRODUCT_MANAGER_NOT_MODIFIABLE = new ErrorCode(1_008_001_019, "产品主数据编辑不允许直接变更产品经理,请通过产品团队维护");
ErrorCode PRODUCT_OBJECT_PERMISSION_DENIED = new ErrorCode(1_008_001_020, "当前用户不具备该产品的操作权限【{}】");
ErrorCode PRODUCT_DELETE_CONFIRM_TEXT_INVALID = new ErrorCode(1_008_001_021, "删除确认口令不正确");
ErrorCode PRODUCT_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_001_022, "产品状态已发生变化,请刷新后重试");
ErrorCode PRODUCT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED = new ErrorCode(1_008_001_023, "产品状态定义不存在或已停用");
// ========== 产品需求 1-008-002-000 ==========
ErrorCode REQUIREMENT_NOT_EXISTS = new ErrorCode(1_008_002_000, "产品需求不存在");
ErrorCode REQUIREMENT_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_002_001, "当前需求状态不支持动作【{}】");
ErrorCode REQUIREMENT_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_002_002, "动作【{}】必须填写原因");
ErrorCode REQUIREMENT_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_002_003, "需求状态已发生变化,请刷新后重试");
ErrorCode REQUIREMENT_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_002_004, "当前需求状态为终态,不允许编辑");
ErrorCode REQUIREMENT_STATUS_NOT_ALLOW_CLOSE = new ErrorCode(1_008_002_005, "只有已验收的需求才能关闭");
ErrorCode REQUIREMENT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED = new ErrorCode(1_008_002_006, "需求状态定义不存在或已停用");
ErrorCode REQUIREMENT_PARENT_NOT_ALLOW_SPLIT = new ErrorCode(1_008_002_007, "父需求状态不是待分流或实施中,不允许拆分");
ErrorCode REQUIREMENT_CHILD_NOT_ALLOW_CLOSE = new ErrorCode(1_008_002_008, "存在子需求状态不对,请先处理子需求");
ErrorCode REQUIREMENT_MODULE_NOT_EXISTS = new ErrorCode(1_008_002_009, "需求模块不存在");
ErrorCode REQUIREMENT_MODULE_NAME_DUPLICATE = new ErrorCode(1_008_002_010, "已经存在名称为【{}】的模块");
ErrorCode REQUIREMENT_MODULE_NOT_BELONG_TO_PRODUCT = new ErrorCode(1_008_002_011, "模块不属于当前产品");
ErrorCode REQUIREMENT_MODULE_HAS_NON_TERMINAL_REQUIREMENTS = new ErrorCode(1_008_002_012, "模块下存在非终态需求,不可删除");
ErrorCode REQUIREMENT_HAS_CHILDREN = new ErrorCode(1_008_002_013, "存在子需求,请先删除子需求");
ErrorCode REQUIREMENT_STATUS_NOT_ALLOW_DELETE = new ErrorCode(1_008_002_014, "只有待确认、待评审、待分流状态的需求才能删除");
ErrorCode REQUIREMENT_MODULE_HAS_CHILDREN = new ErrorCode(1_008_002_015, "存在子模块,请先删除子模块");
ErrorCode REQUIREMENT_MODULE_HAS_REQUIREMENTS = new ErrorCode(1_008_002_016, "模块下存在需求,请先删除需求");
}

View File

@@ -1,13 +0,0 @@
package com.njcn.rdms.module.project.enums;
/**
* 项目交付域字典类型常量
*/
public interface ProjectDictTypeConstants {
/**
* 产品方向
*/
String PRODUCT_DIRECTION = "rdms_product_direction";
}

View File

@@ -1,49 +0,0 @@
# 02-产品管理 SQL已确认口径
## 0. 文档说明
本文档用于记录产品管理 SQL 已确认的实现口径。
本文档只保留已确认结果,不保留待确认项、方案对比或历史演变说明。
## 1. 共享表承接边界
- `rdms_user_object_role`
- `rdms_object_status_model`
- `rdms_object_status_transition`
- `rdms_biz_audit_log`
以上共享表继续由 `rdms-project/rdms-project-boot/src/main/resources/sql/product/01_product_schema.sql` 承接。
## 2. 产品需求状态字段口径
- `rdms_product_requirement` 统一改为 `status_code` 口径。
- 产品需求状态与产品状态保持一致,统一使用状态编码模型。
## 3. 来源承接与需求拆分口径
- 产品需求既可能来自工单流转,也可能来自产品内手工新增。
- 产品需求不论来源,都允许继续拆分为 N 个子需求。
- 同一产品下,同一来源工单只生成 1 条源头需求记录。
- 源头需求记录可以拆分为 N 条子需求。
- 手工新增需求也可以拆分为 N 条子需求。
- 子需求不参与“来源唯一”约束。
- 来源追溯和拆分关系分开建模。
## 4. 需求终态原因承接口径
- 需求终态原因由主表承接当前结果态,同时审计日志保留完整留痕。
- 主表统一承接终态结果字段,覆盖 `reject``cancel``close` 等终态动作。
字段口径:
- `terminal_action_code`
- `terminal_reason`
- `terminal_time`
## 5. 当前确认结果
- 第 1 项:共享表继续由 `01_product_schema.sql` 承接
- 第 2 项:`rdms_product_requirement` 统一改为 `status_code`
- 第 3 项:来源承接与需求拆分分开建模
- 第 4 项:主表补终态结果字段,审计日志继续完整留痕

View File

@@ -1,478 +0,0 @@
# 03-产品管理范围内产品与产品需求链路与工作流方案
## 0. 文档目的
本文档用于回答以下问题:
- 在当前产品管理范围内,`product -> product_requirement` 是否适合做链路视图
- 后续工单进入产品侧时,应如何接入当前产品管理链路
- 是否适合引入 Flowable
- Flowable 在当前产品管理方案中应承接哪些节点,不应承接哪些节点
- 当前产品管理链路应如何建模
- 当前产品需求设计中的“终态原因”口径适用范围是什么
说明:
- 本文档中的“产品作为主上下文”仅限当前产品管理能力建设范围。
- 该口径用于当前产品管理方案收敛,不作为 RDMS 全局统一建模原则。
- 本文档只给方案不直接修改业务代码、SQL 或正式设计文档。
## 1. 当前现状判断
### 1.1 当前代码与文档现状
- 当前仓库未看到 Flowable 依赖、BPMN 定义或流程引擎接入实现。
- `rdms-project` 已开始承接通用对象状态模型,当前已有 `rdms_object_status_model``rdms_object_status_transition` 的 DO 和 Mapper。
- 产品管理当前已经明确:产品与产品需求承接轻量状态流转,但当前版本不展开正式工作流引擎。
- 当前产品需求设计已具备来源追溯、状态流转、认领 / 拒绝、拆分等业务语义。
- 当前要开发的是产品管理,不是项目管理;`project_requirement / execution / task` 不属于本期范围。
### 1.2 当前链路特征
在本期产品管理范围内,当前业务链路主要具备以下特征:
- 产品是当前方案的主上下文
- 产品侧可以手工新增产品需求
- 后续工单可以进入产品侧形成来源需求
- 一个产品需求可以拆分成 N 个子需求
- 某些节点需要人认领、人评审、人审批
- 某些节点只是普通业务状态推进,不适合上审批流
这意味着在本期产品管理范围内,该链路本质上是“产品上下文下的产品需求关系网络”,不是“一条从头跑到尾的单一审批流程”。
## 2. 核心结论
### 2.1 可行性判断
结论:可行,但本期不建议把产品管理范围内的所有动作都堆进单一 Flowable 流程。
推荐方向:
- 在本期产品管理方案内,产品作为当前链路的主上下文和主入口
- 当前链路聚合围绕 `product``product_requirement` 组织
- 需求链(`chain`)表示“本期产品管理范围内,产品下的一条源头需求链”
- 工单在后续接入时,只作为产品需求的来源关系之一,不作为链路根
- 业务主链统一由状态机控制
- Flowable 等以后开发评审流程时再接入,只承接评审、审批类协同节点
- 各业务对象继续维护自己的 `status_code`
- 流程状态不是业务真相源,业务表状态才是业务真相源
- 本期先按表和后端接口完成基础建模和后端闭环,不做前端页面和流程引擎接入
### 2.2 不建议单一长流程的原因
- 产品需求存在手工新增、父子拆分等多种情况;后续再叠加工单来源时,也难以用单流程表达。
- 认领、拒绝、拆分、关闭等动作很多是高频业务动作,不适合全部流程化。
- 流程状态和业务状态容易双写不一致。
- 当前真正需要先打通的是产品管理范围内的后端聚合能力,而不是先上流程图。
### 2.3 终态原因口径适用范围
“终态原因承接口径”不是只针对工单来源需求,而是针对产品需求对象本身。
也就是说,无论产品需求来自:
- 工单流转
- 手工新增
只要走到 `reject``cancel``close` 这类终态动作,都需要明确终态原因承接方式。
从后端聚合查询角度看,主表保留当前结果态原因字段,审计日志继续保留完整留痕。
## 3. 建议总体架构
当前建议拆成 4 层,再预留 1 个后续扩展层。
### 3.1 业务对象层
业务对象继续独立建模,独立维护生命周期和 `status_code`
本期纳入当前链路建模的对象:
- 产品 `product`
- 产品需求 `product_requirement`
- `work_order` 作为后续预留来源对象
要求:
- 在本期产品管理范围内,产品是当前链路的主上下文
- 产品需求是当前链路的核心业务对象
- 每个对象的状态变化由业务服务负责落库
- 后续工单进入产品侧后,只承接来源关系,不取代产品主上下文
- 业务主链先由状态机控制,不由流程引擎接管
### 3.2 关系模型层
关系模型用于描述对象之间的连接关系,而不是靠单个来源字段硬扛全部追溯逻辑。
本期至少需要表达以下关系:
- 产品和源头需求之间的主上下文关系
- 产品需求父子拆分
建议引入统一关系表,例如:
`rdms_biz_relation`
建议关键字段:
- `id`
- `chain_id`
- `product_id`
- `from_biz_type`
- `from_biz_id`
- `to_biz_type`
- `to_biz_id`
- `relation_type`
- `sort`
- `remark`
建议 `relation_type` 取值至少包括:
- `product_root`:产品主上下文
- `split_child`:拆分子需求
后续预留关系类型:
- `source_work_order`:来源工单
说明:
- `product_root` 用于表达本期产品管理范围内,产品和源头需求之间的主上下文关系。
- 手工新增不是对象间来源关系,不必强行补一条虚拟来源边;可由 `rdms_requirement_chain.entry_source_type = manual` 承接。
### 3.3 事件模型层
事件模型用于描述“产品下的一条需求链上发生了什么”,服务于后端聚合查询和后续时间线视图。
建议引入统一事件表,例如:
`rdms_biz_event`
建议关键字段:
- `id`
- `chain_id`
- `product_id`
- `biz_type`
- `biz_id`
- `event_type`
- `action_code`
- `from_status_code`
- `to_status_code`
- `operator_user_id`
- `operator_name`
- `reason`
- `event_time`
- `payload_json`
事件类型可覆盖:
- `create`
- `claim`
- `reject`
- `split`
- `review_pass`
- `review_reject`
- `close`
- `cancel`
后续预留事件类型:
- `source_attach`
### 3.4 需求链聚合层
为了做当前产品管理范围内的链路聚合,建议引入统一聚合对象。
建议引入需求链主表,例如:
`rdms_requirement_chain`
建议关键字段:
- `id`
- `chain_code`
- `product_id`
- `root_requirement_id`
- `entry_source_type`
- `entry_biz_type`
- `entry_biz_id`
- `title`
- `current_status_code`
- `closed_flag`
用途:
- 作为“产品下的一条源头需求链”的聚合单元
- 聚合关系和事件
- 支撑后端聚合查询;后续前端如接入,再由产品详情页和产品需求详情页承接
说明:
- 一个产品下会有多条需求链
- 一条需求链只属于一个产品
- 一条需求链只围绕一个源头产品需求展开
- 后续工单来源需求创建需求链时,工单只写入 `entry_biz_type / entry_biz_id``source_work_order` 关系,不作为根对象
### 3.5 流程绑定层(后续扩展)
流程引擎只负责协同过程,因此等以后开发评审流程时,可再补业务对象和流程实例的绑定层。
建议引入流程绑定表,例如:
`rdms_workflow_binding`
建议关键字段:
- `id`
- `chain_id`
- `product_id`
- `biz_type`
- `biz_id`
- `process_definition_key`
- `process_instance_id`
- `workflow_status`
- `current_task_key`
- `current_task_name`
- `starter_user_id`
- `start_time`
- `end_time`
说明:
- 该层不是本期前置条件
- 本期不接 Flowable 时,不需要先落这张表
- 等以后开发评审流程时再接入,只用于评审、审批类协同节点
## 4. Flowable 适用边界
### 4.1 适合接 Flowable 的节点
以下节点等以后开发评审流程时,可考虑接入 Flowable
- 产品需求待评审
- 高风险终态动作审批
- 其他明确的审批类节点
这类节点的共同特征是:
- 需要明确待办人
- 需要审批意见
- 需要驳回、转交、加签、会签等协作能力
### 4.2 不建议接 Flowable 的节点
以下节点不建议直接建成流程审批节点:
- 后续工单接入产品侧后的认领 / 拒绝
- 普通状态编辑
- 列表筛选查询
- 日常状态推进
- 产品需求拆分
这类节点更适合保留在业务状态机或普通业务服务里,否则流程实例会过多、过碎、过重。
## 5. 当前链路视图如何实现
本期只要求后端先具备聚合查询能力,不要求直接交付页面。
### 5.1 拓扑数据
展示本期产品管理范围内,以产品为入口的对象关系链:
```text
产品
-> 产品需求(源头需求)
-> 子需求
后续工单 -(source_work_order)-> 产品需求(可选来源)
```
后端主要聚合:
- `rdms_requirement_chain`
- `rdms_biz_relation`
- `product`
- `product_requirement`
### 5.2 时间线数据
展示产品下某条需求链上的关键事件:
- 产品需求创建
- 产品需求认领
- 产品需求评审
- 拆分子需求
- 关闭 / 拒绝 / 取消
后端主要聚合:
- `rdms_biz_event`
- `product_requirement` 当前状态摘要
说明:
- 时间线围绕单条需求链展开,不是把整个产品下所有动作混成一条总流水。
- 本期先交付后端聚合查询接口,不要求同时交付拓扑页和时间线页,也不进入前端联调。
## 6. 对现有产品管理设计的建议
### 6.1 本期产品管理范围内的主上下文口径
既然本期要开发的是产品管理,建议当前方案中的聚合查询和后端入口都优先挂在产品上下文下。
要求:
- `product_id` 是链路聚合模型的必填归属字段
- 后续前端如接入,产品详情页和产品需求详情页是主入口
- 后续工单详情页如需展示链路,只适合作为来源跳转入口,不适合作为主视图入口
### 6.2 状态口径
既然你已经确认本期只做产品和产品需求,建议当前阶段只要求这两个对象继续沿用统一的 `status_code` 口径。
说明:
- `product`
- `product_requirement`
项目管理阶段再单独设计 `project_requirement / execution / task` 的状态模型和流转规则,不并入本期范围。
### 6.3 来源与拆分口径
你已经确认:
- 来源承接和需求拆分分开建模
- 后续工单来源需求可拆分
- 手工新增需求也可拆分
当前产品需求口径应将来源追溯和拆分关系分开承接:
- `source_biz_*` 字段只承接来源追溯
- `parent_requirement_id``root_requirement_id` 承接拆分链路
- 后续同一产品下,同一来源工单只生成 1 条源头需求记录
- 子需求不参与来源唯一约束
### 6.4 终态原因口径
产品需求主表统一承接当前结果态字段。
当前字段方向:
- `terminal_action_code`
- `terminal_reason`
- `terminal_time`
这样后端做本期产品管理范围内的聚合查询时,可以直接读取当前结果态原因,而不用每次回扫审计日志。
## 7. 建议实施顺序
建议按三步走,但只有第一步属于本期必做范围。
### 第一步:先完成本期产品管理的基础建模和后端闭环
目标:
- 明确在本期产品管理范围内,产品作为当前链路的主上下文
- 建立链路主表
- 建立链路关系表
- 建立链路事件表
- 统一产品和产品需求的状态口径
- 先把业务主链的状态机闭环跑通
- 先提供产品上下文下查询单条需求链关系拓扑和事件时间线的聚合接口
这一阶段先不接 Flowable只按表和后端接口开发不做前端拓扑页和时间线页也不扩到项目管理对象。
### 第二步:后续开发评审流程时接入有限流程节点
目标:
- 产品需求评审
- 高风险终态审批
- 其他明确需要审批协同的节点
这类节点等以后开发评审流程时可接入 Flowable但不是本期前置条件。
### 第三步:后续在项目管理阶段扩展项目域对象
目标:
- 在后续项目管理建设时,再补 `project_requirement / execution / task` 等对象
- 届时再单独设计它们和产品需求之间的关系、事件和流程边界
说明:
- 这一步不属于本期产品管理开发范围。
## 8. 风险点与控制建议
### 8.1 范围扩散到项目管理
风险:
- 在当前产品管理开发过程中,又把 `project_requirement / execution / task` 一起拉进来
控制建议:
- 所有设计文档都明确“本期只做产品和产品需求”
- 项目域对象放到后续项目管理建设时再单独设计
### 8.2 状态双写不一致
风险:
- 流程状态更新了,但业务表状态没更新
- 业务状态更新了,但流程实例没推进
控制建议:
- 本期主链统一由状态机控制
- 等以后开发评审流程时接入 Flowable也只作为协同触发器
- 每次关键动作统一写 `rdms_biz_event`
### 8.3 来源与拆分关系混淆
风险:
- 来源关系和拆分关系混在一个字段里,后续追溯会失真
控制建议:
- 来源关系和父子拆分关系分开建模
- 统一走关系表,不靠单字段隐式表达
### 8.4 前端聚合过早介入
风险:
- 后端模型和接口还没稳住,就先开始拼页面,后面会频繁返工
控制建议:
- 本期先完成基础建模和聚合查询接口
- 前端页面放到后续接口稳定后再接入
## 9. 当前建议结论
当前建议拍板如下:
1. 在本期产品管理方案内,产品作为当前链路的主上下文。
2. 本期范围只包含 `product``product_requirement``work_order` 只作为后续预留来源对象,不纳入第一批实现。
3. 一条需求链(`chain`)表示“产品下的一条源头需求链”,不是整个产品,也不是某个工单。
4. 本期先做基础建模和后端闭环,包括 `requirement_chain / relation / event` 模型、状态机主链、关键动作落库和聚合查询接口。
5. 本期主链统一由状态机控制Flowable 等以后开发评审流程时再接入,只用于评审、审批类节点。
6. 来源追溯和需求拆分必须分开建模。
7. 产品需求终态原因由主表承接当前结果态,并继续保留审计日志完整留痕。
8. 项目域对象放到后续项目管理建设时再单独设计,不并入本期产品管理范围。
9. 以上口径仅用于当前产品管理建设,不作为 RDMS 全局统一建模原则。
## 10. 下一步建议产物
如果继续推进,建议下一步补 3 份专项文档:
1. `产品管理范围内链路接口设计.md`
明确聚合查询接口、状态动作接口的返回结构。
2. `产品管理范围内链路SQL与表结构设计.md`
明确 `requirement_chain / relation / event` 的表结构和索引设计。
3. `产品管理范围内链路开发顺序与任务拆分.md`
明确 SQL、DO、Mapper、Service、Controller 的开发顺序。

View File

@@ -1,221 +0,0 @@
# 04-产品管理 编码前必看清单
## 0. 文档目的
本文档用于在开始产品管理相关编码前,明确必须先阅读的文档、必须锁死的业务口径,以及当前文档与可执行 SQL 的同步状态。
本文档只保留当前编码前必须关注的内容,不保留历史演变说明。
## 1. 文档分级
### 1.1 一级依据
以下文档为产品管理编码主依据,涉及业务口径、接口口径、状态口径时,必须优先对齐:
- `docs/temp/02-产品管理_业务设计.md`
- `docs/temp/02-产品管理_SQL已确认口径.md`
### 1.2 二级依据
以下文档用于补齐 SQL 结构说明:
- `docs/temp/02-产品管理_SQL口径说明.md`
### 1.3 SQL 阅读基线
以下文件是当前产品管理编码前唯一需要阅读的 SQL 文件,用于确认当前表结构、状态模型、状态流转和审计表设计:
- `rdms-project/rdms-project-boot/product/rdms_biz_audit_log.sql`
说明:
- 当前 SQL 阅读入口统一以该文件为准。
- 该文件当前包含 `rdms_biz_audit_log``rdms_object_status_model``rdms_object_status_transition``rdms_product``rdms_product_requirement``rdms_product_status_log``rdms_user_object_role`
- 编码前必须先识别该文件与已确认口径之间的差异,不能直接把该文件当作最终目标结构。
### 1.4 场景性依据
以下文档只在涉及工单来源、链路视图、Flowable 边界时必读:
- `docs/temp/03-工单到任务全链路与工作流方案.md`
## 2. 阅读顺序
开始编码前,按以下顺序阅读:
1. `02-产品管理_业务设计.md`
2. `02-产品管理_SQL已确认口径.md`
3. `02-产品管理_SQL口径说明.md`
4. `rdms-project/rdms-project-boot/product/rdms_biz_audit_log.sql`
5. `03-工单到任务全链路与工作流方案.md`仅在涉及工单链路、需求拆分、Flowable、全链路视图时阅读
## 3. 每份文档必须看的内容
### 3.1 `02-产品管理_业务设计.md`
必须重点看以下内容:
- 模块范围与非本期范围
- 页面与对象上下文承载方式
- 产品需求规则
- 对象关系与数据设计
- 产品状态与产品需求状态机
- 接口承接
- 权限与动作矩阵
- 测试关注点
阅读目标:
- 明确本次到底做哪些页面、动作、字段和权限。
- 明确哪些能力本次不做,避免编码时扩散。
- 明确关闭、认领、拒绝、分流等动作的业务边界。
### 3.2 `02-产品管理_SQL已确认口径.md`
必须逐条看完以下 4 项:
- 共享表承接边界
- 产品需求状态字段口径
- 来源承接与需求拆分口径
- 需求终态原因承接口径
阅读目标:
- 锁死产品需求 `status_code` 口径。
- 锁死来源追溯和需求拆分分开建模。
- 锁死终态结果字段由主表承接。
### 3.3 `02-产品管理_SQL口径说明.md`
必须重点看以下内容:
- 共享表与主数据口径
- 产品需求口径
- 状态与留痕口径
阅读目标:
- 明确当前确认后的 SQL 结构表达方式。
- 明确状态编码、来源追溯、拆分链路、终态结果字段应该如何落到表结构上。
### 3.4 `rdms-project/rdms-project-boot/product/rdms_biz_audit_log.sql`
必须重点看以下内容:
- `rdms_biz_audit_log`
- `rdms_object_status_model`
- `rdms_object_status_transition`
- `rdms_product`
- `rdms_product_requirement`
- `rdms_product_status_log`
- `rdms_user_object_role`
阅读目标:
- 明确当前统一 SQL 阅读入口下的表结构、索引、状态种子数据和审计字段现状。
- 明确哪些字段和状态定义仍未对齐已确认口径。
### 3.5 `03-工单到任务全链路与工作流方案.md`
仅在以下场景必须看:
- 涉及工单流转到产品需求
- 涉及来源追溯与需求拆分
- 涉及产品需求到项目需求的链路设计
- 涉及 Flowable 接入边界
- 涉及全链路视图
阅读目标:
- 明确 Flowable 只承接协同节点,不直接承接整条业务主链状态。
- 明确来源关系、拆分关系、事件关系和流程绑定关系要分开建模。
## 4. 编码前必须锁死的业务口径
### 4.1 模块边界
- 产品管理能力落在 `rdms-project`,不落到 `rdms-system``rdms-gateway`
- 跨模块共享能力通过 `*-api` 承接,不直接依赖其他 `*-boot` 实现。
### 4.2 产品需求状态口径
- `rdms_product_requirement` 主表统一使用 `status_code`
- 产品需求状态定义与流转统一通过 `rdms_object_status_model``rdms_object_status_transition` 承接。
- `object_type` 统一使用 `product_requirement`
### 4.3 来源追溯与需求拆分口径
- 产品需求来源至少包括 `manual``work_order`
- `source_type``source_biz_type``source_biz_id``source_biz_code` 只承接来源追溯。
- `parent_requirement_id``root_requirement_id` 只承接拆分链路。
- 同一产品下,同一来源工单只允许 1 条源头需求记录。
- 源头需求和手工新增需求都允许继续拆分子需求。
- 子需求不参与来源唯一约束。
### 4.4 终态结果口径
- `reject``cancel``close` 等终态动作统一回写主表。
- 主表统一保留以下字段:
- `terminal_action_code`
- `terminal_reason`
- `terminal_time`
- 审计日志继续保留完整过程留痕。
### 4.5 接口动作口径
- 产品状态动作统一走 `POST /products/{id}/change-status`
- 产品需求状态动作统一走 `POST /products/{id}/requirements/{requirementId}/change-status`
- 产品需求 `change-status` 统一支持 `claim``to_review``to_dispatch``dispatch``reject``cancel``close`
- 状态动作不得混入通用编辑接口。
### 4.6 权限与审计口径
- 对象上下文权限按产品对象角色控制。
- 产品团队、产品需求、状态动作、敏感操作都必须落审计。
- 产品状态日志由 `rdms_product_status_log` 承接。
- 产品经理变更、成员调整、需求认领、拒绝、分流、关闭、拆分等由 `rdms_biz_audit_log` 承接。
## 5. 当前明确不做的内容
以下内容当前不纳入本轮编码:
- 产品版本
- 产品路线图
- 正式评审流
- Flowable 引擎接入实现
- 产品基线
- 产品文档与附件
- 工单状态回写
- 目标版本字段
- 完整链路视图页面
## 6. 当前现状与目标口径差异
`rdms_biz_audit_log.sql` 当前仍存在以下差异:
- `rdms_product_requirement` 仍使用 `status`,尚未切到 `status_code`
- 主表仍保留 `closed_reason``closed_time`
- 主表尚未补齐 `terminal_action_code``terminal_reason``terminal_time`
- 主表尚未补齐 `parent_requirement_id``root_requirement_id`
- 产品需求状态模型和状态流转种子尚未按 `product_requirement` 完整落齐
这意味着:
- 不能直接以当前 `rdms_biz_audit_log.sql` 作为产品需求最终结构开始编码。
- 如果进入正式开发,优先动作之一是先同步当前统一 SQL 文件,再同步 DO、Mapper、Service、Controller、接口 VO。
## 7. 编码前建议执行顺序
1. 先对齐 `02-产品管理_业务设计.md``02-产品管理_SQL已确认口径.md`
2. 再对齐 `rdms_biz_audit_log.sql` 与已确认 SQL 口径
3. 再开始补 DO、Mapper、Service、Controller、ReqVO、RespVO
4. 最后对齐权限、审计、状态流转和接口返回字段
## 8. 编码时禁止自由发挥的点
- 不要把产品需求状态继续做成 `tinyint` 枚举字段。
- 不要把来源追溯和需求拆分混在同一组字段里。
- 不要把 `close` 再拆回独立接口。
- 不要把状态动作塞进通用编辑接口。
- 不要在本轮直接引入 Flowable 落地代码。
- 不要在产品模块里扩展未确认的版本、路线图、附件等能力。

View File

@@ -1,130 +0,0 @@
# 05-产品管理 当前开发完成度清单
## 0. 文档定位
本文档只回答 3 件事:
- 当前产品管理后端已经做了什么
- 当前产品管理后端还有什么没做
- 前端现在到底能调哪一段,不能把哪一段当成已完成
说明:
- 本文档以当前代码实际状态为准,不写历史方案,不写计划性口径。
- 本文档当前只覆盖 `rdms-project/rdms-project-boot` 下的产品管理后端实现现状。
- 本文档中的“已完成”表示代码已实现并已静态核对,不表示已经执行编译、测试或联调。
## 1. 当前已完成
### 1.1 已完成的接口
当前产品主数据以下 6 个接口已完成代码实现:
- `GET /project/product/page`
- `GET /project/product/get`
- `POST /project/product/create`
- `PUT /project/product/update`
- `POST /project/product/change-status`
- `POST /project/product/delete`
### 1.2 已完成的主数据能力
围绕产品主数据,当前已完成以下后端能力:
- 产品分页查询
- 产品详情查询
- 创建产品
- 更新产品
- 产品状态变更
- 删除产品
### 1.3 已完成的服务端校验
当前已补齐以下校验:
- 产品存在性校验
- 产品编码未删除范围唯一校验
- 产品名称未删除范围唯一校验
- 产品经理用户有效性校验
- 产品编码创建后不可修改校验
- 产品状态动作必须命中 `rdms_object_status_transition` 校验
- 状态动作原因是否必填校验
- 删除时产品名称二次确认一致校验
### 1.4 已完成的状态与留痕能力
当前已补齐以下状态处理和留痕:
- 创建时默认状态写入 `active`
- 未传产品编码时由服务端自动生成编码,格式按 `CNPDYYYYNNN` 处理
- 状态变更按 `action_code` 驱动,不允许直接透传目标状态
- 状态变更后同步回写 `rdms_product.status_code`
- 状态变更后同步回写 `rdms_product.last_status_reason`
- 产品状态动作写入 `rdms_product_status_log`
- 创建、编辑、状态变更、删除写入 `rdms_biz_audit_log`
### 1.5 已补齐的支撑代码
当前已补齐以下代码支撑:
- 产品域错误码常量
- `rdms_biz_audit_log` 对应 DO / Mapper
- `rdms_product_status_log` 对应 DO / Mapper
- `ProductMapper` 中产品编码前缀查询能力
- `ObjectStatusTransitionMapper` 中仅按启用流转配置查询
## 2. 当前未完成
以下内容当前还没有开发完成,不能视为“产品管理已完成”:
- 产品团队
- 产品需求
- 关联项目
- 最近动态 / `activities`
- 产品上下文 / `context`
- 对象级导航与按钮权限
- 产品团队维护时的 `rdms_user_object_role` 动态写入
- 团队维护引起的产品经理关系同步
## 3. 当前已确认不做
以下内容已按当前口径确认,本阶段不做,不再视为当前主数据闭环缺口:
- 创建产品时不写 `rdms_user_object_role`
- `rdms_user_object_role` 由后续产品团队维护时动态落库
- `pause` / `archive` / `abandon` / `delete` 当前不做关联项目、执行、任务阻塞校验
## 4. 前端现在可联调范围
前端当前可以开始联调的范围,仅限“产品主数据最小闭环”:
- 产品列表
- 产品详情
- 新建产品
- 编辑产品
- 产品状态变更
- 删除产品
前端当前不应开始联调整个“产品管理”模块,尤其不应把以下内容当成可用:
- 产品团队
- 产品需求
- 关联项目
- 最近动态
- 产品上下文能力
## 5. 当前结论
当前状态不是“产品管理开发完毕”,而是:
- 产品主数据最小闭环已完成代码实现
- 整个产品管理仍有明显未完成范围
- 前端现在可以先调产品主数据 6 个接口
联调前仍需单独确认权限是否齐备,当前主数据接口涉及权限码:
- `project:product:query`
- `project:product:create`
- `project:product:update`
- `project:product:status`
- `project:product:delete`

View File

@@ -1,536 +0,0 @@
# 06-产品主数据 API 接口文档
## 0. 文档说明
本文档用于提供“产品主数据最小闭环”当前已完成接口的标准联调口径,供前端直接调试。
当前文档只覆盖以下 6 个接口:
- `GET /project/product/page`
- `GET /project/product/get`
- `POST /project/product/create`
- `PUT /project/product/update`
- `POST /project/product/change-status`
- `POST /project/product/delete`
说明:
- 本文档以当前代码实现为准。
- 本文档不覆盖产品团队、产品需求、关联项目、最近动态、产品上下文等未完成能力。
- 本文档中的返回示例为标准结构示例,不代表数据库中的真实数据。
- 当前仅做静态对齐,未执行编译、启动和联调验证。
## 1. 接口基础信息
### 1.1 访问前缀
当前 Controller 暴露前缀为:
```text
/project/product
```
### 1.2 认证与权限
默认沿用当前系统 OAuth2 / Token 认证链路。
请求头建议:
```http
Authorization: Bearer {accessToken}
Content-Type: application/json
```
各接口所需权限如下:
| 接口 | 权限码 |
|---|---|
| `GET /project/product/page` | `project:product:query` |
| `GET /project/product/get` | `project:product:query` |
| `POST /project/product/create` | `project:product:create` |
| `PUT /project/product/update` | `project:product:update` |
| `POST /project/product/change-status` | `project:product:status` |
| `POST /project/product/delete` | `project:product:delete` |
### 1.3 统一返回结构
所有接口统一返回 `CommonResult<T>`
```json
{
"code": 0,
"msg": "",
"data": {}
}
```
字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | `number` | 业务返回码,成功固定为 `0` |
| `msg` | `string` | 返回消息,成功时通常为空字符串 |
| `data` | `object / array / boolean / number / null` | 业务数据 |
### 1.4 分页返回结构
分页接口 `data` 统一为:
```json
{
"total": 1,
"list": []
}
```
字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
| `total` | `number` | 总记录数 |
| `list` | `array` | 当前页数据列表 |
### 1.5 日期时间格式
当前接口中的日期时间字段统一按下面格式处理:
```text
yyyy-MM-dd HH:mm:ss
```
例如:
```text
2026-04-18 15:30:00
```
## 2. 产品对象字段说明
产品详情与分页列表当前返回字段一致,对应 `ProductRespVO`
| 字段 | 类型 | 必返 | 说明 |
|---|---|---|---|
| `id` | `number` | 是 | 产品主键 ID |
| `code` | `string` | 是 | 产品编码 |
| `directionCode` | `string` | 是 | 产品方向字典值 |
| `name` | `string` | 是 | 产品名称 |
| `managerUserId` | `number` | 是 | 产品经理用户 ID |
| `description` | `string` | 否 | 产品描述 |
| `statusCode` | `string` | 是 | 产品状态编码 |
| `lastStatusReason` | `string` | 否 | 最近一次状态动作原因 |
| `remark` | `string` | 否 | 备注 |
| `createTime` | `string` | 是 | 创建时间 |
| `updateTime` | `string` | 是 | 更新时间 |
## 3. 通用业务口径
### 3.1 产品方向
`directionCode` 使用系统字典 `rdms_product_direction``value`
当前设计文档中的推荐初始值为:
- `embedded`
- `power_electronics`
- `system_group`
### 3.2 产品编码
- 创建时 `code` 可传可不传。
- 如果创建时不传 `code`,后端自动生成,格式为 `CNPDYYYYNNN`
- 更新时不允许修改 `code`
### 3.3 产品状态
当前产品主数据接口涉及的状态编码:
- `active`
- `paused`
- `archived`
- `abandoned`
### 3.4 状态动作
`POST /project/product/change-status` 当前仅支持以下动作:
- `pause`
- `resume`
- `archive`
- `abandon`
动作驱动规则:
- 前端传 `actionCode`
- 后端按 `rdms_object_status_transition` 校验是否允许流转
- 前端不能直接传目标状态编码
### 3.5 当前编辑限制
当前代码口径下:
- `archived``abandoned` 状态不允许编辑
- `paused` 状态下,仅允许调整 `managerUserId``description``remark`
- `paused` 状态下不允许修改 `directionCode``name`
## 4. 接口明细
## 4.1 获取产品分页
### 接口信息
| 项目 | 内容 |
|---|---|
| 请求方式 | `GET` |
| 接口路径 | `/project/product/page` |
| 权限码 | `project:product:query` |
### 请求参数
| 参数 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `pageNo` | query | `number` | 是 | 页码,从 `1` 开始 |
| `pageSize` | query | `number` | 是 | 每页条数,最大 `200` |
| `keyword` | query | `string` | 否 | 关键词,匹配产品编码或产品名称 |
| `directionCode` | query | `string` | 否 | 产品方向字典值 |
| `managerUserId` | query | `number` | 否 | 产品经理用户 ID |
| `statusCode` | query | `string` | 否 | 产品状态编码 |
| `updateTime` | query | `string[]` | 否 | 更新时间区间,建议传两个同名参数 |
`updateTime` 示例:
```text
/project/product/page?pageNo=1&pageSize=10&updateTime=2026-04-01 00:00:00&updateTime=2026-04-30 23:59:59
```
### 请求示例
```http
GET /project/product/page?pageNo=1&pageSize=10&keyword=RDMS&statusCode=active
```
### 成功返回示例
```json
{
"code": 0,
"msg": "",
"data": {
"total": 2,
"list": [
{
"id": 3200000000001,
"code": "CNPD2026001",
"directionCode": "embedded",
"name": "RDMS产品平台",
"managerUserId": 1024,
"description": "面向研发管理的一体化产品",
"statusCode": "active",
"lastStatusReason": null,
"remark": "首批试点产品",
"createTime": "2026-04-18 09:30:00",
"updateTime": "2026-04-18 09:30:00"
}
]
}
}
```
## 4.2 获取产品详情
### 接口信息
| 项目 | 内容 |
|---|---|
| 请求方式 | `GET` |
| 接口路径 | `/project/product/get` |
| 权限码 | `project:product:query` |
### 请求参数
| 参数 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `id` | query | `number` | 是 | 产品 ID |
### 请求示例
```http
GET /project/product/get?id=3200000000001
```
### 成功返回示例
```json
{
"code": 0,
"msg": "",
"data": {
"id": 3200000000001,
"code": "CNPD2026001",
"directionCode": "embedded",
"name": "RDMS产品平台",
"managerUserId": 1024,
"description": "面向研发管理的一体化产品",
"statusCode": "active",
"lastStatusReason": null,
"remark": "首批试点产品",
"createTime": "2026-04-18 09:30:00",
"updateTime": "2026-04-18 09:30:00"
}
}
```
## 4.3 创建产品
### 接口信息
| 项目 | 内容 |
|---|---|
| 请求方式 | `POST` |
| 接口路径 | `/project/product/create` |
| 权限码 | `project:product:create` |
### 请求体字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | `number` | 否 | 创建时不需要传 |
| `code` | `string` | 否 | 产品编码;为空时后端自动生成 |
| `directionCode` | `string` | 是 | 产品方向字典值 |
| `name` | `string` | 是 | 产品名称 |
| `managerUserId` | `number` | 是 | 产品经理用户 ID |
| `description` | `string` | 否 | 产品描述 |
| `remark` | `string` | 否 | 备注 |
### 请求示例
```json
{
"directionCode": "embedded",
"name": "RDMS产品平台",
"managerUserId": 1024,
"description": "面向研发管理的一体化产品",
"remark": "首批试点产品"
}
```
### 成功返回示例
```json
{
"code": 0,
"msg": "",
"data": 3200000000001
}
```
### 当前服务端规则
- `name` 必填,长度最大 `128`
- `directionCode` 必填,长度最大 `32`
- `managerUserId` 必填
- `code` 最大长度 `64`
- `remark` 最大长度 `500`
- 产品编码未删除范围唯一
- 产品名称未删除范围唯一
- 产品经理必须是有效用户
- 创建成功后默认状态为 `active`
## 4.4 更新产品
### 接口信息
| 项目 | 内容 |
|---|---|
| 请求方式 | `PUT` |
| 接口路径 | `/project/product/update` |
| 权限码 | `project:product:update` |
### 请求体字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | `number` | 是 | 产品 ID |
| `code` | `string` | 否 | 如传入,必须与原产品编码一致 |
| `directionCode` | `string` | 是 | 产品方向字典值 |
| `name` | `string` | 是 | 产品名称 |
| `managerUserId` | `number` | 是 | 产品经理用户 ID |
| `description` | `string` | 否 | 产品描述 |
| `remark` | `string` | 否 | 备注 |
### 请求示例
```json
{
"id": 3200000000001,
"code": "CNPD2026001",
"directionCode": "embedded",
"name": "RDMS产品平台",
"managerUserId": 2048,
"description": "更新后的产品描述",
"remark": "已切换负责人"
}
```
### 成功返回示例
```json
{
"code": 0,
"msg": "",
"data": true
}
```
### 当前服务端规则
- `id` 必传
- 产品必须存在
- 产品经理必须是有效用户
- 产品编码不允许修改
- 产品名称未删除范围唯一
- `archived``abandoned` 状态不允许编辑
- `paused` 状态仅允许调整 `managerUserId``description``remark`
## 4.5 变更产品状态
### 接口信息
| 项目 | 内容 |
|---|---|
| 请求方式 | `POST` |
| 接口路径 | `/project/product/change-status` |
| 权限码 | `project:product:status` |
### 请求体字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | `number` | 是 | 产品 ID |
| `actionCode` | `string` | 是 | 动作编码 |
| `reason` | `string` | 否 | 动作原因;是否必填由流转配置决定 |
### `actionCode` 当前支持值
| `actionCode` | 含义 | 当前典型流转 | 原因是否必填 |
|---|---|---|---|
| `pause` | 暂停 | `active -> paused` | 是 |
| `resume` | 恢复 | `paused -> active` | 否 |
| `archive` | 归档 | `active / paused -> archived` | 是 |
| `abandon` | 废弃 | `active / paused -> abandoned` | 是 |
### 请求示例
```json
{
"id": 3200000000001,
"actionCode": "pause",
"reason": "当前阶段资源受限,先暂停推进"
}
```
### 成功返回示例
```json
{
"code": 0,
"msg": "",
"data": true
}
```
### 当前服务端规则
- 产品必须存在
- 动作必须命中 `rdms_object_status_transition`
- 前端不能直接传目标状态
- 若当前流转配置要求必须填写原因,则 `reason` 必填
- 状态变更后会同步回写:
- `rdms_product.status_code`
- `rdms_product.last_status_reason`
- 状态变更后会写入:
- `rdms_product_status_log`
- `rdms_biz_audit_log`
## 4.6 删除产品
### 接口信息
| 项目 | 内容 |
|---|---|
| 请求方式 | `POST` |
| 接口路径 | `/project/product/delete` |
| 权限码 | `project:product:delete` |
### 请求体字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | `number` | 是 | 产品 ID |
| `productName` | `string` | 是 | 二次确认输入的产品名称 |
| `reason` | `string` | 是 | 删除原因 |
### 请求示例
```json
{
"id": 3200000000001,
"productName": "RDMS产品平台",
"reason": "产品录入错误,需重新创建"
}
```
### 成功返回示例
```json
{
"code": 0,
"msg": "",
"data": true
}
```
### 当前服务端规则
- 产品必须存在
- `productName` 必须与当前产品名称完全一致
- `reason` 必填
- 当前删除实现为逻辑删除
- 删除后会写入:
- `rdms_product_status_log`
- `rdms_biz_audit_log`
## 5. 业务错误码
当前产品主数据接口已使用的产品域错误码如下:
| 错误码 | 常量 | 含义 |
|---|---|---|
| `1008001000` | `PRODUCT_NOT_EXISTS` | 产品不存在 |
| `1008001001` | `PRODUCT_CODE_DUPLICATE` | 已存在相同产品编码 |
| `1008001002` | `PRODUCT_NAME_DUPLICATE` | 已存在相同产品名称 |
| `1008001003` | `PRODUCT_CODE_NOT_MODIFIABLE` | 产品编码创建后不允许修改 |
| `1008001004` | `PRODUCT_STATUS_ACTION_NOT_ALLOWED` | 当前状态不支持指定动作 |
| `1008001005` | `PRODUCT_STATUS_ACTION_REASON_REQUIRED` | 当前动作必须填写原因 |
| `1008001006` | `PRODUCT_DELETE_NAME_MISMATCH` | 删除确认名称与当前产品名称不一致 |
| `1008001007` | `PRODUCT_STATUS_NOT_ALLOW_EDIT` | 当前产品状态不允许编辑 |
| `1008001008` | `PRODUCT_PAUSED_ONLY_ALLOW_LIMITED_UPDATE` | 产品暂停后仅允许变更产品经理、描述和备注 |
此外还可能返回全局错误码:
| 错误码 | 含义 |
|---|---|
| `0` | 成功 |
| `400` | 请求参数不正确 |
| `401` | 账号未登录 |
| `403` | 没有该操作权限 |
| `500` | 系统异常 |
## 6. 联调注意事项
当前前端联调时请注意:
- 当前只联调产品主数据,不要把产品团队、产品需求、关联项目等能力一起接入。
- 创建产品时不写 `rdms_user_object_role`,产品团队关系留待后续团队维护接口处理。
- `pause` / `archive` / `abandon` / `delete` 当前不做关联项目、执行、任务阻塞校验。
- 若联调账号缺少权限,会直接返回 `403`
- 若产品方向字典值未准备好,创建和更新接口会触发字典校验失败。

View File

@@ -1,287 +0,0 @@
/*
产品管理初始化 SQL
说明:
1. 本文件作为当前产品管理唯一执行 SQL。
2. 产品方向 `direction_code` 统一存系统字典 `value`;系统字典数据本文件不重复创建。
3. 产品与产品需求状态统一走状态编码模型。
4. 产品需求当前先按已确认状态集落库;补齐流转动作码 `start_execution`、`accept`。
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for rdms_biz_audit_log
-- ----------------------------
DROP TABLE IF EXISTS `rdms_biz_audit_log`;
CREATE TABLE `rdms_biz_audit_log` (
`id` bigint NOT NULL COMMENT '主键ID',
`biz_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '业务对象类型product、product_requirement、rdms_user_object_role、project、project_requirement、execution、task',
`biz_id` bigint NOT NULL COMMENT '业务对象ID',
`action_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '动作类型create、update、change_manager、add_member、remove_member、claim、split、dispatch、reject、cancel、close、start_execution、accept、export',
`from_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '原状态',
`to_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '目标状态',
`field_changes` json NULL COMMENT '关键字段变更摘要(用于负责人变更、成员调整等)',
`reason` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '动作原因或说明',
`operator_user_id` bigint NOT NULL COMMENT '操作人用户ID',
`operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '操作人名称快照',
`remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注',
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_rdms_biz_audit_log_biz_deleted`(`biz_type` ASC, `biz_id` ASC, `deleted` ASC) USING BTREE COMMENT '业务对象索引',
INDEX `idx_rdms_biz_audit_log_action_deleted`(`action_type` ASC, `deleted` ASC) USING BTREE COMMENT '动作类型索引',
INDEX `idx_rdms_biz_audit_log_operator_deleted`(`operator_user_id` ASC, `deleted` ASC) USING BTREE COMMENT '操作人索引',
INDEX `idx_rdms_biz_audit_log_create_time`(`create_time` ASC) USING BTREE COMMENT '创建时间索引'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'RDMS通用业务审计日志表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for rdms_object_status_model
-- ----------------------------
DROP TABLE IF EXISTS `rdms_object_status_model`;
CREATE TABLE `rdms_object_status_model` (
`id` bigint NOT NULL COMMENT '主键ID',
`object_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '对象类型product、project、product_requirement、project_requirement、execution、task',
`status_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '状态编码',
`status_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '状态名称',
`sort` int NOT NULL DEFAULT 0 COMMENT '排序值',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '配置状态0启用 1停用',
`initial_flag` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否初始状态',
`terminal_flag` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否终态',
`allow_edit` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否允许编辑对象主数据',
`allow_create_project` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否允许新建项目',
`allow_create_requirement` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否允许新增需求',
`remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注',
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uk_rdms_object_status_model_object_status_deleted`(`object_type` ASC, `status_code` ASC, `deleted` ASC) USING BTREE COMMENT '对象状态编码未删除范围唯一',
INDEX `idx_rdms_object_status_model_object_sort_deleted`(`object_type` ASC, `sort` ASC, `deleted` ASC) USING BTREE COMMENT '对象状态排序索引',
INDEX `idx_rdms_object_status_model_object_terminal_deleted`(`object_type` ASC, `terminal_flag` ASC, `deleted` ASC) USING BTREE COMMENT '对象终态索引'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'RDMS对象状态模型表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of rdms_object_status_model
-- ----------------------------
INSERT INTO `rdms_object_status_model` VALUES (3100000001001, 'product', 'active', '启用', 10, 0, b'1', b'0', b'1', b'1', b'1', '产品正常可用状态', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_model` VALUES (3100000001002, 'product', 'paused', '暂停', 20, 0, b'0', b'0', b'1', b'0', b'0', '受环境或资源限制临时暂停推进', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_model` VALUES (3100000001003, 'product', 'archived', '归档', 30, 0, b'0', b'1', b'0', b'0', b'0', '历史留存,只读为主', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_model` VALUES (3100000001004, 'product', 'abandoned', '废弃', 40, 0, b'0', b'1', b'0', b'0', b'0', '确认不再继续推进', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_model` VALUES (3100000001201, 'product_requirement', 'pending_confirm', '待确认', 10, 0, b'1', b'0', b'0', b'0', b'0', '工单流转到产品侧后的初始状态', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_model` VALUES (3100000001202, 'product_requirement', 'pending_process', '待处理', 20, 0, b'1', b'0', b'1', b'0', b'0', '手工新增后的默认状态', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_model` VALUES (3100000001203, 'product_requirement', 'pending_review', '待评审', 30, 0, b'0', b'0', b'1', b'0', b'0', '待产品侧完成业务评审判断', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_model` VALUES (3100000001204, 'product_requirement', 'pending_dispatch', '待分流', 40, 0, b'0', b'0', b'1', b'0', b'0', '需求成立,等待明确承接方向', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_model` VALUES (3100000001205, 'product_requirement', 'dispatched', '已分流', 50, 0, b'0', b'0', b'1', b'0', b'0', '已明确承接方向', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_model` VALUES (3100000001206, 'product_requirement', 'in_progress', '实施中', 60, 0, b'0', b'0', b'1', b'0', b'0', '承接项目已进入正式执行', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_model` VALUES (3100000001207, 'product_requirement', 'accepted', '已验收', 70, 0, b'0', b'0', b'1', b'0', b'0', '承接结果已完成验收', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_model` VALUES (3100000001208, 'product_requirement', 'closed', '已关闭', 80, 0, b'0', b'1', b'0', b'0', b'0', '生命周期闭环完成', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_model` VALUES (3100000001209, 'product_requirement', 'rejected', '已拒绝', 90, 0, b'0', b'1', b'0', b'0', b'0', '需求确认不成立或产品侧不接收', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_model` VALUES (3100000001210, 'product_requirement', 'canceled', '已取消', 100, 0, b'0', b'1', b'0', b'0', b'0', '推进过程中终止', '', NOW(), '', NOW(), b'0');
-- ----------------------------
-- Table structure for rdms_object_status_transition
-- ----------------------------
DROP TABLE IF EXISTS `rdms_object_status_transition`;
CREATE TABLE `rdms_object_status_transition` (
`id` bigint NOT NULL COMMENT '主键ID',
`object_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '对象类型product、project、product_requirement、project_requirement、execution、task',
`action_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '动作编码pause、resume、archive、abandon、claim、to_review、to_dispatch、dispatch、start_execution、accept、reject、cancel、close',
`action_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '动作名称',
`from_status_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '起始状态编码',
`to_status_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '目标状态编码',
`need_reason` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否必须填写原因1必须 0非必须',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '配置状态0启用 1停用',
`remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注',
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uk_rdms_object_status_transition_object_from_action_deleted`(`object_type` ASC, `from_status_code` ASC, `action_code` ASC, `deleted` ASC) USING BTREE COMMENT '对象起始状态动作未删除范围唯一',
INDEX `idx_rdms_object_status_transition_object_from_deleted`(`object_type` ASC, `from_status_code` ASC, `status` ASC, `deleted` ASC) USING BTREE COMMENT '对象起始状态流转索引',
INDEX `idx_rdms_object_status_transition_object_to_deleted`(`object_type` ASC, `to_status_code` ASC, `status` ASC, `deleted` ASC) USING BTREE COMMENT '对象目标状态流转索引'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'RDMS对象状态流转表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of rdms_object_status_transition
-- ----------------------------
INSERT INTO `rdms_object_status_transition` VALUES (3100000001101, 'product', 'pause', '暂停', 'active', 'paused', b'1', 0, '启用转暂停', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001102, 'product', 'resume', '恢复', 'paused', 'active', b'0', 0, '暂停恢复启用', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001103, 'product', 'archive', '归档', 'active', 'archived', b'1', 0, '启用转归档', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001104, 'product', 'archive', '归档', 'paused', 'archived', b'1', 0, '暂停转归档', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001105, 'product', 'abandon', '废弃', 'active', 'abandoned', b'1', 0, '启用转废弃', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001106, 'product', 'abandon', '废弃', 'paused', 'abandoned', b'1', 0, '暂停转废弃', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001301, 'product_requirement', 'claim', '认领', 'pending_confirm', 'pending_process', b'0', 0, '待确认转待处理', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001302, 'product_requirement', 'reject', '拒绝', 'pending_confirm', 'rejected', b'1', 0, '待确认转已拒绝', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001303, 'product_requirement', 'cancel', '取消', 'pending_confirm', 'canceled', b'1', 0, '待确认转已取消', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001304, 'product_requirement', 'to_review', '转待评审', 'pending_process', 'pending_review', b'0', 0, '待处理转待评审', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001305, 'product_requirement', 'to_dispatch', '转待分流', 'pending_process', 'pending_dispatch', b'0', 0, '待处理转待分流', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001306, 'product_requirement', 'reject', '拒绝', 'pending_process', 'rejected', b'1', 0, '待处理转已拒绝', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001307, 'product_requirement', 'cancel', '取消', 'pending_process', 'canceled', b'1', 0, '待处理转已取消', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001308, 'product_requirement', 'to_dispatch', '转待分流', 'pending_review', 'pending_dispatch', b'0', 0, '待评审转待分流', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001309, 'product_requirement', 'reject', '拒绝', 'pending_review', 'rejected', b'1', 0, '待评审转已拒绝', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001310, 'product_requirement', 'cancel', '取消', 'pending_review', 'canceled', b'1', 0, '待评审转已取消', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001311, 'product_requirement', 'dispatch', '分流', 'pending_dispatch', 'dispatched', b'0', 0, '待分流转已分流', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001312, 'product_requirement', 'cancel', '取消', 'pending_dispatch', 'canceled', b'1', 0, '待分流转已取消', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001313, 'product_requirement', 'start_execution', '开始实施', 'dispatched', 'in_progress', b'0', 0, '已分流转实施中', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001314, 'product_requirement', 'cancel', '取消', 'dispatched', 'canceled', b'1', 0, '已分流转已取消', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001315, 'product_requirement', 'accept', '验收', 'in_progress', 'accepted', b'0', 0, '实施中转已验收', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001316, 'product_requirement', 'cancel', '取消', 'in_progress', 'canceled', b'1', 0, '实施中转已取消', '', NOW(), '', NOW(), b'0');
INSERT INTO `rdms_object_status_transition` VALUES (3100000001317, 'product_requirement', 'close', '关闭', 'accepted', 'closed', b'1', 0, '已验收转已关闭', '', NOW(), '', NOW(), b'0');
-- ----------------------------
-- Table structure for rdms_product
-- ----------------------------
DROP TABLE IF EXISTS `rdms_product`;
CREATE TABLE `rdms_product` (
`id` bigint NOT NULL COMMENT '主键ID',
`code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '产品编码格式CNPDYYYYNNN支持手工录入或系统自动生成',
`direction_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '产品方向字典值system_dict_data.value推荐字典类型 rdms_product_direction',
`status_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'active' COMMENT '产品状态编码(引用 rdms_object_status_model.status_codeobject_type = product',
`name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '产品名称',
`manager_user_id` bigint NOT NULL COMMENT '当前产品经理用户ID冗余读模型字段权威来源仍为 rdms_user_object_role',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '产品描述',
`last_status_reason` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '最近一次状态动作原因',
`remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注',
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uk_rdms_product_code_deleted`(`code` ASC, `deleted` ASC) USING BTREE COMMENT '产品编码未删除范围唯一',
UNIQUE INDEX `uk_rdms_product_name_deleted`(`name` ASC, `deleted` ASC) USING BTREE COMMENT '产品名称未删除范围唯一',
INDEX `idx_rdms_product_direction_status_code_deleted`(`direction_code` ASC, `status_code` ASC, `deleted` ASC) USING BTREE COMMENT '产品方向状态索引',
INDEX `idx_rdms_product_manager_status_code_deleted`(`manager_user_id` ASC, `status_code` ASC, `deleted` ASC) USING BTREE COMMENT '产品经理状态索引',
INDEX `idx_rdms_product_status_code_deleted`(`status_code` ASC, `deleted` ASC) USING BTREE COMMENT '产品状态索引',
INDEX `idx_rdms_product_update_time`(`update_time` ASC) USING BTREE COMMENT '更新时间索引'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '产品主表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for rdms_product_rd_order
-- ----------------------------
DROP TABLE IF EXISTS `rdms_product_rd_order`;
CREATE TABLE `rdms_product_rd_order` (
`id` bigint NOT NULL COMMENT '主键ID',
`product_id` bigint NOT NULL COMMENT '产品ID',
`order_year` int NOT NULL COMMENT '研发令号年度',
`rd_order_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '研发令号',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '状态0有效 1失效',
`remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注',
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uk_rdms_product_rd_order_product_year_deleted`(`product_id` ASC, `order_year` ASC, `deleted` ASC) USING BTREE COMMENT '同一产品同一年度未删除范围唯一',
INDEX `idx_rdms_product_rd_order_product_status_deleted`(`product_id` ASC, `status` ASC, `deleted` ASC) USING BTREE COMMENT '产品研发令号状态索引',
INDEX `idx_rdms_product_rd_order_no_deleted`(`rd_order_no` ASC, `deleted` ASC) USING BTREE COMMENT '研发令号检索索引',
INDEX `idx_rdms_product_rd_order_year_deleted`(`order_year` ASC, `deleted` ASC) USING BTREE COMMENT '研发令年度索引'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '产品研发令号表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for rdms_product_requirement
-- ----------------------------
DROP TABLE IF EXISTS `rdms_product_requirement`;
CREATE TABLE `rdms_product_requirement` (
`id` bigint NOT NULL COMMENT '主键ID',
`product_id` bigint NOT NULL COMMENT '所属产品ID',
`title` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '需求标题',
`category` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '需求分类',
`source_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '需求来源类型manual、work_order',
`source_biz_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '来源业务类型work_order',
`source_biz_id` bigint NULL DEFAULT NULL COMMENT '来源业务ID',
`source_biz_code` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '来源业务编号',
`root_requirement_id` bigint NULL DEFAULT NULL COMMENT '源头需求ID同一来源链路根节点',
`parent_requirement_id` bigint NULL DEFAULT NULL COMMENT '直接父需求ID拆分子需求回指父需求',
`priority` tinyint NOT NULL DEFAULT 1 COMMENT '优先级0低 1中 2高 3紧急',
`status_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'pending_process' COMMENT '需求状态编码(引用 rdms_object_status_model.status_codeobject_type = product_requirement',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '需求描述',
`proposer_id` bigint NOT NULL COMMENT '提出人用户ID',
`current_handler_user_id` bigint NULL DEFAULT NULL COMMENT '当前处理人用户ID',
`implement_project_id` bigint NULL DEFAULT NULL COMMENT '默认实现项目ID',
`terminal_action_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '终态动作编码reject、cancel、close',
`terminal_reason` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '终态原因',
`terminal_time` datetime NULL DEFAULT NULL COMMENT '终态时间',
`sort` int NOT NULL DEFAULT 0 COMMENT '排序值',
`remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注',
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_rdms_product_requirement_product_status_deleted`(`product_id` ASC, `status_code` ASC, `deleted` ASC) USING BTREE COMMENT '产品需求状态索引',
INDEX `idx_rdms_product_requirement_product_source_deleted`(`product_id` ASC, `source_type` ASC, `deleted` ASC) USING BTREE COMMENT '产品需求来源索引',
INDEX `idx_rdms_product_requirement_product_priority_deleted`(`product_id` ASC, `priority` ASC, `deleted` ASC) USING BTREE COMMENT '产品需求优先级索引',
INDEX `idx_rdms_product_requirement_source_biz_deleted`(`source_biz_type` ASC, `source_biz_id` ASC, `deleted` ASC) USING BTREE COMMENT '来源业务索引',
INDEX `idx_rdms_product_requirement_root_deleted`(`root_requirement_id` ASC, `deleted` ASC) USING BTREE COMMENT '源头需求索引',
INDEX `idx_rdms_product_requirement_parent_deleted`(`parent_requirement_id` ASC, `deleted` ASC) USING BTREE COMMENT '父需求索引',
INDEX `idx_rdms_product_requirement_handler_deleted`(`current_handler_user_id` ASC, `deleted` ASC) USING BTREE COMMENT '当前处理人索引',
INDEX `idx_rdms_product_requirement_terminal_action_deleted`(`terminal_action_code` ASC, `deleted` ASC) USING BTREE COMMENT '终态动作索引',
INDEX `idx_rdms_product_requirement_implement_project_deleted`(`implement_project_id` ASC, `deleted` ASC) USING BTREE COMMENT '默认实现项目索引'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '产品需求表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for rdms_product_status_log
-- ----------------------------
DROP TABLE IF EXISTS `rdms_product_status_log`;
CREATE TABLE `rdms_product_status_log` (
`id` bigint NOT NULL COMMENT '主键ID',
`product_id` bigint NOT NULL COMMENT '产品ID',
`action_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '动作类型pause、resume、archive、abandon、delete',
`from_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '变更前状态编码',
`to_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '变更后状态编码',
`reason` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '动作原因',
`operator_user_id` bigint NOT NULL COMMENT '操作人用户ID',
`operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '操作人名称快照',
`product_code_snapshot` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '产品编码快照',
`product_name_snapshot` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '产品名称快照',
`remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注',
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_rdms_product_status_log_product_deleted`(`product_id` ASC, `deleted` ASC) USING BTREE COMMENT '产品状态日志索引',
INDEX `idx_rdms_product_status_log_operator_deleted`(`operator_user_id` ASC, `deleted` ASC) USING BTREE COMMENT '操作人索引',
INDEX `idx_rdms_product_status_log_create_time`(`create_time` ASC) USING BTREE COMMENT '创建时间索引'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '产品状态日志表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for rdms_user_object_role
-- ----------------------------
DROP TABLE IF EXISTS `rdms_user_object_role`;
CREATE TABLE `rdms_user_object_role` (
`id` bigint NOT NULL COMMENT '主键ID',
`user_id` bigint NOT NULL COMMENT '用户ID',
`object_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '对象类型product、project',
`object_id` bigint NOT NULL COMMENT '对象ID',
`role_id` bigint NOT NULL COMMENT '对象角色ID指向 system_role.id要求 scope_type = object',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '状态0有效 1失效成员关系是否有效的唯一判定字段',
`joined_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间',
`left_time` datetime NULL DEFAULT NULL COMMENT '退出时间,仅用于留痕,不参与权限判断',
`remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '备注',
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uk_rdms_user_object_role_user_object_deleted`(`user_id` ASC, `object_type` ASC, `object_id` ASC, `deleted` ASC) USING BTREE COMMENT '用户对象关系未删除范围唯一',
INDEX `idx_rdms_user_object_role_object_status_deleted`(`object_type` ASC, `object_id` ASC, `status` ASC, `deleted` ASC) USING BTREE COMMENT '对象成员索引',
INDEX `idx_rdms_user_object_role_role_deleted`(`role_id` ASC, `deleted` ASC) USING BTREE COMMENT '对象角色索引',
INDEX `idx_rdms_user_object_role_user_deleted`(`user_id` ASC, `deleted` ASC) USING BTREE COMMENT '用户索引'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'RDMS对象成员角色关系表' ROW_FORMAT = DYNAMIC;
SET FOREIGN_KEY_CHECKS = 1;

View File

@@ -0,0 +1,86 @@
package com.njcn.rdms.module.project.constant;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Set;
/**
* 对象动态常量
*
* 说明:
* 1. 当前先承载产品对象首页时间线使用的 activityType / actionType 常量
* 2. 后续项目等对象复用同类动态能力时,继续按前缀分组扩展,不单独拆分枚举
*/
public final class ObjectActivityConstants {
private ObjectActivityConstants() {
}
// ========== 动态来源类型 ==========
public static final String ACTIVITY_TYPE_STATUS = "status";
public static final String ACTIVITY_TYPE_PRODUCT = "product";
public static final String ACTIVITY_TYPE_MEMBER = "member";
// ========== 审计业务类型 ==========
public static final String PRODUCT_BIZ_TYPE = "product";
public static final String MEMBER_BIZ_TYPE = "rdms_user_object_role";
// ========== 产品对象动作 ==========
public static final String PRODUCT_ACTION_CREATE = "create";
public static final String PRODUCT_ACTION_UPDATE = "update";
public static final String PRODUCT_ACTION_DELETE = "delete";
public static final String PRODUCT_ACTION_CHANGE_MANAGER = "change_manager";
// ========== 状态动作 ==========
public static final String STATUS_ACTION_PAUSE = "pause";
public static final String STATUS_ACTION_RESUME = "resume";
public static final String STATUS_ACTION_ARCHIVE = "archive";
public static final String STATUS_ACTION_ABANDON = "abandon";
// ========== 成员动作 ==========
public static final String MEMBER_ACTION_ADD = "add_member";
public static final String MEMBER_ACTION_UPDATE = "update_member";
public static final String MEMBER_ACTION_REMOVE = "remove_member";
public static final List<String> STATUS_ACTION_TYPES = List.of(
STATUS_ACTION_PAUSE, STATUS_ACTION_RESUME, STATUS_ACTION_ARCHIVE, STATUS_ACTION_ABANDON);
public static final List<String> PRODUCT_TIMELINE_ACTION_TYPES = List.of(
PRODUCT_ACTION_CREATE, PRODUCT_ACTION_CHANGE_MANAGER);
public static final List<String> MEMBER_TIMELINE_ACTION_TYPES = List.of(
MEMBER_ACTION_ADD, MEMBER_ACTION_UPDATE, MEMBER_ACTION_REMOVE);
private static final Set<String> STATUS_ACTION_TYPE_SET = Set.copyOf(STATUS_ACTION_TYPES);
public static boolean isStatusAction(String actionType) {
return STATUS_ACTION_TYPE_SET.contains(normalize(actionType));
}
public static String resolveActionName(String actionType) {
String normalizedActionType = normalize(actionType);
if (!StringUtils.hasText(normalizedActionType)) {
return actionType;
}
return switch (normalizedActionType) {
case PRODUCT_ACTION_CREATE -> "创建";
case PRODUCT_ACTION_UPDATE -> "更新";
case PRODUCT_ACTION_DELETE -> "删除";
case STATUS_ACTION_PAUSE -> "暂停";
case STATUS_ACTION_RESUME -> "恢复";
case STATUS_ACTION_ARCHIVE -> "归档";
case STATUS_ACTION_ABANDON -> "废弃";
case PRODUCT_ACTION_CHANGE_MANAGER -> "切换产品经理";
case MEMBER_ACTION_ADD -> "新增成员";
case MEMBER_ACTION_UPDATE -> "调整成员";
case MEMBER_ACTION_REMOVE -> "移出成员";
default -> normalizedActionType;
};
}
private static String normalize(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
}

View File

@@ -3,6 +3,7 @@ package com.njcn.rdms.module.project.controller.admin.product;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductRespVO;
@@ -39,7 +40,6 @@ public class ProductController {
@PutMapping("/update")
@Operation(summary = "更新产品")
@PreAuthorize("@ss.hasPermission('project:product:update')")
public CommonResult<Boolean> updateProduct(@Valid @RequestBody ProductSaveReqVO updateReqVO) {
productService.updateProduct(updateReqVO);
return success(true);
@@ -48,12 +48,18 @@ public class ProductController {
@GetMapping("/get")
@Operation(summary = "获取产品详情")
@Parameter(name = "id", description = "产品编号", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('project:product:query')")
public CommonResult<ProductRespVO> getProduct(@RequestParam("id") Long id) {
ProductDO product = productService.getProduct(id);
return success(BeanUtils.toBean(product, ProductRespVO.class));
}
@GetMapping("/{id}/context")
@Operation(summary = "获取产品上下文")
@Parameter(name = "id", description = "产品编号", required = true, example = "1024")
public CommonResult<ProductContextRespVO> getProductContext(@PathVariable("id") Long id) {
return success(productService.getProductContext(id));
}
@GetMapping("/page")
@Operation(summary = "获取产品分页")
@PreAuthorize("@ss.hasPermission('project:product:query')")
@@ -64,7 +70,6 @@ public class ProductController {
@PostMapping("/change-status")
@Operation(summary = "变更产品状态")
@PreAuthorize("@ss.hasPermission('project:product:status')")
public CommonResult<Boolean> changeProductStatus(@Valid @RequestBody ProductStatusActionReqVO reqVO) {
productService.changeProductStatus(reqVO);
return success(true);
@@ -72,7 +77,6 @@ public class ProductController {
@PostMapping("/delete")
@Operation(summary = "删除产品")
@PreAuthorize("@ss.hasPermission('project:product:delete')")
public CommonResult<Boolean> deleteProduct(@Valid @RequestBody ProductDeleteReqVO reqVO) {
productService.deleteProduct(reqVO);
return success(true);

View File

@@ -0,0 +1,63 @@
package com.njcn.rdms.module.project.controller.admin.product;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberUpdateReqVO;
import com.njcn.rdms.module.project.service.product.ProductMemberService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - 产品团队")
@RestController
@RequestMapping("/project/product")
@Validated
public class ProductMemberController {
@Resource
private ProductMemberService productMemberService;
@GetMapping("/{id}/members")
@Operation(summary = "获取产品团队成员列表")
@Parameter(name = "id", description = "产品编号", required = true, example = "1024")
public CommonResult<List<ProductMemberRespVO>> getProductMemberList(@PathVariable("id") Long productId) {
return success(productMemberService.getProductMemberList(productId));
}
@PostMapping("/{id}/members")
@Operation(summary = "新增产品团队成员")
@Parameter(name = "id", description = "产品编号", required = true, example = "1024")
public CommonResult<Long> createProductMember(@PathVariable("id") Long productId,
@Valid @RequestBody ProductMemberSaveReqVO reqVO) {
return success(productMemberService.createProductMember(productId, reqVO));
}
@PutMapping("/{id}/members/{memberId}")
@Operation(summary = "调整产品团队成员角色")
public CommonResult<Boolean> updateProductMember(@PathVariable("id") Long productId,
@PathVariable("memberId") Long memberId,
@Valid @RequestBody ProductMemberUpdateReqVO reqVO) {
productMemberService.updateProductMember(productId, memberId, reqVO);
return success(true);
}
@PostMapping("/{id}/members/{memberId}/inactive")
@Operation(summary = "移出产品团队成员")
public CommonResult<Boolean> inactiveProductMember(@PathVariable("id") Long productId,
@PathVariable("memberId") Long memberId,
@Valid @RequestBody ProductMemberInactiveReqVO reqVO) {
productMemberService.inactiveProductMember(productId, memberId, reqVO);
return success(true);
}
}

View File

@@ -0,0 +1,157 @@
package com.njcn.rdms.module.project.controller.admin.product;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.product.vo.requirement.*;
import com.njcn.rdms.module.project.service.product.ProductRequirementService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
/**
* 管理后台 - 产品需求控制器
*/
@Tag(name = "管理后台 - 产品需求")
@RestController
@RequestMapping("/project/product/requirement")
@Validated
public class ProductRequirementController {
@Resource
private ProductRequirementService requirementService;
// ========== 需求管理 ==========
@PostMapping("/create")
@Operation(summary = "创建产品需求")
public CommonResult<Long> createRequirement(@Valid @RequestBody ProductRequirementSaveReqVO createReqVO) {
return success(requirementService.createRequirement(createReqVO));
}
@PutMapping("/update")
@Operation(summary = "更新产品需求")
public CommonResult<Boolean> updateRequirement(@Valid @RequestBody ProductRequirementUpdateReqVO updateReqVO) {
requirementService.updateRequirement(updateReqVO);
return success(true);
}
@GetMapping("/get")
@Operation(summary = "获取需求详情")
@Parameter(name = "id", description = "需求编号", required = true, example = "1024")
@Parameter(name = "productId", description = "产品编号", required = true, example = "1024")
public CommonResult<ProductRequirementRespVO> getRequirement(@RequestParam("id") Long id,
@RequestParam("productId") Long productId) {
return success(requirementService.getRequirement(id, productId));
}
@GetMapping("/page")
@Operation(summary = "获取需求分页列表")
public CommonResult<PageResult<ProductRequirementRespVO>> getRequirementPage(@Valid ProductRequirementPageReqVO pageReqVO) {
return success(requirementService.getRequirementPage(pageReqVO));
}
@GetMapping("/tree")
@Operation(summary = "获取需求树形列表(分页)")
public CommonResult<PageResult<ProductRequirementRespVO>> getRequirementTree(@Valid ProductRequirementPageReqVO pageReqVO) {
return success(requirementService.getRequirementTree(pageReqVO));
}
@PostMapping("/change-status")
@Operation(summary = "变更需求状态")
public CommonResult<Boolean> changeRequirementStatus(@Valid @RequestBody ProductRequirementStatusActionReqVO reqVO) {
requirementService.changeRequirementStatus(reqVO);
return success(true);
}
@PostMapping("/delete")
@Operation(summary = "删除产品需求")
public CommonResult<Boolean> deleteRequirement(@Valid @RequestBody ProductRequirementDeleteReqVO reqVO) {
requirementService.deleteRequirement(reqVO.getId(), reqVO.getProductId());
return success(true);
}
@PostMapping("/split")
@Operation(summary = "拆分产品需求")
public CommonResult<Long> splitRequirement(@Valid @RequestBody ProductRequirementSplitReqVO reqVO) {
System.out.println("-----------------------");
System.out.println(reqVO);
return success(requirementService.splitRequirement(reqVO));
}
@PostMapping("/close")
@Operation(summary = "关闭产品需求")
public CommonResult<Boolean> closeRequirement(@Valid @RequestBody ProductRequirementCloseReqVO reqVO) {
requirementService.closeRequirement(reqVO);
return success(true);
}
@GetMapping("/allowed-transitions")
@Operation(summary = "获取需求可执行的状态动作列表")
@Parameter(name = "requirementId", description = "需求编号", required = true, example = "1024")
@Parameter(name = "productId", description = "产品编号", required = true, example = "1024")
public CommonResult<List<ProductRequirementStatusTransitionRespVO>> getAllowedTransitions(
@RequestParam("requirementId") Long requirementId,
@RequestParam("productId") Long productId) {
return success(requirementService.getAllowedTransitions(requirementId, productId));
}
@GetMapping("/lifecycle")
@Operation(summary = "获取需求生命周期信息")
@Parameter(name = "requirementId", description = "需求编号", required = true, example = "1024")
@Parameter(name = "productId", description = "产品编号", required = true, example = "1024")
public CommonResult<ProductRequirementLifecycleRespVO> getRequirementLifecycle(
@RequestParam("requirementId") Long requirementId,
@RequestParam("productId") Long productId) {
return success(requirementService.getRequirementLifecycle(requirementId, productId));
}
// ========== 模块管理 ==========
@PostMapping("/module/create")
@Operation(summary = "创建需求模块")
public CommonResult<Long> createRequirementModule(@Valid @RequestBody ProductRequirementModuleReqVO reqVO) {
return success(requirementService.createRequirementModule(reqVO));
}
@PutMapping("/module/update")
@Operation(summary = "更新需求模块")
public CommonResult<Boolean> updateRequirementModule(@Valid @RequestBody ProductRequirementModuleReqVO reqVO) {
requirementService.updateRequirementModule(reqVO);
return success(true);
}
@PostMapping("/module/delete")
@Operation(summary = "删除需求模块")
public CommonResult<Boolean> deleteRequirementModule(@Valid @RequestBody ProductRequirementModuleDeleteReqVO reqVO) {
requirementService.deleteRequirementModule(reqVO.getId(), reqVO.getProductId());
return success(true);
}
@GetMapping("/module/tree")
@Operation(summary = "获取需求模块树")
@Parameter(name = "productId", description = "产品编号", required = true, example = "1024")
public CommonResult<List<ProductRequirementModuleRespVO>> getRequirementModuleTree(@RequestParam("productId") Long productId) {
return success(requirementService.getRequirementModuleTree(productId));
}
@GetMapping("/status/dict")
@Operation(summary = "获取需求所有状态字典")
public CommonResult<List<ProductRequirementStatusDictRespVO>> getRequirementStatusDict() {
return success(requirementService.getRequirementStatusDict());
}
@GetMapping("/status/dict/terminal")
@Operation(summary = "获取需求终止态状态字典")
public CommonResult<List<ProductRequirementStatusDictRespVO>> getRequirementTerminalStatusDict() {
return success(requirementService.getRequirementTerminalStatusDict());
}
}

View File

@@ -0,0 +1,71 @@
package com.njcn.rdms.module.project.controller.admin.product;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityPageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelinePageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelineRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingBaseInfoUpdateReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingRespVO;
import com.njcn.rdms.module.project.service.product.ProductService;
import com.njcn.rdms.module.project.service.product.ProductSettingService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - 产品设置")
@RestController
@RequestMapping("/project/product")
@Validated
public class ProductSettingController {
@Resource
private ProductSettingService productSettingService;
@Resource
private ProductService productService;
@GetMapping("/{id}/settings")
@Operation(summary = "获取产品设置")
@Parameter(name = "id", description = "产品编号", required = true, example = "1024")
public CommonResult<ProductSettingRespVO> getProductSettings(@PathVariable("id") Long id) {
return success(productSettingService.getProductSettings(id));
}
@GetMapping("/{id}/activities")
@Operation(summary = "获取产品动态")
@Parameter(name = "id", description = "产品编号", required = true, example = "1024")
public CommonResult<PageResult<ProductActivityRespVO>> getProductActivities(@PathVariable("id") Long id,
@Valid ProductActivityPageReqVO reqVO) {
return success(productSettingService.getProductActivities(id, reqVO));
}
@GetMapping("/{id}/activities/page")
@Operation(summary = "获取产品动态时间线分页")
@Parameter(name = "id", description = "产品编号", required = true, example = "1024")
public CommonResult<PageResult<ProductActivityTimelineRespVO>> getProductActivityTimelinePage(
@PathVariable("id") Long id, @Valid ProductActivityTimelinePageReqVO reqVO) {
return success(productSettingService.getProductActivityTimelinePage(id, reqVO));
}
@PutMapping("/{id}/settings/base-info")
@Operation(summary = "更新产品设置基础信息")
@Parameter(name = "id", description = "产品编号", required = true, example = "1024")
public CommonResult<Boolean> updateProductBaseInfo(@PathVariable("id") Long id,
@Valid @RequestBody ProductSettingBaseInfoUpdateReqVO reqVO) {
productService.updateProductBaseInfo(id, reqVO);
return success(true);
}
}

View File

@@ -0,0 +1,31 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.activity;
import com.njcn.rdms.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - 产品动态分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class ProductActivityPageReqVO extends PageParam {
@Schema(description = "动态类型", example = "status")
@Size(max = 16, message = "动态类型长度不能超过16个字符")
private String activityType;
@Schema(description = "动作编码", example = "pause")
@Size(max = 32, message = "动作编码长度不能超过32个字符")
private String actionType;
@Schema(description = "操作时间区间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] operateTime;
}

View File

@@ -0,0 +1,45 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.activity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - 产品动态 Response VO")
@Data
public class ProductActivityRespVO {
@Schema(description = "动态类型", example = "status")
private String type;
@Schema(description = "动作编码", example = "pause")
private String actionType;
@Schema(description = "动作名称", example = "暂停")
private String actionName;
@Schema(description = "原状态", example = "active")
private String fromStatus;
@Schema(description = "目标状态", example = "paused")
private String toStatus;
@Schema(description = "动作原因", example = "资源不足")
private String reason;
@Schema(description = "操作人用户编号", example = "1024")
private Long operatorUserId;
@Schema(description = "操作人名称", example = "张三")
private String operatorName;
@Schema(description = "操作时间", example = "2026-04-21 12:00:00")
private LocalDateTime operateTime;
@Schema(description = "展示摘要", example = "张三执行了【暂停】:资源不足")
private String summary;
@Schema(description = "补充详情")
private String details;
}

View File

@@ -0,0 +1,35 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.activity;
import com.njcn.rdms.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import java.util.List;
import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - 产品动态时间线分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class ProductActivityTimelinePageReqVO extends PageParam {
@Schema(description = "动态类型", example = "status")
@Size(max = 16, message = "动态类型长度不能超过16个字符")
private String activityType;
@Schema(description = "动作编码数组")
private List<@Size(max = 32, message = "动作编码长度不能超过32个字符") String> actionTypes;
@Schema(description = "开始时间", example = "2026-03-24 00:00:00")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime startTime;
@Schema(description = "结束时间", example = "2026-04-23 23:59:59")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime endTime;
}

View File

@@ -0,0 +1,54 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.activity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - 产品动态时间线 Response VO")
@Data
public class ProductActivityTimelineRespVO {
@Schema(description = "动态唯一标识", example = "status:11")
private String id;
@Schema(description = "动态类型", example = "status")
private String type;
@Schema(description = "动作编码", example = "pause")
private String actionType;
@Schema(description = "动作名称", example = "暂停")
private String actionName;
@Schema(description = "操作人用户编号", example = "1024")
private Long operatorUserId;
@Schema(description = "操作人名称", example = "张三")
private String operatorName;
@Schema(description = "目标成员用户编号,仅 member 类型返回", example = "2043945809271713793")
private Long targetUserId;
@Schema(description = "目标成员名称,仅 member 类型返回,读取缓存实时转换", example = "张三")
private String targetUserName;
@Schema(description = "发生时间", example = "2026-04-21 12:00:00")
private LocalDateTime occurredAt;
@Schema(description = "展示摘要", example = "张三执行了【暂停】:资源不足")
private String summary;
@Schema(description = "动作原因", example = "资源不足")
private String reason;
@Schema(description = "原状态", example = "active")
private String fromStatus;
@Schema(description = "目标状态", example = "paused")
private String toStatus;
@Schema(description = "补充详情")
private String details;
}

View File

@@ -0,0 +1,15 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.member;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - 产品团队成员移出 Request VO")
@Data
public class ProductMemberInactiveReqVO {
@Schema(description = "移出原因", example = "已退出当前产品协作")
@Size(max = 500, message = "移出原因长度不能超过500个字符")
private String reason;
}

View File

@@ -0,0 +1,45 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.member;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - 产品团队成员 Response VO")
@Data
public class ProductMemberRespVO {
@Schema(description = "团队关系编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long id;
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
private Long userId;
@Schema(description = "用户昵称", example = "小王")
private String userNickname;
@Schema(description = "角色编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3100000002001")
private Long roleId;
@Schema(description = "角色名称", example = "产品经理")
private String roleName;
@Schema(description = "角色编码", example = "product_manager")
private String roleCode;
@Schema(description = "是否当前产品经理", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
private Boolean managerFlag;
@Schema(description = "状态0有效 1失效", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
private Integer status;
@Schema(description = "加入时间")
private LocalDateTime joinedTime;
@Schema(description = "退出时间")
private LocalDateTime leftTime;
@Schema(description = "备注", example = "当前负责需求收敛")
private String remark;
}

View File

@@ -0,0 +1,30 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.member;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - 产品团队成员新增 Request VO")
@Data
public class ProductMemberSaveReqVO {
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "用户编号不能为空")
private Long userId;
@Schema(description = "角色编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3100000002001")
@NotNull(message = "角色编号不能为空")
private Long roleId;
@Schema(description = "备注", example = "来自产品团队维护")
@Size(max = 500, message = "备注长度不能超过500个字符")
private String remark;
@Schema(description = "原产品经理用户编号,仅切换产品经理时传递", example = "2048")
private Long previousManagerUserId;
@Schema(description = "原产品经理交接后的角色编号,仅切换产品经理时传递", example = "3100000002002")
private Long previousManagerRoleId;
}

View File

@@ -0,0 +1,30 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.member;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - 产品团队成员更新 Request VO")
@Data
public class ProductMemberUpdateReqVO {
@Schema(description = "角色编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3100000002002")
@NotNull(message = "角色编号不能为空")
private Long roleId;
@Schema(description = "备注", example = "调整为产品观察者")
@Size(max = 500, message = "备注长度不能超过500个字符")
private String remark;
@Schema(description = "变更原因", example = "职责调整")
@Size(max = 500, message = "变更原因长度不能超过500个字符")
private String reason;
@Schema(description = "原产品经理用户编号,仅切换产品经理时传递", example = "2048")
private Long previousManagerUserId;
@Schema(description = "原产品经理交接后的角色编号,仅切换产品经理时传递", example = "3100000002002")
private Long previousManagerRoleId;
}

View File

@@ -0,0 +1,25 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.product;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - 产品上下文导航 Response VO")
@Data
public class ProductContextNavRespVO {
@Schema(description = "菜单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3201")
private Long id;
@Schema(description = "菜单名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "概览")
private String name;
@Schema(description = "菜单路径", example = "/project/product/overview")
private String path;
@Schema(description = "菜单图标", example = "mdi:view-dashboard-outline")
private String icon;
@Schema(description = "显示顺序", example = "10")
private Integer sort;
}

View File

@@ -0,0 +1,28 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.product;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - 产品上下文中的当前产品摘要 Response VO")
@Data
public class ProductContextProductRespVO {
@Schema(description = "产品编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long id;
@Schema(description = "产品编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "CNPD2026001")
private String code;
@Schema(description = "产品方向字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "direction_value")
private String directionCode;
@Schema(description = "产品名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "RDMS产品平台")
private String name;
@Schema(description = "产品经理用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long managerUserId;
@Schema(description = "产品状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "active")
private String statusCode;
}

View File

@@ -0,0 +1,26 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.product;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Schema(description = "管理后台 - 产品上下文 Response VO")
@Data
@NoArgsConstructor
public class ProductContextRespVO {
@Schema(description = "当前产品摘要")
private ProductContextProductRespVO currentProduct;
@Schema(description = "当前用户在该产品下的角色信息")
private ProductContextRoleRespVO currentRole;
@Schema(description = "当前产品下可见导航集合")
private List<ProductContextNavRespVO> navs;
@Schema(description = "当前产品下按钮权限码集合")
private List<String> buttons;
}

View File

@@ -0,0 +1,19 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.product;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - 产品上下文中的当前角色 Response VO")
@Data
public class ProductContextRoleRespVO {
@Schema(description = "对象角色编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3201")
private Long roleId;
@Schema(description = "对象角色编码", example = "product_manager")
private String roleCode;
@Schema(description = "对象角色名称", example = "产品经理")
private String roleName;
}

View File

@@ -24,4 +24,9 @@ public class ProductDeleteReqVO {
@Size(max = 500, message = "删除原因长度不能超过500个字符")
private String reason;
@Schema(description = "删除确认口令,当前固定输入 DELETE", requiredMode = Schema.RequiredMode.REQUIRED, example = "DELETE")
@NotBlank(message = "删除确认口令不能为空")
@Size(max = 32, message = "删除确认口令长度不能超过32个字符")
private String confirmText;
}

View File

@@ -2,7 +2,7 @@ package com.njcn.rdms.module.project.controller.admin.product.vo.product;
import com.njcn.rdms.framework.common.pojo.PageParam;
import com.njcn.rdms.framework.dict.validation.InDict;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
import com.njcn.rdms.module.system.enums.DictTypeConstants;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
@@ -21,8 +21,8 @@ public class ProductPageReqVO extends PageParam {
@Schema(description = "关键词,匹配产品编码或产品名称", example = "CNPD2026001")
private String keyword;
@Schema(description = "产品方向字典值", example = "embedded")
@InDict(type = ProjectDictTypeConstants.PRODUCT_DIRECTION)
@Schema(description = "产品方向字典值", example = "direction_value")
@InDict(type = DictTypeConstants.OBJECT_DIRECTION)
private String directionCode;
@Schema(description = "产品经理用户编号", example = "1024")

View File

@@ -15,7 +15,7 @@ public class ProductRespVO {
@Schema(description = "产品编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "CNPD2026001")
private String code;
@Schema(description = "产品方向字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "embedded")
@Schema(description = "产品方向字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "direction_value")
private String directionCode;
@Schema(description = "产品名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "RDMS产品平台")
@@ -33,9 +33,6 @@ public class ProductRespVO {
@Schema(description = "最近一次状态动作原因", example = "阶段性暂停")
private String lastStatusReason;
@Schema(description = "备注", example = "预留")
private String remark;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;

View File

@@ -1,7 +1,7 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.product;
import com.njcn.rdms.framework.dict.validation.InDict;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
import com.njcn.rdms.module.system.enums.DictTypeConstants;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@@ -19,10 +19,10 @@ public class ProductSaveReqVO {
@Size(max = 64, message = "产品编码长度不能超过64个字符")
private String code;
@Schema(description = "产品方向字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "embedded")
@Schema(description = "产品方向字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "direction_value")
@NotBlank(message = "产品方向不能为空")
@Size(max = 32, message = "产品方向长度不能超过32个字符")
@InDict(type = ProjectDictTypeConstants.PRODUCT_DIRECTION)
@InDict(type = DictTypeConstants.OBJECT_DIRECTION)
private String directionCode;
@Schema(description = "产品名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "RDMS产品平台")
@@ -37,8 +37,4 @@ public class ProductSaveReqVO {
@Schema(description = "产品描述", example = "面向研发管理的一体化产品")
private String description;
@Schema(description = "备注", example = "预留")
@Size(max = 500, message = "备注长度不能超过500个字符")
private String remark;
}

View File

@@ -0,0 +1,29 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.requirement;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 管理后台 - 产品需求关闭 Request VO
*/
@Schema(description = "管理后台 - 产品需求关闭 Request VO")
@Data
public class ProductRequirementCloseReqVO {
@Schema(description = "需求编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "需求编号不能为空")
private Long id;
@Schema(description = "所属产品ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "产品ID不能为空")
private Long productId;
@Schema(description = "关闭原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "需求已完成验收")
@NotBlank(message = "关闭原因不能为空")
@Size(max = 255, message = "关闭原因长度不能超过255个字符")
private String reason;
}

View File

@@ -0,0 +1,19 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.requirement;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 管理后台 - 产品需求删除 Request VO
*/
@Schema(description = "管理后台 - 产品需求删除 Request VO")
@Data
public class ProductRequirementDeleteReqVO {
@Schema(description = "需求ID编辑时传入", example = "1024")
private Long id;
@Schema(description = "所属产品ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "产品ID不能为空")
private Long productId;
}

View File

@@ -0,0 +1,33 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.requirement;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/**
* 管理后台 - 产品需求生命周期 Response VO
*/
@Schema(description = "管理后台 - 产品需求生命周期 Response VO")
@Data
public class ProductRequirementLifecycleRespVO {
@Schema(description = "当前状态编码", example = "pending_dispatch")
private String statusCode;
@Schema(description = "当前状态名称", example = "待分流")
private String statusName;
@Schema(description = "最近一次状态动作原因", example = "评审通过")
private String lastStatusReason;
@Schema(description = "是否终态", example = "false")
private Boolean terminal;
@Schema(description = "是否允许编辑", example = "true")
private Boolean allowEdit;
@Schema(description = "当前状态可执行动作列表")
private List<ProductRequirementStatusTransitionRespVO> availableActions;
}

View File

@@ -0,0 +1,19 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.requirement;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 管理后台 - 产品需求模块删除 Request VO
*/
@Schema(description = "管理后台 - 产品需求模块删除 Request VO")
@Data
public class ProductRequirementModuleDeleteReqVO {
@Schema(description = "模块ID编辑时传入", example = "1024")
private Long id;
@Schema(description = "所属产品ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "产品ID不能为空")
private Long productId;
}

View File

@@ -0,0 +1,40 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.requirement;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 管理后台 - 产品需求模块保存 Request VO
*/
@Schema(description = "管理后台 - 产品需求模块保存 Request VO")
@Data
public class ProductRequirementModuleReqVO {
@Schema(description = "模块ID编辑时传入", example = "1024")
private Long id;
@Schema(description = "所属产品ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "产品ID不能为空")
private Long productId;
@Schema(description = "父模块ID0表示顶级", example = "0")
private Long parentId;
@Schema(description = "模块名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "核心功能")
@NotBlank(message = "模块名称不能为空")
@Size(max = 100, message = "模块名称长度不能超过100个字符")
private String moduleName;
@Schema(description = "模块说明", example = "产品核心功能模块")
private String remark;
@Schema(description = "图标", example = "icon-function")
private String icon;
@Schema(description = "排序值", example = "0")
private Integer sort;
}

View File

@@ -0,0 +1,39 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.requirement;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/**
* 管理后台 - 产品需求模块 Response VO
*/
@Schema(description = "管理后台 - 产品需求模块 Response VO")
@Data
public class ProductRequirementModuleRespVO {
@Schema(description = "模块ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long id;
@Schema(description = "父模块ID0表示顶级", example = "0")
private Long parentId;
@Schema(description = "所属产品ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long productId;
@Schema(description = "模块名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "核心功能")
private String moduleName;
@Schema(description = "模块说明", example = "产品核心功能模块")
private String remark;
@Schema(description = "图标", example = "icon-function")
private String icon;
@Schema(description = "排序值", example = "0")
private Integer sort;
@Schema(description = "子模块列表")
private List<ProductRequirementModuleRespVO> children;
}

View File

@@ -0,0 +1,49 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.requirement;
import com.baomidou.mybatisplus.annotation.TableField;
import com.njcn.rdms.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/**
* 管理后台 - 产品需求分页 Request VO
*/
@Schema(description = "管理后台 - 产品需求分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class ProductRequirementPageReqVO extends PageParam {
@Schema(description = "所属产品ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long productId;
@Schema(description = "所属模块ID", example = "1024")
private Long moduleId;
@Schema(description = "所属模块ID列表包含子模块用于IN查询", example = "[1024, 1025]")
private List<Long> moduleIds;
@Schema(description = "父需求ID查询子需求时使用", example = "1024")
private Long parentId;
@Schema(description = "标题关键词", example = "模块")
private String title;
@Schema(description = "需求分类字典值", example = "function")
private String category;
@Schema(description = "优先级0低 1中 2高 3紧急", example = "1")
private Integer priority;
@Schema(description = "状态编码", example = "pending_dispatch")
private String statusCode;
@Schema(description = "当前处理人用户编号", example = "1024")
private Long currentHandlerUserId;
@Schema(description = "来源类型manual:手工新增, work_order:工单流转)", example = "manual")
private String sourceType;
}

View File

@@ -0,0 +1,97 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.requirement;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 管理后台 - 产品需求 Response VO
*/
@Schema(description = "管理后台 - 产品需求 Response VO")
@Data
public class ProductRequirementRespVO {
@Schema(description = "需求ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long id;
@Schema(description = "父需求ID0表示顶级需求", example = "0")
private Long parentId;
@Schema(description = "所属模块ID", example = "1024")
private Long moduleId;
@Schema(description = "是否需要评审0不需要1需要", example = "0")
private Integer reviewRequired;
@Schema(description = "需求标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "支持需求模块化管理")
private String title;
@Schema(description = "需求描述(富文本)", example = "<p>详细描述需求内容</p>")
private String description;
@Schema(description = "需求分类字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "function")
private String category;
@Schema(description = "需求分类名称", example = "功能需求")
private String categoryName;
@Schema(description = "来源类型manual:手工新增, work_order:工单流转)", requiredMode = Schema.RequiredMode.REQUIRED, example = "manual")
private String sourceType;
@Schema(description = "来源业务ID", example = "1024")
private Long sourceBizId;
@Schema(description = "优先级0低 1中 2高 3紧急", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Integer priority;
@Schema(description = "优先级名称", example = "")
private String priorityName;
@Schema(description = "当前状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "pending_dispatch")
private String statusCode;
@Schema(description = "当前状态名称", example = "待分流")
private String statusName;
@Schema(description = "最近一次状态动作原因", example = "评审通过")
private String lastStatusReason;
@Schema(description = "提出人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long proposerId;
@Schema(description = "提出人用户姓名", example = "张三")
private String proposerNickname;
@Schema(description = "当前处理人用户编号", example = "1024")
private Long currentHandlerUserId;
@Schema(description = "当前处理人姓名", example = "李四")
private String currentHandlerUserNickname;
@Schema(description = "默认实现项目编号", example = "1024")
private Long implementProjectId;
@Schema(description = "实现项目名称", example = "NPQS-10086")
private String implementProjectName;
@Schema(description = "预期完成时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime completionDate;
@Schema(description = "排序值", example = "0")
private Integer sort;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;
@Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime updateTime;
@Schema(description = "子需求列表(树形结构)")
private List<ProductRequirementRespVO> children;
@Schema(description = "是否为终态(已拒绝、已取消、已关闭)", example = "false")
private Boolean terminal;
}

View File

@@ -0,0 +1,66 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.requirement;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 管理后台 - 产品需求保存 Request VO
*/
@Schema(description = "管理后台 - 产品需求保存 Request VO")
@Data
public class ProductRequirementSaveReqVO {
@Schema(description = "需求ID", example = "1024")
private Long id;
@Schema(description = "所属产品ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "产品编号不能为空")
private Long productId;
@Schema(description = "所属模块ID为空时归入全部需求", example = "1024")
private Long moduleId;
@Schema(description = "是否需要评审0不需要1需要", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
@NotNull(message = "是否需要评审不能为空")
private Integer reviewRequired;
@Schema(description = "需求标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "支持需求模块化管理")
@NotBlank(message = "需求标题不能为空")
@Size(max = 200, message = "需求标题长度不能超过200个字符")
private String title;
@Schema(description = "需求描述(富文本)", example = "<p>详细描述需求内容</p>")
private String description;
@Schema(description = "需求分类字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "function")
@NotBlank(message = "需求分类不能为空")
@Size(max = 64, message = "需求分类长度不能超过64个字符")
private String category;
@Schema(description = "优先级0低 1中 2高 3紧急", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "优先级不能为空")
private Integer priority;
@Schema(description = "提出人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "提出人不能为空")
private Long proposerId;
@Schema(description = "当前处理人用户编号", example = "1024")
private Long currentHandlerUserId;
@Schema(description = "默认实现项目编号", example = "1024")
private Long implementProjectId;
@Schema(description = "预期完成时间", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "预期完成时间不能为空")
private LocalDateTime completionDate;
@Schema(description = "排序值(越小越靠前)", example = "0")
private Integer sort;
}

View File

@@ -0,0 +1,67 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.requirement;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 管理后台 - 产品需求拆分 Request VO
*/
@Schema(description = "管理后台 - 产品需求拆分 Request VO")
@Data
public class ProductRequirementSplitReqVO {
@Schema(description = "父需求ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "父需求编号不能为空")
private Long parentId;
@Schema(description = "所属产品ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "产品编号不能为空")
private Long productId;
@Schema(description = "所属模块ID为空时归入全部需求", example = "1024")
private Long moduleId;
@Schema(description = "是否需要评审0不需要1需要", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
@NotNull(message = "是否需要评审不能为空")
private Integer reviewRequired;
@Schema(description = "需求标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "支持需求模块化管理")
@NotBlank(message = "需求标题不能为空")
@Size(max = 200, message = "需求标题长度不能超过200个字符")
private String title;
@Schema(description = "需求描述(富文本)", example = "<p>详细描述需求内容</p>")
private String description;
@Schema(description = "需求分类字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "function")
@NotBlank(message = "需求分类不能为空")
@Size(max = 64, message = "需求分类长度不能超过64个字符")
private String category;
@Schema(description = "优先级0低 1中 2高 3紧急", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "优先级不能为空")
private Integer priority;
@Schema(description = "提出人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "提出人不能为空")
private Long proposerId;
@Schema(description = "当前处理人用户编号", example = "1024")
private Long currentHandlerUserId;
@Schema(description = "默认实现项目编号", example = "1024")
private Long implementProjectId;
@Schema(description = "预期完成时间", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "预期完成时间不能为空")
private LocalDateTime completionDate;
@Schema(description = "排序值(越小越靠前)", example = "0")
private Integer sort;
}

View File

@@ -0,0 +1,35 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.requirement;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 管理后台 - 产品需求状态变更 Request VO
* 注意:需求不直接关联产品,通过模块间接关联
*/
@Schema(description = "管理后台 - 产品需求状态变更 Request VO")
@Data
public class ProductRequirementStatusActionReqVO {
@Schema(description = "需求ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "需求ID不能为空")
private Long id;
@Schema(description = "所属产品ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "产品编号不能为空")
private Long productId;
@Schema(description = "动作编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "dispatch")
@NotBlank(message = "动作编码不能为空")
@Size(max = 32, message = "动作编码长度不能超过32个字符")
private String actionCode;
@Schema(description = "状态变更原因", example = "评审通过,进入分流阶段")
private String reason;
@Schema(description = "实现项目编号dispatch动作时可选", example = "1024")
private Long implementProjectId;
}

View File

@@ -0,0 +1,28 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.requirement;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 管理后台 - 产品需求状态字典 Response VO
*/
@Schema(description = "管理后台 - 产品需求状态字典 Response VO")
@Data
public class ProductRequirementStatusDictRespVO {
@Schema(description = "状态编码", example = "pending_confirm")
private String statusCode;
@Schema(description = "状态名称", example = "待确认")
private String statusName;
@Schema(description = "排序值", example = "1")
private Integer sort;
@Schema(description = "是否初始状态", example = "true")
private Boolean initialFlag;
@Schema(description = "是否终态", example = "false")
private Boolean terminalFlag;
}

View File

@@ -0,0 +1,30 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.requirement;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 管理后台 - 产品需求状态可执行动作 Response VO
*/
@Schema(description = "管理后台 - 产品需求状态可执行动作 Response VO")
@Data
@NoArgsConstructor
public class ProductRequirementStatusTransitionRespVO {
@Schema(description = "动作编码", example = "dispatch")
private String actionCode;
@Schema(description = "动作名称", example = "明确分流/拆分")
private String actionName;
@Schema(description = "目标状态编码", example = "implementing")
private String toStatusCode;
@Schema(description = "目标状态名称", example = "实施中")
private String toStatusName;
@Schema(description = "是否必须填写原因", example = "false")
private Boolean needReason;
}

View File

@@ -0,0 +1,67 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.requirement;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 管理后台 - 产品需求编辑 Request VO
*/
@Schema(description = "管理后台 - 产品需求编辑 Request VO")
@Data
public class ProductRequirementUpdateReqVO {
@Schema(description = "需求ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "需求ID不能为空")
private Long id;
@Schema(description = "所属产品ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "产品ID不能为空")
private Long productId;
@Schema(description = "所属模块ID为空时归入全部需求", example = "1024")
private Long moduleId;
@Schema(description = "是否需要评审0不需要1需要", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
@NotNull(message = "是否需要评审不能为空")
private Integer reviewRequired;
@Schema(description = "需求标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "支持需求模块化管理")
@NotBlank(message = "需求标题不能为空")
@Size(max = 200, message = "需求标题长度不能超过200个字符")
private String title;
@Schema(description = "需求描述(富文本)", example = "<p>详细描述需求内容</p>")
private String description;
@Schema(description = "需求分类字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "function")
@NotBlank(message = "需求分类不能为空")
@Size(max = 64, message = "需求分类长度不能超过64个字符")
private String category;
@Schema(description = "优先级0低 1中 2高 3紧急", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "优先级不能为空")
private Integer priority;
@Schema(description = "提出人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotNull(message = "提出人不能为空")
private Long proposerId;
@Schema(description = "当前处理人用户编号", example = "1024")
private Long currentHandlerUserId;
@Schema(description = "默认实现项目编号", example = "1024")
private Long implementProjectId;
@Schema(description = "预期完成时间", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "预期完成时间不能为空")
private LocalDateTime completionDate;
@Schema(description = "排序值(越小越靠前)", example = "0")
private Integer sort;
}

View File

@@ -0,0 +1,21 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.setting;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.NoArgsConstructor;
@Schema(description = "管理后台 - 产品设置生命周期动作 Response VO")
@Data
@NoArgsConstructor
public class ProductSettingActionRespVO {
@Schema(description = "动作编码", example = "archive")
private String actionCode;
@Schema(description = "动作名称", example = "归档")
private String actionName;
@Schema(description = "是否必须填写原因", example = "true")
private Boolean needReason;
}

View File

@@ -0,0 +1,39 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.setting;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.NoArgsConstructor;
@Schema(description = "管理后台 - 产品设置基础信息 Response VO")
@Data
@NoArgsConstructor
public class ProductSettingBaseInfoRespVO {
@Schema(description = "产品编号", example = "1024")
private Long id;
@Schema(description = "产品编码", example = "CNPD2026001")
private String code;
@Schema(description = "产品方向字典值", example = "direction_value")
private String directionCode;
@Schema(description = "产品名称", example = "统一交付平台")
private String name;
@Schema(description = "产品经理用户编号", example = "10001")
private Long managerUserId;
@Schema(description = "产品经理昵称", example = "张三")
private String managerUserNickname;
@Schema(description = "产品描述", example = "产品描述")
private String description;
@Schema(description = "产品状态编码", example = "active")
private String statusCode;
@Schema(description = "最近一次状态动作原因", example = "恢复正常推进")
private String lastStatusReason;
}

View File

@@ -0,0 +1,28 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.setting;
import com.njcn.rdms.framework.dict.validation.InDict;
import com.njcn.rdms.module.system.enums.DictTypeConstants;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - 产品设置基础信息更新 Request VO")
@Data
public class ProductSettingBaseInfoUpdateReqVO {
@Schema(description = "产品方向字典值", requiredMode = Schema.RequiredMode.REQUIRED, example = "direction_value")
@NotBlank(message = "产品方向不能为空")
@Size(max = 32, message = "产品方向长度不能超过32个字符")
@InDict(type = DictTypeConstants.OBJECT_DIRECTION)
private String directionCode;
@Schema(description = "产品名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "RDMS产品平台")
@NotBlank(message = "产品名称不能为空")
@Size(max = 128, message = "产品名称长度不能超过128个字符")
private String name;
@Schema(description = "产品描述", example = "面向研发管理的一体化产品")
private String description;
}

View File

@@ -0,0 +1,32 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.setting;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Schema(description = "管理后台 - 产品设置生命周期 Response VO")
@Data
@NoArgsConstructor
public class ProductSettingLifecycleRespVO {
@Schema(description = "当前状态编码", example = "active")
private String statusCode;
@Schema(description = "当前状态名称", example = "启用")
private String statusName;
@Schema(description = "最近一次状态动作原因", example = "恢复正常推进")
private String lastStatusReason;
@Schema(description = "是否终态", example = "false")
private Boolean terminal;
@Schema(description = "是否允许编辑", example = "true")
private Boolean allowEdit;
@Schema(description = "当前状态可执行动作")
private List<ProductSettingActionRespVO> availableActions;
}

View File

@@ -0,0 +1,18 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.setting;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.NoArgsConstructor;
@Schema(description = "管理后台 - 产品设置 Response VO")
@Data
@NoArgsConstructor
public class ProductSettingRespVO {
@Schema(description = "产品基础信息")
private ProductSettingBaseInfoRespVO baseInfo;
@Schema(description = "产品生命周期信息")
private ProductSettingLifecycleRespVO lifecycle;
}

View File

@@ -0,0 +1,57 @@
package com.njcn.rdms.module.project.dal.dataobject.member;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* RDMS 对象成员角色关系表
*/
@TableName("rdms_user_object_role")
@Data
@EqualsAndHashCode(callSuper = true)
public class UserObjectRoleDO extends BaseDO {
/**
* 主键ID
*/
@TableId
private Long id;
/**
* 用户ID
*/
private Long userId;
/**
* 对象类型
*/
private String objectType;
/**
* 对象ID
*/
private Long objectId;
/**
* 对象角色ID
*/
private Long roleId;
/**
* 状态0有效 1失效
*/
private Integer status;
/**
* 加入时间
*/
private LocalDateTime joinedTime;
/**
* 退出时间
*/
private LocalDateTime leftTime;
/**
* 备注
*/
private String remark;
}

View File

@@ -47,9 +47,5 @@ public class ProductDO extends BaseDO {
* 最近一次状态动作原因
*/
private String lastStatusReason;
/**
* 备注
*/
private String remark;
}

View File

@@ -0,0 +1,101 @@
package com.njcn.rdms.module.project.dal.dataobject.product;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 产品需求主表
*/
@TableName("rdms_product_requirement")
@Data
@EqualsAndHashCode(callSuper = true)
public class ProductRequirementDO extends BaseDO {
/**
* 主键ID
*/
@TableId
private Long id;
/**
* 父需求ID0表示顶级需求
*/
private Long parentId;
/**
* 所属模块ID0表示全部需求
*/
private Long moduleId;
/**
* 所属产品ID
*/
private Long productId;
/**
* 是否需要评审0不需要1需要
*/
private Integer reviewRequired;
/**
* 需求标题
*/
private String title;
/**
* 需求描述(富文本)
*/
private String description;
/**
* 需求分类字典值
*/
private String category;
/**
* 来源类型manual:手工新增, work_order:工单流转)
*/
private String sourceType;
/**
* 来源业务ID
*/
private Long sourceBizId;
/**
* 优先级0低 1中 2高 3紧急
*/
private Integer priority;
/**
* 当前状态编码
*/
private String statusCode;
/**
* 最近一次状态动作原因
*/
private String lastStatusReason;
/**
* 提出人用户ID
*/
private Long proposerId;
/**
* 提出人用户姓名快照
*/
private String proposerNickname;
/**
* 当前处理人用户ID
*/
private Long currentHandlerUserId;
/**
* 当前处理人姓名快照
*/
private String currentHandlerUserNickname;
/**
* 默认实现项目ID分流后填写
*/
private Long implementProjectId;
/**
* 预期完成时间
*/
private LocalDateTime completionDate;
/**
* 排序值(越小越靠前)
*/
private Integer sort;
}

View File

@@ -0,0 +1,47 @@
package com.njcn.rdms.module.project.dal.dataobject.product;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 产品需求模块表(支撑前端左侧模块树)
*/
@TableName("rdms_product_requirement_module")
@Data
@EqualsAndHashCode(callSuper = true)
public class ProductRequirementModuleDO extends BaseDO {
/**
* 主键ID
*/
@TableId
private Long id;
/**
* 父模块ID0表示顶级
*/
private Long parentId;
/**
* 所属产品ID
*/
private Long productId;
/**
* 模块名称
*/
private String moduleName;
/**
* 模块说明
*/
private String remark;
/**
* 图标
*/
private String icon;
/**
* 排序值
*/
private Integer sort;
}

View File

@@ -0,0 +1,59 @@
package com.njcn.rdms.module.project.dal.dataobject.product;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 产品需求状态变更日志表
*/
@TableName("rdms_product_requirement_status_log")
@Data
@EqualsAndHashCode(callSuper = true)
public class ProductRequirementStatusLogDO extends BaseDO {
/**
* 主键ID
*/
@TableId
private Long id;
/**
* 需求ID
*/
private Long requirementId;
/**
* 动作编码
*/
private String actionType;
/**
* 变更前状态
*/
private String fromStatus;
/**
* 变更后状态
*/
private String toStatus;
/**
* 动作原因
*/
private String reason;
/**
* 操作人ID
*/
private Long operatorUserId;
/**
* 操作人名称快照
*/
private String operatorName;
/**
* 需求标题快照
*/
private String requirementTitleSnapshot;
/**
* 备注
*/
private String remark;
}

View File

@@ -51,14 +51,6 @@ public class ObjectStatusModelDO extends BaseDO {
* 是否允许编辑对象主数据
*/
private Boolean allowEdit;
/**
* 是否允许新建项目
*/
private Boolean allowCreateProject;
/**
* 是否允许新增需求
*/
private Boolean allowCreateRequirement;
/**
* 备注
*/

View File

@@ -1,9 +1,55 @@
package com.njcn.rdms.module.project.dal.mysql.audit;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface BizAuditLogMapper extends BaseMapperX<BizAuditLogDO> {
default List<BizAuditLogDO> selectListByBiz(String bizType, Long bizId, String actionType, LocalDateTime[] operateTime) {
return selectList(new LambdaQueryWrapperX<BizAuditLogDO>()
.eq(BizAuditLogDO::getBizType, bizType)
.eq(BizAuditLogDO::getBizId, bizId)
.eqIfPresent(BizAuditLogDO::getActionType, actionType)
.betweenIfPresent(BaseDO::getCreateTime, operateTime)
.orderByDesc(BaseDO::getCreateTime)
.orderByDesc(BizAuditLogDO::getId));
}
default List<BizAuditLogDO> selectListByBizAndActions(String bizType, Long bizId, List<String> actionTypes,
LocalDateTime startTime, LocalDateTime endTime) {
return selectList(new LambdaQueryWrapperX<BizAuditLogDO>()
.eq(BizAuditLogDO::getBizType, bizType)
.eq(BizAuditLogDO::getBizId, bizId)
.inIfPresent(BizAuditLogDO::getActionType, actionTypes)
.betweenIfPresent(BaseDO::getCreateTime, startTime, endTime)
.orderByDesc(BaseDO::getCreateTime)
.orderByDesc(BizAuditLogDO::getId));
}
default List<BizAuditLogDO> selectListByBizType(String bizType, String actionType, LocalDateTime[] operateTime) {
return selectList(new LambdaQueryWrapperX<BizAuditLogDO>()
.eq(BizAuditLogDO::getBizType, bizType)
.eqIfPresent(BizAuditLogDO::getActionType, actionType)
.betweenIfPresent(BaseDO::getCreateTime, operateTime)
.orderByDesc(BaseDO::getCreateTime)
.orderByDesc(BizAuditLogDO::getId));
}
default List<BizAuditLogDO> selectListByBizTypeAndActions(String bizType, List<String> actionTypes,
LocalDateTime startTime, LocalDateTime endTime) {
return selectList(new LambdaQueryWrapperX<BizAuditLogDO>()
.eq(BizAuditLogDO::getBizType, bizType)
.inIfPresent(BizAuditLogDO::getActionType, actionTypes)
.betweenIfPresent(BaseDO::getCreateTime, startTime, endTime)
.orderByDesc(BaseDO::getCreateTime)
.orderByDesc(BizAuditLogDO::getId));
}
}

View File

@@ -0,0 +1,55 @@
package com.njcn.rdms.module.project.dal.mysql.member;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collections;
import java.util.List;
@Mapper
public interface UserObjectRoleMapper extends BaseMapperX<UserObjectRoleDO> {
default List<UserObjectRoleDO> selectListByObject(String objectType, Long objectId) {
return selectList(new LambdaQueryWrapperX<UserObjectRoleDO>()
.eq(UserObjectRoleDO::getObjectType, objectType)
.eq(UserObjectRoleDO::getObjectId, objectId)
.orderByAsc(UserObjectRoleDO::getStatus)
.orderByAsc(UserObjectRoleDO::getJoinedTime)
.orderByAsc(UserObjectRoleDO::getId));
}
default UserObjectRoleDO selectByObjectAndUserId(String objectType, Long objectId, Long userId) {
return selectOne(new LambdaQueryWrapperX<UserObjectRoleDO>()
.eq(UserObjectRoleDO::getObjectType, objectType)
.eq(UserObjectRoleDO::getObjectId, objectId)
.eq(UserObjectRoleDO::getUserId, userId));
}
default UserObjectRoleDO selectByIdAndObject(Long id, String objectType, Long objectId) {
return selectOne(new LambdaQueryWrapperX<UserObjectRoleDO>()
.eq(UserObjectRoleDO::getId, id)
.eq(UserObjectRoleDO::getObjectType, objectType)
.eq(UserObjectRoleDO::getObjectId, objectId));
}
default UserObjectRoleDO selectActiveByObjectAndUserId(String objectType, Long objectId, Long userId) {
return selectOne(new LambdaQueryWrapperX<UserObjectRoleDO>()
.eq(UserObjectRoleDO::getObjectType, objectType)
.eq(UserObjectRoleDO::getObjectId, objectId)
.eq(UserObjectRoleDO::getUserId, userId)
.eq(UserObjectRoleDO::getStatus, 0));
}
default List<UserObjectRoleDO> selectListByIdsAndObject(List<Long> ids, String objectType, Long objectId) {
if (ids == null || ids.isEmpty()) {
return Collections.emptyList();
}
return selectList(new LambdaQueryWrapperX<UserObjectRoleDO>()
.in(UserObjectRoleDO::getId, ids)
.eq(UserObjectRoleDO::getObjectType, objectType)
.eq(UserObjectRoleDO::getObjectId, objectId));
}
}

View File

@@ -43,4 +43,19 @@ public interface ProductMapper extends BaseMapperX<ProductDO> {
.orderByDesc(ProductDO::getCode));
}
default int updateStatusByIdAndStatus(Long id, String fromStatus, String toStatus, String lastStatusReason) {
ProductDO update = new ProductDO();
update.setStatusCode(toStatus);
update.setLastStatusReason(lastStatusReason);
return update(update, new LambdaQueryWrapperX<ProductDO>()
.eq(ProductDO::getId, id)
.eq(ProductDO::getStatusCode, fromStatus));
}
default int deleteByIdAndStatus(Long id, String statusCode) {
return delete(new LambdaQueryWrapperX<ProductDO>()
.eq(ProductDO::getId, id)
.eq(ProductDO::getStatusCode, statusCode));
}
}

View File

@@ -0,0 +1,138 @@
package com.njcn.rdms.module.project.dal.mysql.product;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.controller.admin.product.vo.requirement.ProductRequirementPageReqVO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementDO;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.util.StringUtils;
import java.util.List;
/**
* 产品需求 Mapper
*/
@Mapper
public interface ProductRequirementMapper extends BaseMapperX<ProductRequirementDO> {
/**
* 分页查询需求列表
*/
default PageResult<ProductRequirementDO> selectPage(ProductRequirementPageReqVO reqVO) {
LambdaQueryWrapperX<ProductRequirementDO> queryWrapper = buildQueryWrapper(reqVO);
return selectPage(reqVO, queryWrapper);
}
/**
* 查询所有符合条件的需求列表(不分页,用于树形查询)
*/
default List<ProductRequirementDO> selectList(ProductRequirementPageReqVO reqVO) {
LambdaQueryWrapperX<ProductRequirementDO> queryWrapper = buildQueryWrapper(reqVO);
return selectList(queryWrapper);
}
/**
* 构建查询条件
*/
private LambdaQueryWrapperX<ProductRequirementDO> buildQueryWrapper(ProductRequirementPageReqVO reqVO) {
LambdaQueryWrapperX<ProductRequirementDO> queryWrapper = new LambdaQueryWrapperX<>();
// 标题模糊搜索
if (StringUtils.hasText(reqVO.getTitle())) {
queryWrapper.like(ProductRequirementDO::getTitle, reqVO.getTitle());
}
// 分类精确匹配
queryWrapper.eqIfPresent(ProductRequirementDO::getCategory, reqVO.getCategory())
// 优先级精确匹配
.eqIfPresent(ProductRequirementDO::getPriority, reqVO.getPriority())
// 状态精确匹配
.eqIfPresent(ProductRequirementDO::getStatusCode, reqVO.getStatusCode())
// 负责人精确匹配
.eqIfPresent(ProductRequirementDO::getCurrentHandlerUserId, reqVO.getCurrentHandlerUserId())
// 来源类型精确匹配
.eqIfPresent(ProductRequirementDO::getSourceType, reqVO.getSourceType())
// 模块ID列表IN查询优先使用moduleIds用于支持子模块查询
.inIfPresent(ProductRequirementDO::getModuleId, reqVO.getModuleIds())
// 模块ID精确匹配当moduleIds为空时使用
.eqIfPresent(ProductRequirementDO::getModuleId, reqVO.getModuleId())
// 父需求ID精确匹配查询子需求时使用
.eqIfPresent(ProductRequirementDO::getParentId, reqVO.getParentId())
// 产品ID精确匹配
.eq(ProductRequirementDO::getProductId, reqVO.getProductId())
// 按排序值升序,再按创建时间降序
.orderByAsc(ProductRequirementDO::getSort)
.orderByDesc(ProductRequirementDO::getCreateTime);
return queryWrapper;
}
/**
* 根据产品ID和模块ID查询需求列表
*/
default List<ProductRequirementDO> selectListByProductIdAndModuleId(Long productId, Long moduleId) {
return selectList(new LambdaQueryWrapperX<ProductRequirementDO>()
.eq(ProductRequirementDO::getProductId, productId)
.eq(ProductRequirementDO::getModuleId, moduleId)
.orderByAsc(ProductRequirementDO::getSort)
.orderByDesc(ProductRequirementDO::getCreateTime));
}
/**
* 根据父需求ID查询子需求列表
*/
default List<ProductRequirementDO> selectListByParentId(Long parentId) {
return selectList(new LambdaQueryWrapperX<ProductRequirementDO>()
.eq(ProductRequirementDO::getParentId, parentId)
.orderByAsc(ProductRequirementDO::getSort)
.orderByDesc(ProductRequirementDO::getCreateTime));
}
/**
* 根据产品ID查询所有需求用于模块删除时级联处理
*/
default List<ProductRequirementDO> selectListByProductId(Long productId) {
return selectList(new LambdaQueryWrapperX<ProductRequirementDO>()
.eq(ProductRequirementDO::getProductId, productId)
.orderByAsc(ProductRequirementDO::getSort));
}
/**
* 根据模块ID查询需求列表
*/
default List<ProductRequirementDO> selectListByModuleId(Long moduleId) {
return selectList(new LambdaQueryWrapperX<ProductRequirementDO>()
.eq(ProductRequirementDO::getModuleId, moduleId)
.orderByAsc(ProductRequirementDO::getSort));
}
/**
* 带并发控制的状态更新id + fromStatus 条件更新)
*/
default int updateStatusByIdAndStatus(Long id, String fromStatus, String toStatus, String lastStatusReason) {
return updateStatusByIdAndStatusWithProject(id, fromStatus, toStatus, lastStatusReason, null);
}
/**
* 带并发控制的状态更新支持同时更新实现项目ID
*/
default int updateStatusByIdAndStatusWithProject(Long id, String fromStatus, String toStatus, String lastStatusReason, Long implementProjectId) {
ProductRequirementDO update = new ProductRequirementDO();
update.setStatusCode(toStatus);
update.setLastStatusReason(lastStatusReason);
if (implementProjectId != null) {
update.setImplementProjectId(implementProjectId);
}
return update(update, new LambdaQueryWrapperX<ProductRequirementDO>()
.eq(ProductRequirementDO::getId, id)
.eq(ProductRequirementDO::getStatusCode, fromStatus));
}
/**
* 根据ID和状态删除带并发控制
*/
default int deleteByIdAndStatus(Long id, String statusCode) {
return delete(new LambdaQueryWrapperX<ProductRequirementDO>()
.eq(ProductRequirementDO::getId, id)
.eq(ProductRequirementDO::getStatusCode, statusCode));
}
}

View File

@@ -0,0 +1,53 @@
package com.njcn.rdms.module.project.dal.mysql.product;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementModuleDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* 产品需求模块 Mapper
*/
@Mapper
public interface ProductRequirementModuleMapper extends BaseMapperX<ProductRequirementModuleDO> {
/**
* 根据产品ID查询模块列表用于构建模块树
*/
default List<ProductRequirementModuleDO> selectListByProductId(Long productId) {
return selectList(new LambdaQueryWrapperX<ProductRequirementModuleDO>()
.eq(ProductRequirementModuleDO::getProductId, productId)
.orderByAsc(ProductRequirementModuleDO::getSort)
.orderByAsc(ProductRequirementModuleDO::getCreateTime));
}
/**
* 根据父模块ID查询子模块列表
*/
default List<ProductRequirementModuleDO> selectListByParentId(Long parentId) {
return selectList(new LambdaQueryWrapperX<ProductRequirementModuleDO>()
.eq(ProductRequirementModuleDO::getParentId, parentId)
.orderByAsc(ProductRequirementModuleDO::getSort));
}
/**
* 根据产品ID和模块名称查询模块用于校验名称唯一性
*/
default ProductRequirementModuleDO selectByProductIdAndModuleName(Long productId, String moduleName) {
return selectOne(new LambdaQueryWrapperX<ProductRequirementModuleDO>()
.eq(ProductRequirementModuleDO::getProductId, productId)
.eq(ProductRequirementModuleDO::getModuleName, moduleName));
}
/**
* 根据产品ID和父模块ID查询模块用于查找产品的"全部需求"根模块)
*/
default ProductRequirementModuleDO selectByProductIdAndParentId(Long productId, Long parentId) {
return selectOne(new LambdaQueryWrapperX<ProductRequirementModuleDO>()
.eq(ProductRequirementModuleDO::getProductId, productId)
.eq(ProductRequirementModuleDO::getParentId, parentId));
}
}

View File

@@ -0,0 +1,25 @@
package com.njcn.rdms.module.project.dal.mysql.product;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementStatusLogDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* 产品需求状态变更日志 Mapper
*/
@Mapper
public interface ProductRequirementStatusLogMapper extends BaseMapperX<ProductRequirementStatusLogDO> {
/**
* 根据需求ID查询状态变更日志列表
*/
default List<ProductRequirementStatusLogDO> selectListByRequirementId(Long requirementId) {
return selectList(new LambdaQueryWrapperX<ProductRequirementStatusLogDO>()
.eq(ProductRequirementStatusLogDO::getRequirementId, requirementId)
.orderByDesc(ProductRequirementStatusLogDO::getCreateTime));
}
}

View File

@@ -1,9 +1,34 @@
package com.njcn.rdms.module.project.dal.mysql.product;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductStatusLogDO;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface ProductStatusLogMapper extends BaseMapperX<ProductStatusLogDO> {
default List<ProductStatusLogDO> selectListByProductId(Long productId, String actionType, LocalDateTime[] operateTime) {
return selectList(new LambdaQueryWrapperX<ProductStatusLogDO>()
.eq(ProductStatusLogDO::getProductId, productId)
.eqIfPresent(ProductStatusLogDO::getActionType, actionType)
.betweenIfPresent(BaseDO::getCreateTime, operateTime)
.orderByDesc(BaseDO::getCreateTime)
.orderByDesc(ProductStatusLogDO::getId));
}
default List<ProductStatusLogDO> selectListByProductIdAndActions(Long productId, List<String> actionTypes,
LocalDateTime startTime, LocalDateTime endTime) {
return selectList(new LambdaQueryWrapperX<ProductStatusLogDO>()
.eq(ProductStatusLogDO::getProductId, productId)
.inIfPresent(ProductStatusLogDO::getActionType, actionTypes)
.betweenIfPresent(BaseDO::getCreateTime, startTime, endTime)
.orderByDesc(BaseDO::getCreateTime)
.orderByDesc(ProductStatusLogDO::getId));
}
}

View File

@@ -16,6 +16,13 @@ public interface ObjectStatusModelMapper extends BaseMapperX<ObjectStatusModelDO
.eq(ObjectStatusModelDO::getStatusCode, statusCode));
}
default ObjectStatusModelDO selectByObjectTypeAndStatusCodeEnabled(String objectType, String statusCode) {
return selectOne(new LambdaQueryWrapperX<ObjectStatusModelDO>()
.eq(ObjectStatusModelDO::getObjectType, objectType)
.eq(ObjectStatusModelDO::getStatusCode, statusCode)
.eq(ObjectStatusModelDO::getStatus, 0));
}
default List<ObjectStatusModelDO> selectListByObjectType(String objectType) {
return selectList(new LambdaQueryWrapperX<ObjectStatusModelDO>()
.eq(ObjectStatusModelDO::getObjectType, objectType)

View File

@@ -1,5 +1,6 @@
package com.njcn.rdms.module.project.framework.rpc.config;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Configuration;
@@ -8,6 +9,6 @@ import org.springframework.context.annotation.Configuration;
* Project 模块的 RPC 配置
*/
@Configuration(value = "projectRpcConfiguration", proxyBeanMethods = false)
@EnableFeignClients(clients = {AdminUserApi.class})
@EnableFeignClients(clients = {AdminUserApi.class, ObjectPermissionApi.class})
public class RpcConfiguration {
}

View File

@@ -0,0 +1,35 @@
package com.njcn.rdms.module.project.framework.security.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 对象级权限校验注解。
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckObjectPermission {
/**
* 对象类型,例如 product。
*/
String objectType();
/**
* 对象编号 SpEL 表达式。
*/
String objectId();
/**
* 对象权限码。
*/
String permission() default "";
/**
* 是否仅校验成员身份。
*/
boolean memberOnly() default false;
}

View File

@@ -0,0 +1,67 @@
package com.njcn.rdms.module.project.framework.security.aop;
import com.njcn.rdms.framework.common.util.spring.SpringExpressionUtils;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import com.njcn.rdms.module.project.framework.security.service.ObjectPermissionService;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
/**
* 对象级权限切面。
*/
@Aspect
@Component
@Slf4j
public class ObjectPermissionAspect {
private final Map<String, ObjectPermissionService> objectPermissionServiceMap;
public ObjectPermissionAspect(List<ObjectPermissionService> objectPermissionServices) {
this.objectPermissionServiceMap = objectPermissionServices.stream()
.collect(Collectors.toMap(ObjectPermissionService::getObjectType, Function.identity()));
}
@Around("@annotation(checkObjectPermission)")
public Object aroundPointCut(ProceedingJoinPoint joinPoint,
CheckObjectPermission checkObjectPermission) throws Throwable {
ObjectPermissionService permissionService = objectPermissionServiceMap.get(checkObjectPermission.objectType());
if (permissionService == null) {
throw invalidParamException("暂不支持对象类型:{}", checkObjectPermission.objectType());
}
Long objectId = resolveObjectId(joinPoint, checkObjectPermission.objectId());
permissionService.checkPermission(objectId, checkObjectPermission.permission(),
checkObjectPermission.memberOnly());
return joinPoint.proceed();
}
private Long resolveObjectId(ProceedingJoinPoint joinPoint, String expression) {
Object parsedValue = SpringExpressionUtils.parseExpression(joinPoint, expression);
if (parsedValue instanceof Number number) {
return number.longValue();
}
if (parsedValue instanceof String value && StringUtils.hasText(value)) {
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException ex) {
log.warn("[resolveObjectId][expression({}) value({}) 不是合法数字]", expression, value);
}
}
if (Objects.isNull(parsedValue)) {
throw invalidParamException("对象编号不能为空");
}
throw invalidParamException("对象编号解析失败:{}", expression);
}
}

View File

@@ -0,0 +1,22 @@
package com.njcn.rdms.module.project.framework.security.service;
/**
* 对象级权限服务。
*/
public interface ObjectPermissionService {
/**
* 返回支持的对象类型。
*/
String getObjectType();
/**
* 校验当前登录用户是否具备对象权限。
*
* @param objectId 对象编号
* @param permission 权限码
* @param memberOnly 是否只要求成员身份
*/
void checkPermission(Long objectId, String permission, boolean memberOnly);
}

View File

@@ -0,0 +1,85 @@
package com.njcn.rdms.module.project.framework.security.service;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.enums.permission.PermissionScopeTypeEnum;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.stream.Collectors;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
/**
* 产品对象权限服务。
*/
@Service
public class ProductObjectPermissionService implements ObjectPermissionService {
private static final String PRODUCT_OBJECT_TYPE = "product";
private static final String ROLE_SCOPE_OBJECT = PermissionScopeTypeEnum.OBJECT.getScopeType();
@Resource
private UserObjectRoleMapper userObjectRoleMapper;
@Resource
private ObjectPermissionApi objectPermissionApi;
@Override
public String getObjectType() {
return PRODUCT_OBJECT_TYPE;
}
@Override
public void checkPermission(Long objectId, String permission, boolean memberOnly) {
if (objectId == null) {
throw invalidParamException("对象编号不能为空");
}
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
UserObjectRoleDO currentMember = userObjectRoleMapper
.selectActiveByObjectAndUserId(PRODUCT_OBJECT_TYPE, objectId, loginUserId);
if (currentMember == null) {
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED,
buildDeniedPermission(permission, memberOnly));
}
if (memberOnly) {
return;
}
String normalizedPermission = normalizePermission(permission);
if (!getRolePermissions(currentMember.getRoleId()).contains(normalizedPermission)) {
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED, normalizedPermission);
}
}
private Set<String> getRolePermissions(Long roleId) {
Set<String> permissions = objectPermissionApi
.getObjectRolePermissions(roleId, ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE)
.getCheckedData();
if (permissions == null || permissions.isEmpty()) {
return Collections.emptySet();
}
return permissions.stream()
.filter(StringUtils::hasText)
.map(String::trim)
.collect(Collectors.toCollection(LinkedHashSet::new));
}
private String normalizePermission(String permission) {
if (!StringUtils.hasText(permission)) {
throw invalidParamException("对象权限码不能为空");
}
return permission.trim();
}
private String buildDeniedPermission(String permission, boolean memberOnly) {
return memberOnly ? "member" : normalizePermission(permission);
}
}

View File

@@ -0,0 +1,305 @@
package com.njcn.rdms.module.project.service.product;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.framework.common.util.object.PageUtils;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityPageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityRespVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductStatusLogDO;
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductStatusLogMapper;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
import jakarta.annotation.Resource;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class ProductActivityQueryService {
private static final String PRODUCT_OBJECT_TYPE = ObjectActivityConstants.PRODUCT_BIZ_TYPE;
private static final String MEMBER_BIZ_TYPE = ObjectActivityConstants.MEMBER_BIZ_TYPE;
private static final String ACTIVITY_ROLE_NAME_CACHE = "project_activity_role_name#10m";
private static final String ROLE_SCOPE_OBJECT = "object";
@Resource
private ProductStatusLogMapper productStatusLogMapper;
@Resource
private BizAuditLogMapper bizAuditLogMapper;
@Resource
private UserObjectRoleMapper userObjectRoleMapper;
@Resource
private CacheManager cacheManager;
@Resource
private ObjectPermissionApi objectPermissionApi;
public PageResult<ProductActivityRespVO> getProductActivities(Long productId, ProductActivityPageReqVO reqVO) {
List<ActivityItem> items = new ArrayList<>();
if (includeType(reqVO.getActivityType(), ObjectActivityConstants.ACTIVITY_TYPE_STATUS)) {
productStatusLogMapper.selectListByProductId(productId, reqVO.getActionType(), reqVO.getOperateTime())
.forEach(log -> items.add(new ActivityItem(log.getId(), log.getCreateTime(), toStatusActivity(log))));
}
if (includeType(reqVO.getActivityType(), ObjectActivityConstants.ACTIVITY_TYPE_PRODUCT)) {
bizAuditLogMapper.selectListByBiz(PRODUCT_OBJECT_TYPE, productId, reqVO.getActionType(), reqVO.getOperateTime())
.forEach(log -> items.add(new ActivityItem(log.getId(), log.getCreateTime(), toProductActivity(log))));
}
if (includeType(reqVO.getActivityType(), ObjectActivityConstants.ACTIVITY_TYPE_MEMBER)) {
appendMemberActivities(productId, reqVO, items);
}
items.sort(Comparator.comparing(ActivityItem::operateTime, Comparator.nullsLast(LocalDateTime::compareTo))
.thenComparing(ActivityItem::sourceId, Comparator.nullsLast(Long::compareTo))
.reversed());
List<ProductActivityRespVO> activities = items.stream()
.map(ActivityItem::respVO)
.toList();
PageResult<ProductActivityRespVO> pageResult = buildPageResult(activities, reqVO);
fillMemberRoleNames(pageResult.getList());
return pageResult;
}
private void appendMemberActivities(Long productId, ProductActivityPageReqVO reqVO, List<ActivityItem> items) {
List<BizAuditLogDO> memberLogs = bizAuditLogMapper
.selectListByBizType(MEMBER_BIZ_TYPE, reqVO.getActionType(), reqVO.getOperateTime());
if (memberLogs.isEmpty()) {
return;
}
List<Long> memberIds = memberLogs.stream()
.map(BizAuditLogDO::getBizId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (memberIds.isEmpty()) {
return;
}
Map<Long, UserObjectRoleDO> memberMap = userObjectRoleMapper
.selectListByIdsAndObject(memberIds, PRODUCT_OBJECT_TYPE, productId)
.stream()
.collect(Collectors.toMap(UserObjectRoleDO::getId, Function.identity()));
memberLogs.stream()
.filter(log -> memberMap.containsKey(log.getBizId()))
.forEach(log -> items.add(new ActivityItem(log.getId(), log.getCreateTime(), toMemberActivity(log))));
}
private PageResult<ProductActivityRespVO> buildPageResult(List<ProductActivityRespVO> activities,
ProductActivityPageReqVO reqVO) {
if (activities.isEmpty()) {
return PageResult.empty();
}
int start = PageUtils.getStart(reqVO);
if (start >= activities.size()) {
return PageResult.empty((long) activities.size());
}
int end = Math.min(start + reqVO.getPageSize(), activities.size());
return new PageResult<>(activities.subList(start, end), (long) activities.size());
}
private void fillMemberRoleNames(List<ProductActivityRespVO> activities) {
if (activities == null || activities.isEmpty()) {
return;
}
Set<Long> roleIds = new LinkedHashSet<>();
for (ProductActivityRespVO activity : activities) {
if (!Objects.equals(activity.getType(), ObjectActivityConstants.ACTIVITY_TYPE_MEMBER)) {
continue;
}
Long beforeRoleId = getFieldChangeLong(activity.getDetails(), "roleId", "before");
Long afterRoleId = getFieldChangeLong(activity.getDetails(), "roleId", "after");
if (beforeRoleId != null) {
roleIds.add(beforeRoleId);
}
if (afterRoleId != null) {
roleIds.add(afterRoleId);
}
}
if (roleIds.isEmpty()) {
return;
}
Map<Long, String> roleNameMap = loadRoleNameMap(roleIds);
for (ProductActivityRespVO activity : activities) {
if (!Objects.equals(activity.getType(), ObjectActivityConstants.ACTIVITY_TYPE_MEMBER)) {
continue;
}
activity.setDetails(appendRoleNames(activity.getDetails(), roleNameMap));
}
}
private Map<Long, String> loadRoleNameMap(Set<Long> roleIds) {
Map<Long, String> roleNameMap = new LinkedHashMap<>();
if (roleIds == null || roleIds.isEmpty()) {
return roleNameMap;
}
Cache cache = cacheManager == null ? null : cacheManager.getCache(ACTIVITY_ROLE_NAME_CACHE);
Set<Long> missIds = new LinkedHashSet<>();
for (Long roleId : roleIds) {
String roleName = cache == null ? null : cache.get(roleId, String.class);
if (roleName != null) {
roleNameMap.put(roleId, roleName);
} else {
missIds.add(roleId);
}
}
if (missIds.isEmpty()) {
return roleNameMap;
}
Map<Long, ObjectRoleRespDTO> roleMap = objectPermissionApi == null ? Map.of()
: objectPermissionApi.getObjectRoleMap(missIds, ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE);
if (roleMap == null || roleMap.isEmpty()) {
return roleNameMap;
}
for (Long roleId : missIds) {
ObjectRoleRespDTO role = roleMap.get(roleId);
if (role == null || !StringUtils.hasText(role.getName())) {
continue;
}
roleNameMap.put(roleId, role.getName());
if (cache != null) {
cache.put(roleId, role.getName());
}
}
return roleNameMap;
}
private Long getFieldChangeLong(String fieldChanges, String fieldName, String valueField) {
JsonNode valueNode = getFieldChangeNode(fieldChanges, fieldName, valueField);
if (valueNode == null || valueNode.isNull()) {
return null;
}
if (valueNode.isNumber()) {
return valueNode.longValue();
}
if (valueNode.isTextual() && StringUtils.hasText(valueNode.textValue())) {
return Long.valueOf(valueNode.textValue().trim());
}
return null;
}
private JsonNode getFieldChangeNode(String fieldChanges, String fieldName, String valueField) {
if (!StringUtils.hasText(fieldChanges) || !JsonUtils.isJsonObject(fieldChanges)) {
return null;
}
JsonNode fieldNode = JsonUtils.parseTree(fieldChanges).path(fieldName);
if (fieldNode.isMissingNode()) {
return null;
}
JsonNode valueNode = fieldNode.path(valueField);
return valueNode.isMissingNode() ? null : valueNode;
}
private String appendRoleNames(String details, Map<Long, String> roleNameMap) {
if (!StringUtils.hasText(details) || !JsonUtils.isJsonObject(details)) {
return details;
}
Long beforeRoleId = getFieldChangeLong(details, "roleId", "before");
Long afterRoleId = getFieldChangeLong(details, "roleId", "after");
if (beforeRoleId == null && afterRoleId == null) {
return details;
}
JsonNode detailsNode = JsonUtils.parseTree(details);
if (!(detailsNode instanceof ObjectNode)) {
return details;
}
ObjectNode objectNode = ((ObjectNode) detailsNode).deepCopy();
ObjectNode roleNameNode = objectNode.putObject("roleName");
appendRoleName(roleNameNode, "before", beforeRoleId, roleNameMap);
appendRoleName(roleNameNode, "after", afterRoleId, roleNameMap);
return JsonUtils.toJsonString(objectNode);
}
private void appendRoleName(ObjectNode roleNameNode, String fieldName, Long roleId, Map<Long, String> roleNameMap) {
if (roleId == null) {
roleNameNode.putNull(fieldName);
return;
}
String roleName = roleNameMap.get(roleId);
if (StringUtils.hasText(roleName)) {
roleNameNode.put(fieldName, roleName);
return;
}
roleNameNode.putNull(fieldName);
}
private boolean includeType(String actual, String expected) {
return !StringUtils.hasText(actual) || Objects.equals(actual.trim(), expected);
}
private ProductActivityRespVO toStatusActivity(ProductStatusLogDO log) {
ProductActivityRespVO respVO = new ProductActivityRespVO();
respVO.setType(ObjectActivityConstants.ACTIVITY_TYPE_STATUS);
respVO.setActionType(log.getActionType());
respVO.setActionName(ObjectActivityConstants.resolveActionName(log.getActionType()));
respVO.setFromStatus(log.getFromStatus());
respVO.setToStatus(log.getToStatus());
respVO.setReason(log.getReason());
respVO.setOperatorUserId(log.getOperatorUserId());
respVO.setOperatorName(log.getOperatorName());
respVO.setOperateTime(log.getCreateTime());
respVO.setSummary(buildSummary(log.getOperatorName(), respVO.getActionName(), log.getReason()));
respVO.setDetails(JsonUtils.toJsonString(buildStatusDetails(log)));
return respVO;
}
private Map<String, Object> buildStatusDetails(ProductStatusLogDO log) {
Map<String, Object> details = new LinkedHashMap<>();
details.put("productCodeSnapshot", log.getProductCodeSnapshot());
details.put("productNameSnapshot", log.getProductNameSnapshot());
return details;
}
private ProductActivityRespVO toProductActivity(BizAuditLogDO log) {
ProductActivityRespVO respVO = new ProductActivityRespVO();
respVO.setType(ObjectActivityConstants.ACTIVITY_TYPE_PRODUCT);
respVO.setActionType(log.getActionType());
respVO.setActionName(ObjectActivityConstants.resolveActionName(log.getActionType()));
respVO.setFromStatus(log.getFromStatus());
respVO.setToStatus(log.getToStatus());
respVO.setReason(log.getReason());
respVO.setOperatorUserId(log.getOperatorUserId());
respVO.setOperatorName(log.getOperatorName());
respVO.setOperateTime(log.getCreateTime());
respVO.setSummary(buildSummary(log.getOperatorName(), respVO.getActionName(), log.getReason()));
respVO.setDetails(log.getFieldChanges());
return respVO;
}
private ProductActivityRespVO toMemberActivity(BizAuditLogDO log) {
ProductActivityRespVO respVO = toProductActivity(log);
respVO.setType(ObjectActivityConstants.ACTIVITY_TYPE_MEMBER);
return respVO;
}
private String buildSummary(String operatorName, String actionName, String reason) {
String actualOperatorName = StringUtils.hasText(operatorName) ? operatorName : "系统";
if (StringUtils.hasText(reason)) {
return String.format("%s执行了【%s】%s", actualOperatorName, actionName, reason);
}
return String.format("%s执行了【%s】", actualOperatorName, actionName);
}
private record ActivityItem(Long sourceId, LocalDateTime operateTime, ProductActivityRespVO respVO) {
}
}

View File

@@ -0,0 +1,522 @@
package com.njcn.rdms.module.project.service.product;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.framework.common.util.object.PageUtils;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelinePageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelineRespVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductStatusLogDO;
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductStatusLogMapper;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
@Service
public class ProductActivityTimelineQueryService {
/**
* 成员名称在读取时间线时再通过缓存转换,避免把昵称快照写进动态记录。
*/
private static final String TIMELINE_USER_NICKNAME_CACHE = "project_timeline_user_nickname#10m";
private static final String TIMELINE_ROLE_NAME_CACHE = "project_timeline_role_name#10m";
private static final String ROLE_SCOPE_OBJECT = "object";
private static final String PRODUCT_OBJECT_TYPE = ObjectActivityConstants.PRODUCT_BIZ_TYPE;
@Resource
private ProductStatusLogMapper productStatusLogMapper;
@Resource
private BizAuditLogMapper bizAuditLogMapper;
@Resource
private UserObjectRoleMapper userObjectRoleMapper;
@Resource
private CacheManager cacheManager;
@Resource
private AdminUserApi adminUserApi;
@Resource
private ObjectPermissionApi objectPermissionApi;
public PageResult<ProductActivityTimelineRespVO> getProductActivityTimelinePage(
Long productId, ProductActivityTimelinePageReqVO reqVO) {
LocalDateTime[] timeRange = buildTimeRange(reqVO);
List<String> actionTypes = normalizeActionTypes(reqVO.getActionTypes());
List<ActivityItem> items = new ArrayList<>();
appendStatusActivities(productId, reqVO.getActivityType(), actionTypes, timeRange, items);
appendProductActivities(productId, reqVO.getActivityType(), actionTypes, timeRange, items);
appendMemberActivities(productId, reqVO.getActivityType(), actionTypes, timeRange, items);
items.sort(Comparator.comparing(ActivityItem::occurredAt, Comparator.nullsLast(LocalDateTime::compareTo))
.thenComparing(ActivityItem::sourceId, Comparator.nullsLast(Long::compareTo))
.reversed());
PageResult<ProductActivityTimelineRespVO> pageResult =
buildPageResult(items.stream().map(ActivityItem::respVO).toList(), reqVO);
fillTargetUserNames(pageResult.getList());
fillMemberRoleNames(pageResult.getList());
return pageResult;
}
private void appendStatusActivities(Long productId, String activityType, List<String> actionTypes,
LocalDateTime[] timeRange, List<ActivityItem> items) {
if (!includeType(activityType, ObjectActivityConstants.ACTIVITY_TYPE_STATUS)) {
return;
}
List<String> statusActions = limitActions(actionTypes, ObjectActivityConstants.STATUS_ACTION_TYPES);
if (shouldSkipByIntersection(actionTypes, statusActions)) {
return;
}
productStatusLogMapper.selectListByProductIdAndActions(productId, statusActions, timeRange[0], timeRange[1])
.forEach(log -> items.add(new ActivityItem(log.getId(), log.getCreateTime(), toStatusTimeline(log))));
}
private void appendProductActivities(Long productId, String activityType, List<String> actionTypes,
LocalDateTime[] timeRange, List<ActivityItem> items) {
if (!includeType(activityType, ObjectActivityConstants.ACTIVITY_TYPE_PRODUCT)) {
return;
}
List<String> productActions = limitActions(actionTypes, ObjectActivityConstants.PRODUCT_TIMELINE_ACTION_TYPES);
if (shouldSkipByIntersection(actionTypes, productActions)) {
return;
}
List<BizAuditLogDO> productLogs = bizAuditLogMapper.selectListByBizAndActions(
ObjectActivityConstants.PRODUCT_BIZ_TYPE, productId, productActions, timeRange[0], timeRange[1]);
Set<CreateSignature> createSignatures = buildCreateSignatures(productLogs);
for (BizAuditLogDO log : productLogs) {
if (ObjectActivityConstants.isStatusAction(log.getActionType())) {
continue;
}
if (isCreateInitManagerNoise(log, createSignatures)) {
continue;
}
if (!ObjectActivityConstants.PRODUCT_TIMELINE_ACTION_TYPES.contains(trim(log.getActionType()))) {
continue;
}
items.add(new ActivityItem(log.getId(), log.getCreateTime(), toProductTimeline(log)));
}
}
private void appendMemberActivities(Long productId, String activityType, List<String> actionTypes,
LocalDateTime[] timeRange, List<ActivityItem> items) {
if (!includeType(activityType, ObjectActivityConstants.ACTIVITY_TYPE_MEMBER)) {
return;
}
List<String> memberActions = limitActions(actionTypes, ObjectActivityConstants.MEMBER_TIMELINE_ACTION_TYPES);
if (shouldSkipByIntersection(actionTypes, memberActions)) {
return;
}
List<BizAuditLogDO> memberLogs = bizAuditLogMapper.selectListByBizTypeAndActions(
ObjectActivityConstants.MEMBER_BIZ_TYPE, memberActions, timeRange[0], timeRange[1]);
if (memberLogs.isEmpty()) {
return;
}
Map<Long, UserObjectRoleDO> memberMap = loadMemberMap(productId, memberLogs);
Set<CreateSignature> createSignatures = loadCreateSignatures(productId, timeRange);
for (BizAuditLogDO log : memberLogs) {
UserObjectRoleDO member = memberMap.get(log.getBizId());
if (member == null) {
continue;
}
if (!ObjectActivityConstants.MEMBER_TIMELINE_ACTION_TYPES.contains(trim(log.getActionType()))) {
continue;
}
if (isCreateInitMemberNoise(log, createSignatures)) {
continue;
}
items.add(new ActivityItem(log.getId(), log.getCreateTime(), toMemberTimeline(log, member)));
}
}
private LocalDateTime[] buildTimeRange(ProductActivityTimelinePageReqVO reqVO) {
if ((reqVO.getStartTime() == null) != (reqVO.getEndTime() == null)) {
throw invalidParamException("开始时间和结束时间必须同时传入");
}
if (reqVO.getStartTime() == null) {
LocalDateTime endTime = LocalDateTime.now();
return new LocalDateTime[]{endTime.minusDays(30), endTime};
}
if (reqVO.getStartTime().isAfter(reqVO.getEndTime())) {
throw invalidParamException("开始时间不能晚于结束时间");
}
return new LocalDateTime[]{reqVO.getStartTime(), reqVO.getEndTime()};
}
private List<String> normalizeActionTypes(List<String> actionTypes) {
if (actionTypes == null || actionTypes.isEmpty()) {
return null;
}
List<String> normalized = actionTypes.stream()
.filter(StringUtils::hasText)
.map(String::trim)
.distinct()
.toList();
return normalized.isEmpty() ? null : normalized;
}
private List<String> limitActions(List<String> actionTypes, List<String> allowedActions) {
if (actionTypes == null || actionTypes.isEmpty()) {
return allowedActions;
}
return actionTypes.stream()
.filter(allowedActions::contains)
.distinct()
.toList();
}
private boolean shouldSkipByIntersection(List<String> actualActions, List<String> limitedActions) {
return actualActions != null && actualActions.size() > 0 && limitedActions.isEmpty();
}
private boolean includeType(String actual, String expected) {
return !StringUtils.hasText(actual) || Objects.equals(actual.trim(), expected);
}
private Map<Long, UserObjectRoleDO> loadMemberMap(Long productId, List<BizAuditLogDO> memberLogs) {
List<Long> memberIds = memberLogs.stream()
.map(BizAuditLogDO::getBizId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (memberIds.isEmpty()) {
return Map.of();
}
Map<Long, UserObjectRoleDO> memberMap = new LinkedHashMap<>();
for (UserObjectRoleDO member : userObjectRoleMapper.selectListByIdsAndObject(
memberIds, ObjectActivityConstants.PRODUCT_BIZ_TYPE, productId)) {
memberMap.put(member.getId(), member);
}
return memberMap;
}
private Set<CreateSignature> loadCreateSignatures(Long productId, LocalDateTime[] timeRange) {
List<BizAuditLogDO> productLogs = bizAuditLogMapper.selectListByBizAndActions(
ObjectActivityConstants.PRODUCT_BIZ_TYPE, productId,
ObjectActivityConstants.PRODUCT_TIMELINE_ACTION_TYPES, timeRange[0], timeRange[1]);
return buildCreateSignatures(productLogs);
}
private Set<CreateSignature> buildCreateSignatures(List<BizAuditLogDO> productLogs) {
Set<CreateSignature> signatures = new LinkedHashSet<>();
for (BizAuditLogDO log : productLogs) {
if (!Objects.equals(trim(log.getActionType()), ObjectActivityConstants.PRODUCT_ACTION_CREATE)) {
continue;
}
Long managerUserId = getFieldChangeLong(log.getFieldChanges(), "managerUserId", "after");
if (managerUserId == null || log.getCreateTime() == null) {
continue;
}
signatures.add(new CreateSignature(log.getCreateTime(), managerUserId));
}
return signatures;
}
private boolean isCreateInitManagerNoise(BizAuditLogDO log, Set<CreateSignature> createSignatures) {
if (!Objects.equals(trim(log.getActionType()), ObjectActivityConstants.PRODUCT_ACTION_CHANGE_MANAGER)) {
return false;
}
if (getFieldChangeLong(log.getFieldChanges(), "managerUserId", "before") != null) {
return false;
}
Long managerUserId = getFieldChangeLong(log.getFieldChanges(), "managerUserId", "after");
return managerUserId != null && log.getCreateTime() != null
&& createSignatures.contains(new CreateSignature(log.getCreateTime(), managerUserId));
}
private boolean isCreateInitMemberNoise(BizAuditLogDO log, Set<CreateSignature> createSignatures) {
if (!Objects.equals(trim(log.getActionType()), ObjectActivityConstants.MEMBER_ACTION_ADD)) {
return false;
}
if (getFieldChangeLong(log.getFieldChanges(), "userId", "before") != null) {
return false;
}
Long userId = getFieldChangeLong(log.getFieldChanges(), "userId", "after");
return userId != null && log.getCreateTime() != null
&& createSignatures.contains(new CreateSignature(log.getCreateTime(), userId));
}
private Long getFieldChangeLong(String fieldChanges, String fieldName, String valueField) {
JsonNode valueNode = getFieldChangeNode(fieldChanges, fieldName, valueField);
if (valueNode == null || valueNode.isNull()) {
return null;
}
if (valueNode.isNumber()) {
return valueNode.longValue();
}
if (valueNode.isTextual() && StringUtils.hasText(valueNode.textValue())) {
return Long.valueOf(valueNode.textValue().trim());
}
return null;
}
private JsonNode getFieldChangeNode(String fieldChanges, String fieldName, String valueField) {
if (!StringUtils.hasText(fieldChanges) || !JsonUtils.isJsonObject(fieldChanges)) {
return null;
}
JsonNode fieldNode = JsonUtils.parseTree(fieldChanges).path(fieldName);
if (fieldNode.isMissingNode()) {
return null;
}
JsonNode valueNode = fieldNode.path(valueField);
return valueNode.isMissingNode() ? null : valueNode;
}
private PageResult<ProductActivityTimelineRespVO> buildPageResult(List<ProductActivityTimelineRespVO> activities,
ProductActivityTimelinePageReqVO reqVO) {
if (activities.isEmpty()) {
return PageResult.empty();
}
int start = PageUtils.getStart(reqVO);
if (start >= activities.size()) {
return PageResult.empty((long) activities.size());
}
int end = Math.min(start + reqVO.getPageSize(), activities.size());
return new PageResult<>(activities.subList(start, end), (long) activities.size());
}
private void fillTargetUserNames(List<ProductActivityTimelineRespVO> activities) {
if (activities == null || activities.isEmpty()) {
return;
}
Set<Long> userIds = new LinkedHashSet<>();
for (ProductActivityTimelineRespVO activity : activities) {
if (activity != null && activity.getTargetUserId() != null) {
userIds.add(activity.getTargetUserId());
}
}
if (userIds.isEmpty()) {
return;
}
Map<Long, String> nicknameMap = loadUserNicknameMap(userIds);
for (ProductActivityTimelineRespVO activity : activities) {
if (activity == null || activity.getTargetUserId() == null) {
continue;
}
activity.setTargetUserName(nicknameMap.get(activity.getTargetUserId()));
}
}
private void fillMemberRoleNames(List<ProductActivityTimelineRespVO> activities) {
if (activities == null || activities.isEmpty()) {
return;
}
Set<Long> roleIds = new LinkedHashSet<>();
for (ProductActivityTimelineRespVO activity : activities) {
if (!Objects.equals(activity.getType(), ObjectActivityConstants.ACTIVITY_TYPE_MEMBER)) {
continue;
}
Long beforeRoleId = getFieldChangeLong(activity.getDetails(), "roleId", "before");
Long afterRoleId = getFieldChangeLong(activity.getDetails(), "roleId", "after");
if (beforeRoleId != null) {
roleIds.add(beforeRoleId);
}
if (afterRoleId != null) {
roleIds.add(afterRoleId);
}
}
if (roleIds.isEmpty()) {
return;
}
Map<Long, String> roleNameMap = loadRoleNameMap(roleIds);
for (ProductActivityTimelineRespVO activity : activities) {
if (!Objects.equals(activity.getType(), ObjectActivityConstants.ACTIVITY_TYPE_MEMBER)) {
continue;
}
activity.setDetails(appendRoleNames(activity.getDetails(), roleNameMap));
}
}
private Map<Long, String> loadUserNicknameMap(Set<Long> userIds) {
Map<Long, String> nicknameMap = new LinkedHashMap<>();
if (userIds == null || userIds.isEmpty()) {
return nicknameMap;
}
Cache cache = cacheManager == null ? null : cacheManager.getCache(TIMELINE_USER_NICKNAME_CACHE);
Set<Long> missIds = new LinkedHashSet<>();
for (Long userId : userIds) {
String nickname = cache == null ? null : cache.get(userId, String.class);
if (nickname != null) {
nicknameMap.put(userId, nickname);
} else {
missIds.add(userId);
}
}
if (missIds.isEmpty()) {
return nicknameMap;
}
Map<Long, AdminUserRespDTO> userMap = adminUserApi == null ? Map.of() : adminUserApi.getUserMap(missIds);
if (userMap == null || userMap.isEmpty()) {
return nicknameMap;
}
for (Long userId : missIds) {
AdminUserRespDTO user = userMap.get(userId);
if (user == null) {
continue;
}
nicknameMap.put(userId, user.getNickname());
if (cache != null && user.getNickname() != null) {
cache.put(userId, user.getNickname());
}
}
return nicknameMap;
}
private Map<Long, String> loadRoleNameMap(Set<Long> roleIds) {
Map<Long, String> roleNameMap = new LinkedHashMap<>();
if (roleIds == null || roleIds.isEmpty()) {
return roleNameMap;
}
Cache cache = cacheManager == null ? null : cacheManager.getCache(TIMELINE_ROLE_NAME_CACHE);
Set<Long> missIds = new LinkedHashSet<>();
for (Long roleId : roleIds) {
String roleName = cache == null ? null : cache.get(roleId, String.class);
if (roleName != null) {
roleNameMap.put(roleId, roleName);
} else {
missIds.add(roleId);
}
}
if (missIds.isEmpty()) {
return roleNameMap;
}
Map<Long, ObjectRoleRespDTO> roleMap = objectPermissionApi == null ? Map.of()
: objectPermissionApi.getObjectRoleMap(missIds, ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE);
if (roleMap == null || roleMap.isEmpty()) {
return roleNameMap;
}
for (Long roleId : missIds) {
ObjectRoleRespDTO role = roleMap.get(roleId);
if (role == null || !StringUtils.hasText(role.getName())) {
continue;
}
roleNameMap.put(roleId, role.getName());
if (cache != null) {
cache.put(roleId, role.getName());
}
}
return roleNameMap;
}
private String appendRoleNames(String details, Map<Long, String> roleNameMap) {
if (!StringUtils.hasText(details) || !JsonUtils.isJsonObject(details)) {
return details;
}
Long beforeRoleId = getFieldChangeLong(details, "roleId", "before");
Long afterRoleId = getFieldChangeLong(details, "roleId", "after");
if (beforeRoleId == null && afterRoleId == null) {
return details;
}
JsonNode detailsNode = JsonUtils.parseTree(details);
if (!(detailsNode instanceof ObjectNode)) {
return details;
}
ObjectNode objectNode = ((ObjectNode) detailsNode).deepCopy();
ObjectNode roleNameNode = objectNode.putObject("roleName");
appendRoleName(roleNameNode, "before", beforeRoleId, roleNameMap);
appendRoleName(roleNameNode, "after", afterRoleId, roleNameMap);
return JsonUtils.toJsonString(objectNode);
}
private void appendRoleName(ObjectNode roleNameNode, String fieldName, Long roleId, Map<Long, String> roleNameMap) {
if (roleId == null) {
roleNameNode.putNull(fieldName);
return;
}
String roleName = roleNameMap.get(roleId);
if (StringUtils.hasText(roleName)) {
roleNameNode.put(fieldName, roleName);
return;
}
roleNameNode.putNull(fieldName);
}
private ProductActivityTimelineRespVO toStatusTimeline(ProductStatusLogDO log) {
ProductActivityTimelineRespVO respVO = new ProductActivityTimelineRespVO();
respVO.setId(ObjectActivityConstants.ACTIVITY_TYPE_STATUS + ":" + log.getId());
respVO.setType(ObjectActivityConstants.ACTIVITY_TYPE_STATUS);
respVO.setActionType(log.getActionType());
respVO.setActionName(ObjectActivityConstants.resolveActionName(log.getActionType()));
respVO.setOperatorUserId(log.getOperatorUserId());
respVO.setOperatorName(log.getOperatorName());
respVO.setOccurredAt(log.getCreateTime());
respVO.setSummary(buildSummary(log.getOperatorName(), respVO.getActionName(), log.getReason()));
respVO.setReason(log.getReason());
respVO.setFromStatus(log.getFromStatus());
respVO.setToStatus(log.getToStatus());
respVO.setDetails(JsonUtils.toJsonString(buildStatusDetails(log)));
return respVO;
}
private ProductActivityTimelineRespVO toProductTimeline(BizAuditLogDO log) {
ProductActivityTimelineRespVO respVO = new ProductActivityTimelineRespVO();
respVO.setId(ObjectActivityConstants.ACTIVITY_TYPE_PRODUCT + ":" + log.getId());
respVO.setType(ObjectActivityConstants.ACTIVITY_TYPE_PRODUCT);
respVO.setActionType(log.getActionType());
respVO.setActionName(ObjectActivityConstants.resolveActionName(log.getActionType()));
respVO.setOperatorUserId(log.getOperatorUserId());
respVO.setOperatorName(log.getOperatorName());
respVO.setOccurredAt(log.getCreateTime());
respVO.setSummary(buildSummary(log.getOperatorName(), respVO.getActionName(), log.getReason()));
respVO.setReason(log.getReason());
respVO.setFromStatus(log.getFromStatus());
respVO.setToStatus(log.getToStatus());
respVO.setDetails(log.getFieldChanges());
return respVO;
}
private ProductActivityTimelineRespVO toMemberTimeline(BizAuditLogDO log, UserObjectRoleDO member) {
ProductActivityTimelineRespVO respVO = toProductTimeline(log);
respVO.setId(ObjectActivityConstants.ACTIVITY_TYPE_MEMBER + ":" + log.getId());
respVO.setType(ObjectActivityConstants.ACTIVITY_TYPE_MEMBER);
respVO.setTargetUserId(member == null ? null : member.getUserId());
return respVO;
}
private Map<String, Object> buildStatusDetails(ProductStatusLogDO log) {
Map<String, Object> details = new LinkedHashMap<>();
details.put("productCodeSnapshot", log.getProductCodeSnapshot());
details.put("productNameSnapshot", log.getProductNameSnapshot());
return details;
}
private String buildSummary(String operatorName, String actionName, String reason) {
String actualOperatorName = StringUtils.hasText(operatorName) ? operatorName : "系统";
if (StringUtils.hasText(reason)) {
return String.format("%s执行了【%s】%s", actualOperatorName, actionName, reason);
}
return String.format("%s执行了【%s】", actualOperatorName, actionName);
}
private String trim(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private record CreateSignature(LocalDateTime occurredAt, Long userId) {
}
private record ActivityItem(Long sourceId, LocalDateTime occurredAt, ProductActivityTimelineRespVO respVO) {
}
}

View File

@@ -0,0 +1,50 @@
package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberUpdateReqVO;
import java.util.List;
/**
* 产品团队 Service 接口
*/
public interface ProductMemberService {
/**
* 获取产品团队成员列表
*
* @param productId 产品编号
* @return 成员列表
*/
List<ProductMemberRespVO> getProductMemberList(Long productId);
/**
* 新增产品团队成员
*
* @param productId 产品编号
* @param reqVO 请求参数
* @return 团队关系编号
*/
Long createProductMember(Long productId, ProductMemberSaveReqVO reqVO);
/**
* 调整产品团队成员角色
*
* @param productId 产品编号
* @param memberId 成员关系编号
* @param reqVO 请求参数
*/
void updateProductMember(Long productId, Long memberId, ProductMemberUpdateReqVO reqVO);
/**
* 移出产品团队成员
*
* @param productId 产品编号
* @param memberId 成员关系编号
* @param reqVO 请求参数
*/
void inactiveProductMember(Long productId, Long memberId, ProductMemberInactiveReqVO reqVO);
}

View File

@@ -0,0 +1,415 @@
package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberUpdateReqVO;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
* 产品团队 Service 实现类
*/
@Service
public class ProductMemberServiceImpl implements ProductMemberService {
private static final String PRODUCT_OBJECT_TYPE = "product";
private static final String ROLE_SCOPE_OBJECT = "object";
private static final String PRODUCT_QUERY_PERMISSION = "project:product:query";
private static final String PRODUCT_UPDATE_PERMISSION = "project:product:update";
private static final Integer MEMBER_STATUS_ACTIVE = 0;
private static final Integer MEMBER_STATUS_INACTIVE = 1;
private static final String PRODUCT_MANAGER_ROLE_CODE = "product_manager";
@Resource
private ProductMapper productMapper;
@Resource
private UserObjectRoleMapper userObjectRoleMapper;
@Resource
private ObjectPermissionApi objectPermissionApi;
@Resource
private BizAuditLogMapper bizAuditLogMapper;
@Resource
private AdminUserApi adminUserApi;
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_QUERY_PERMISSION)
public List<ProductMemberRespVO> getProductMemberList(Long productId) {
ProductDO product = validateProductExists(productId);
List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(PRODUCT_OBJECT_TYPE, productId);
Map<Long, ObjectRoleRespDTO> roleMap = getRoleMap(members.stream().map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet()));
Map<Long, AdminUserRespDTO> userMap = getUserMap(members.stream().map(UserObjectRoleDO::getUserId).collect(Collectors.toSet()));
return members.stream().map(member -> {
ProductMemberRespVO respVO = new ProductMemberRespVO();
respVO.setId(member.getId());
respVO.setUserId(member.getUserId());
AdminUserRespDTO user = userMap.get(member.getUserId());
respVO.setUserNickname(user == null ? null : user.getNickname());
respVO.setRoleId(member.getRoleId());
ObjectRoleRespDTO role = roleMap.get(member.getRoleId());
respVO.setRoleName(role == null ? null : role.getName());
respVO.setRoleCode(role == null ? null : role.getCode());
respVO.setManagerFlag(Objects.equals(member.getUserId(), product.getManagerUserId())
&& Objects.equals(member.getStatus(), MEMBER_STATUS_ACTIVE));
respVO.setStatus(member.getStatus());
respVO.setJoinedTime(member.getJoinedTime());
respVO.setLeftTime(member.getLeftTime());
respVO.setRemark(member.getRemark());
return respVO;
}).collect(Collectors.toList());
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_UPDATE_PERMISSION)
public Long createProductMember(Long productId, ProductMemberSaveReqVO reqVO) {
ProductDO product = validateProductExists(productId);
ObjectRoleRespDTO targetRole = validateProductRole(reqVO.getRoleId());
UserObjectRoleDO existingMember = userObjectRoleMapper
.selectByObjectAndUserId(PRODUCT_OBJECT_TYPE, productId, reqVO.getUserId());
if (existingMember != null && Objects.equals(existingMember.getStatus(), MEMBER_STATUS_ACTIVE)) {
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_ALREADY_EXISTS);
}
UserObjectRoleDO member;
UserObjectRoleDO before = null;
LocalDateTime now = LocalDateTime.now();
if (existingMember == null) {
member = new UserObjectRoleDO();
member.setUserId(reqVO.getUserId());
member.setObjectType(PRODUCT_OBJECT_TYPE);
member.setObjectId(productId);
member.setRoleId(targetRole.getId());
member.setStatus(MEMBER_STATUS_ACTIVE);
member.setJoinedTime(now);
member.setLeftTime(null);
member.setRemark(normalizeNullableText(reqVO.getRemark()));
userObjectRoleMapper.insert(member);
} else {
before = cloneMember(existingMember);
member = existingMember;
member.setRoleId(targetRole.getId());
member.setStatus(MEMBER_STATUS_ACTIVE);
member.setJoinedTime(now);
member.setLeftTime(null);
member.setRemark(normalizeNullableText(reqVO.getRemark()));
userObjectRoleMapper.updateById(member);
}
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, before, member, null);
if (isManagerRole(targetRole)) {
transferManager(product, member, reqVO.getPreviousManagerUserId(), reqVO.getPreviousManagerRoleId(), null);
}
return member.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_UPDATE_PERMISSION)
public void updateProductMember(Long productId, Long memberId, ProductMemberUpdateReqVO reqVO) {
ProductDO product = validateProductExists(productId);
UserObjectRoleDO member = validateMemberExists(productId, memberId);
if (!Objects.equals(member.getStatus(), MEMBER_STATUS_ACTIVE)) {
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_NOT_ACTIVE);
}
ObjectRoleRespDTO targetRole = validateProductRole(reqVO.getRoleId());
UserObjectRoleDO before = cloneMember(member);
member.setRemark(normalizeNullableText(reqVO.getRemark()));
if (isManagerRole(targetRole)) {
member.setRoleId(targetRole.getId());
userObjectRoleMapper.updateById(member);
transferManager(product, member, reqVO.getPreviousManagerUserId(),
reqVO.getPreviousManagerRoleId(), normalizeNullableText(reqVO.getReason()));
} else {
if (Objects.equals(member.getUserId(), product.getManagerUserId())) {
throw exception(ErrorCodeConstants.PRODUCT_MANAGER_MEMBER_NOT_ALLOW_DOWNGRADE);
}
member.setRoleId(targetRole.getId());
userObjectRoleMapper.updateById(member);
}
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_UPDATE,
before, member, normalizeNullableText(reqVO.getReason()));
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_UPDATE_PERMISSION)
public void inactiveProductMember(Long productId, Long memberId, ProductMemberInactiveReqVO reqVO) {
ProductDO product = validateProductExists(productId);
UserObjectRoleDO member = validateMemberExists(productId, memberId);
if (!Objects.equals(member.getStatus(), MEMBER_STATUS_ACTIVE)) {
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_NOT_ACTIVE);
}
if (Objects.equals(member.getUserId(), product.getManagerUserId())) {
throw exception(ErrorCodeConstants.PRODUCT_MANAGER_MEMBER_NOT_ALLOW_REMOVE);
}
UserObjectRoleDO before = cloneMember(member);
member.setStatus(MEMBER_STATUS_INACTIVE);
member.setLeftTime(LocalDateTime.now());
userObjectRoleMapper.updateById(member);
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_REMOVE, before, member,
normalizeNullableText(reqVO.getReason()));
}
private ProductDO validateProductExists(Long productId) {
if (productId == null) {
throw exception(ErrorCodeConstants.PRODUCT_NOT_EXISTS);
}
ProductDO product = productMapper.selectById(productId);
if (product == null) {
throw exception(ErrorCodeConstants.PRODUCT_NOT_EXISTS);
}
return product;
}
private UserObjectRoleDO validateMemberExists(Long productId, Long memberId) {
UserObjectRoleDO member = userObjectRoleMapper.selectByIdAndObject(memberId, PRODUCT_OBJECT_TYPE, productId);
if (member == null) {
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_NOT_EXISTS);
}
return member;
}
private ObjectRoleRespDTO validateProductRole(Long roleId) {
ObjectRoleRespDTO role = objectPermissionApi
.getObjectRoleById(roleId, ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE)
.getCheckedData();
if (role == null) {
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_ROLE_INVALID);
}
return role;
}
private void transferManager(ProductDO product,
UserObjectRoleDO targetManagerMember,
Long previousManagerUserId,
Long previousManagerRoleId,
String reason) {
Long currentManagerUserId = product.getManagerUserId();
Long targetManagerUserId = targetManagerMember.getUserId();
if (Objects.equals(currentManagerUserId, targetManagerUserId)) {
product.setManagerUserId(targetManagerUserId);
productMapper.updateById(product);
return;
}
ObjectRoleRespDTO previousManagerRole = validatePreviousManagerTransfer(currentManagerUserId,
previousManagerUserId, previousManagerRoleId);
transferPreviousManager(product.getId(), previousManagerUserId, previousManagerRole.getId(), reason);
product.setManagerUserId(targetManagerUserId);
productMapper.updateById(product);
writeManagerChangeAuditLog(product.getId(), currentManagerUserId, targetManagerUserId, reason);
}
private ObjectRoleRespDTO validatePreviousManagerTransfer(Long currentManagerUserId,
Long previousManagerUserId,
Long previousManagerRoleId) {
if (currentManagerUserId == null
|| previousManagerUserId == null
|| previousManagerRoleId == null) {
throw exception(ErrorCodeConstants.PRODUCT_MANAGER_TRANSFER_INFO_REQUIRED);
}
if (!Objects.equals(currentManagerUserId, previousManagerUserId)) {
throw exception(ErrorCodeConstants.PRODUCT_MANAGER_TRANSFER_SOURCE_INVALID);
}
ObjectRoleRespDTO previousManagerRole = validateProductRole(previousManagerRoleId);
if (isManagerRole(previousManagerRole)) {
throw exception(ErrorCodeConstants.PRODUCT_MANAGER_TRANSFER_ROLE_INVALID);
}
return previousManagerRole;
}
private void transferPreviousManager(Long productId, Long previousManagerUserId, Long previousManagerRoleId, String reason) {
UserObjectRoleDO existingMember = userObjectRoleMapper
.selectByObjectAndUserId(PRODUCT_OBJECT_TYPE, productId, previousManagerUserId);
UserObjectRoleDO before = existingMember == null ? null : cloneMember(existingMember);
LocalDateTime now = LocalDateTime.now();
UserObjectRoleDO member;
String actionType;
if (existingMember == null) {
member = new UserObjectRoleDO();
member.setUserId(previousManagerUserId);
member.setObjectType(PRODUCT_OBJECT_TYPE);
member.setObjectId(productId);
member.setRoleId(previousManagerRoleId);
member.setStatus(MEMBER_STATUS_ACTIVE);
member.setJoinedTime(now);
member.setLeftTime(null);
member.setRemark(null);
userObjectRoleMapper.insert(member);
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
} else {
member = existingMember;
member.setRoleId(previousManagerRoleId);
member.setStatus(MEMBER_STATUS_ACTIVE);
member.setLeftTime(null);
if (!Objects.equals(before.getStatus(), MEMBER_STATUS_ACTIVE)) {
member.setJoinedTime(now);
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
} else {
actionType = ObjectActivityConstants.MEMBER_ACTION_UPDATE;
}
userObjectRoleMapper.updateById(member);
}
writeMemberAuditLog(member, actionType, before, member, reason);
}
private boolean isManagerRole(ObjectRoleRespDTO role) {
return Objects.equals(PRODUCT_MANAGER_ROLE_CODE, role.getCode());
}
private Map<Long, ObjectRoleRespDTO> getRoleMap(Set<Long> roleIds) {
if (roleIds.isEmpty()) {
return Collections.emptyMap();
}
List<ObjectRoleRespDTO> roles = objectPermissionApi
.getObjectRoleList(roleIds, ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE)
.getCheckedData();
if (roles == null || roles.isEmpty()) {
return Collections.emptyMap();
}
return roles.stream().collect(Collectors.toMap(ObjectRoleRespDTO::getId, Function.identity()));
}
private Map<Long, AdminUserRespDTO> getUserMap(Set<Long> userIds) {
if (userIds.isEmpty()) {
return Collections.emptyMap();
}
return adminUserApi.getUserMap(userIds);
}
private void writeMemberAuditLog(UserObjectRoleDO member,
String actionType,
UserObjectRoleDO before,
UserObjectRoleDO after,
String reason) {
BizAuditLogDO auditLog = new BizAuditLogDO();
auditLog.setBizType(ObjectActivityConstants.MEMBER_BIZ_TYPE);
auditLog.setBizId(member.getId());
auditLog.setActionType(actionType);
auditLog.setFieldChanges(buildMemberFieldChanges(before, after));
auditLog.setReason(reason);
auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
bizAuditLogMapper.insert(auditLog);
}
private void writeManagerChangeAuditLog(Long productId, Long beforeManagerUserId, Long afterManagerUserId, String reason) {
if (Objects.equals(beforeManagerUserId, afterManagerUserId)) {
return;
}
BizAuditLogDO auditLog = new BizAuditLogDO();
auditLog.setBizType(PRODUCT_OBJECT_TYPE);
auditLog.setBizId(productId);
auditLog.setActionType(ObjectActivityConstants.PRODUCT_ACTION_CHANGE_MANAGER);
auditLog.setFieldChanges(buildManagerFieldChanges(beforeManagerUserId, afterManagerUserId));
auditLog.setReason(reason);
auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
bizAuditLogMapper.insert(auditLog);
}
private String buildMemberFieldChanges(UserObjectRoleDO before, UserObjectRoleDO after) {
Map<String, Object> fieldChanges = new LinkedHashMap<>();
appendFieldChange(fieldChanges, "userId", valueOf(before, UserObjectRoleDO::getUserId),
valueOf(after, UserObjectRoleDO::getUserId));
appendFieldChange(fieldChanges, "roleId", valueOf(before, UserObjectRoleDO::getRoleId),
valueOf(after, UserObjectRoleDO::getRoleId));
appendFieldChange(fieldChanges, "status", valueOf(before, UserObjectRoleDO::getStatus),
valueOf(after, UserObjectRoleDO::getStatus));
appendFieldChange(fieldChanges, "joinedTime", valueOf(before, UserObjectRoleDO::getJoinedTime),
valueOf(after, UserObjectRoleDO::getJoinedTime));
appendFieldChange(fieldChanges, "leftTime", valueOf(before, UserObjectRoleDO::getLeftTime),
valueOf(after, UserObjectRoleDO::getLeftTime));
appendFieldChange(fieldChanges, "remark", valueOf(before, UserObjectRoleDO::getRemark),
valueOf(after, UserObjectRoleDO::getRemark));
return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges);
}
private String buildManagerFieldChanges(Long beforeManagerUserId, Long afterManagerUserId) {
Map<String, Object> fieldChanges = new LinkedHashMap<>();
appendFieldChange(fieldChanges, "managerUserId", beforeManagerUserId, afterManagerUserId);
return JsonUtils.toJsonString(fieldChanges);
}
private UserObjectRoleDO cloneMember(UserObjectRoleDO source) {
UserObjectRoleDO clone = new UserObjectRoleDO();
clone.setId(source.getId());
clone.setUserId(source.getUserId());
clone.setObjectType(source.getObjectType());
clone.setObjectId(source.getObjectId());
clone.setRoleId(source.getRoleId());
clone.setStatus(source.getStatus());
clone.setJoinedTime(source.getJoinedTime());
clone.setLeftTime(source.getLeftTime());
clone.setRemark(source.getRemark());
return clone;
}
private <T> T valueOf(UserObjectRoleDO member, Function<UserObjectRoleDO, T> getter) {
return member == null ? null : getter.apply(member);
}
private void appendFieldChange(Map<String, Object> fieldChanges, String fieldName, Object before, Object after) {
if (Objects.equals(before, after)) {
return;
}
Map<String, Object> value = new LinkedHashMap<>();
value.put("before", before);
value.put("after", after);
fieldChanges.put(fieldName, value);
}
private String normalizeNullableText(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return value.trim();
}
private String defaultText(String value) {
return StringUtils.hasText(value) ? value : "";
}
}

View File

@@ -0,0 +1,147 @@
package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.product.vo.requirement.*;
import java.util.List;
/**
* 产品需求 Service 接口
*/
public interface ProductRequirementService {
/**
* 创建产品需求
*
* @param createReqVO 创建请求
* @return 需求编号
*/
Long createRequirement(ProductRequirementSaveReqVO createReqVO);
/**
* 更新产品需求(不含状态变更)
*
* @param updateReqVO 更新请求
*/
void updateRequirement(ProductRequirementUpdateReqVO updateReqVO);
/**
* 获取需求详情
*
* @param id 需求编号
* @return 需求详情
*/
ProductRequirementRespVO getRequirement(Long id, Long productId);
/**
* 获取需求分页列表
*
* @param pageReqVO 分页请求
* @return 分页结果
*/
PageResult<ProductRequirementRespVO> getRequirementPage(ProductRequirementPageReqVO pageReqVO);
/**
* 获取需求树形列表(分页)
*
* @param pageReqVO 分页请求
* @return 分页结果(只按父需求分页,子需求不计入分页)
*/
PageResult<ProductRequirementRespVO> getRequirementTree(ProductRequirementPageReqVO pageReqVO);
/**
* 变更需求状态
*
* @param reqVO 状态动作请求
*/
void changeRequirementStatus(ProductRequirementStatusActionReqVO reqVO);
/**
* 删除需求
*
* @param id 需求编号
* @param productId 产品编号
*/
void deleteRequirement(Long id, Long productId);
/**
* 拆分需求(创建子需求)
*
* @param reqVO 拆分请求
* @return 子需求编号
*/
Long splitRequirement(ProductRequirementSplitReqVO reqVO);
/**
* 关闭需求(大需求关闭时级联关闭子需求)
*
* @param reqVO 关闭请求
*/
void closeRequirement(ProductRequirementCloseReqVO reqVO);
/**
* 获取需求当前可执行的状态动作列表
*
* @param requirementId 需求编号
* @param productId 产品编号
* @return 可执行动作列表
*/
List<ProductRequirementStatusTransitionRespVO> getAllowedTransitions(Long requirementId, Long productId);
/**
* 获取需求生命周期信息(当前状态 + 可执行动作)
*
* @param requirementId 需求编号
* @param productId 产品编号
* @return 生命周期信息
*/
ProductRequirementLifecycleRespVO getRequirementLifecycle(Long requirementId, Long productId);
// ========== 模块管理 ==========
/**
* 创建需求模块
*
* @param reqVO 模块保存请求
* @return 模块编号
*/
Long createRequirementModule(ProductRequirementModuleReqVO reqVO);
/**
* 更新需求模块
*
* @param reqVO 模块保存请求
*/
void updateRequirementModule(ProductRequirementModuleReqVO reqVO);
/**
* 删除需求模块(级联删除模块下需求)
*
* @param moduleId 模块编号
* @param productId 产品编号
*/
void deleteRequirementModule(Long moduleId, Long productId);
/**
* 获取需求模块树
*
* @param productId 产品编号
* @return 模块树
*/
List<ProductRequirementModuleRespVO> getRequirementModuleTree(Long productId);
/**
* 获取需求所有状态字典列表
*
* @return 状态字典列表
*/
List<ProductRequirementStatusDictRespVO> getRequirementStatusDict();
/**
* 获取需求终止态状态字典列表
*
* @return 终止态状态字典列表
*/
List<ProductRequirementStatusDictRespVO> getRequirementTerminalStatusDict();
}

View File

@@ -0,0 +1,914 @@
package com.njcn.rdms.module.project.service.product;
import com.google.common.annotations.VisibleForTesting;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.controller.admin.product.vo.requirement.*;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementModuleDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementStatusLogDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementModuleMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementStatusLogMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.*;
import java.util.stream.Collectors;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
/**
* 产品需求 Service 实现类
*/
@Service
public class ProductRequirementServiceImpl implements ProductRequirementService {
// 对象类型常量
private static final String REQUIREMENT_OBJECT_TYPE = "product_requirement";
private static final String PRODUCT_OBJECT_TYPE = "product";
// 需求状态常量
private static final String STATUS_PENDING_CONFIRM = "pending_confirm";
private static final String STATUS_PENDING_REVIEW = "pending_review";
private static final String STATUS_PENDING_DISPATCH = "pending_dispatch";
private static final String STATUS_IMPLEMENTING = "implementing";
private static final String STATUS_ACCEPTED = "accepted";
private static final String STATUS_CLOSED = "closed";
private static final String STATUS_REJECTED = "rejected";
private static final String STATUS_CANCELLED = "cancelled";
// 终态状态集合
private static final List<String> TERMINAL_STATUSES = List.of(STATUS_CLOSED, STATUS_REJECTED, STATUS_CANCELLED);
// 子需求允许大需求关闭的状态集合
private static final List<String> CHILD_ALLOW_CLOSE_STATUSES = List.of(STATUS_CLOSED, STATUS_CANCELLED, STATUS_REJECTED, STATUS_ACCEPTED);
// 允许删除的状态集合(实施中之前的状态)
private static final List<String> ALLOW_DELETE_STATUSES = List.of(STATUS_PENDING_CONFIRM, STATUS_PENDING_REVIEW, STATUS_PENDING_DISPATCH);
// 权限常量
private static final String PRODUCT_CREATE_PERMISSION = "project:product:create";
private static final String PRODUCT_QUERY_PERMISSION = "project:product:query";
private static final String PRODUCT_UPDATE_PERMISSION = "project:product:update";
private static final String PRODUCT_STATUS_PERMISSION = "project:product:status";
private static final String PRODUCT_DELETE_PERMISSION = "project:product:delete";
private static final String PRODUCT_SPLIT_PERMISSION = "project:product:split";
// 审计动作常量
private static final String ACTION_CREATE = "create";
private static final String ACTION_UPDATE = "update";
private static final String ACTION_DELETE = "delete";
private static final String ACTION_SPLIT = "split";
private static final String ACTION_CLOSE = "close";
private static final String BIZ_TYPE_REQUIREMENT = "product_requirement";
@Resource
private ProductRequirementMapper requirementMapper;
@Resource
private ProductRequirementModuleMapper moduleMapper;
@Resource
private ProductRequirementStatusLogMapper statusLogMapper;
@Resource
private BizAuditLogMapper bizAuditLogMapper;
@Resource
private ObjectStatusTransitionMapper statusTransitionMapper;
@Resource
private ObjectStatusModelMapper statusModelMapper;
// ========== 需求增删改查 ==========
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#createReqVO.productId",
permission = PRODUCT_CREATE_PERMISSION)
public Long createRequirement(ProductRequirementSaveReqVO createReqVO) {
// 当未选择模块时,自动归属到该产品的"全部需求"模块
Long moduleId = resolveModuleId(createReqVO.getModuleId(), createReqVO.getProductId());
// 校验模块是否属于当前产品
validateModuleBelongsToProduct(moduleId, createReqVO.getProductId());
ProductRequirementDO requirement = new ProductRequirementDO();
requirement.setProductId(createReqVO.getProductId());
requirement.setParentId(0L); // 新增需求默认是顶级需求
requirement.setModuleId(moduleId);
requirement.setReviewRequired(createReqVO.getReviewRequired());
requirement.setTitle(createReqVO.getTitle().trim());
requirement.setDescription(normalizeNullableText(createReqVO.getDescription()));
requirement.setCategory(createReqVO.getCategory());
requirement.setSourceType("manual"); // 手工新增默认来源类型
requirement.setPriority(createReqVO.getPriority());
// 根据是否需要评审确定初始状态
String initialStatus = Objects.equals(createReqVO.getReviewRequired(), 1)
? STATUS_PENDING_REVIEW : STATUS_PENDING_DISPATCH;
requirement.setStatusCode(initialStatus);
requirement.setProposerId(createReqVO.getProposerId());
requirement.setCurrentHandlerUserId(createReqVO.getCurrentHandlerUserId());
requirement.setImplementProjectId(createReqVO.getImplementProjectId());
requirement.setCompletionDate(createReqVO.getCompletionDate());
requirement.setSort(createReqVO.getSort() != null ? createReqVO.getSort() : 0);
requirementMapper.insert(requirement);
// 写入业务审计日志
writeBizAuditLog(requirement, ACTION_CREATE, null, initialStatus, null, null);
return requirement.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#updateReqVO.productId",
permission = PRODUCT_UPDATE_PERMISSION)
public void updateRequirement(ProductRequirementUpdateReqVO updateReqVO) {
ProductRequirementDO requirement = validateRequirementExists(updateReqVO.getId());
// 校验终态不允许编辑
validateRequirementEditable(requirement);
// 当未选择模块时,自动归属到该产品的"全部需求"模块
Long moduleId = resolveModuleId(updateReqVO.getModuleId(), updateReqVO.getProductId());
// 校验模块是否属于当前产品
validateModuleBelongsToProduct(moduleId, updateReqVO.getProductId());
String fromStatus = requirement.getStatusCode();
requirement.setModuleId(moduleId);
requirement.setReviewRequired(updateReqVO.getReviewRequired());
requirement.setTitle(updateReqVO.getTitle().trim());
requirement.setDescription(normalizeNullableText(updateReqVO.getDescription()));
requirement.setCategory(updateReqVO.getCategory());
requirement.setPriority(updateReqVO.getPriority());
requirement.setProposerId(updateReqVO.getProposerId());
requirement.setCurrentHandlerUserId(updateReqVO.getCurrentHandlerUserId());
requirement.setImplementProjectId(updateReqVO.getImplementProjectId());
requirement.setCompletionDate(updateReqVO.getCompletionDate());
requirement.setSort(updateReqVO.getSort() != null ? updateReqVO.getSort() : 0);
requirementMapper.updateById(requirement);
writeBizAuditLog(requirement, ACTION_UPDATE, fromStatus, requirement.getStatusCode(), null, null);
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_QUERY_PERMISSION)
public ProductRequirementRespVO getRequirement(Long id, Long productId) {
ProductRequirementDO requirement = validateRequirementExists(id);
return buildRequirementRespVO(requirement);
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#pageReqVO.productId",
permission = PRODUCT_QUERY_PERMISSION)
public PageResult<ProductRequirementRespVO> getRequirementPage(ProductRequirementPageReqVO pageReqVO) {
// 处理模块ID条件支持递归查询子模块
if (pageReqVO.getModuleId() != null) {
if (isAllRequirementsModule(pageReqVO.getModuleId())) {
// "全部需求"模块忽略模块ID条件查询该产品下所有需求
pageReqVO.setModuleId(null);
} else {
// 非"全部需求"模块获取该模块及其所有子模块的ID列表
List<Long> moduleIds = getAllModuleIdsWithChildren(pageReqVO.getModuleId(), pageReqVO.getProductId());
pageReqVO.setModuleIds(moduleIds);
pageReqVO.setModuleId(null);
}
}
PageResult<ProductRequirementDO> pageResult = requirementMapper.selectPage(pageReqVO);
List<ProductRequirementRespVO> list = pageResult.getList().stream()
.map(this::buildRequirementRespVO)
.collect(Collectors.toList());
return new PageResult<>(list, pageResult.getTotal());
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#pageReqVO.productId",
permission = PRODUCT_QUERY_PERMISSION)
public PageResult<ProductRequirementRespVO> getRequirementTree(ProductRequirementPageReqVO pageReqVO) {
Long moduleId = pageReqVO.getModuleId();
Long productId = pageReqVO.getProductId();
// 处理模块过滤条件仅当选中具体模块时递归加载子模块ID进行过滤
if (moduleId != null) {
pageReqVO.setModuleIds(getAllModuleIdsWithChildren(moduleId, productId));
pageReqVO.setModuleId(null);
}
// 清空parentId查询所有符合条件的需求不限层级
pageReqVO.setParentId(null);
// 第一步:查询所有符合搜索条件的需求
List<ProductRequirementDO> matchedRequirements = requirementMapper.selectList(pageReqVO);
if (matchedRequirements.isEmpty()) {
return new PageResult<>(Collections.emptyList(), 0L);
}
// 第二步找出所有匹配需求的根节点ID同时收集路径上的所有节点ID
Set<Long> rootIds = new HashSet<>();
Set<Long> pathNodeIds = new HashSet<>();
Map<Long, ProductRequirementDO> requirementCache = new HashMap<>();
for (ProductRequirementDO req : matchedRequirements) {
requirementCache.put(req.getId(), req);
pathNodeIds.add(req.getId());
Long rootId = findRootRequirementIdAndCollectPath(req, requirementCache, pathNodeIds);
rootIds.add(rootId);
}
// 第三步:查询根需求详情并按创建时间倒排
List<ProductRequirementDO> rootRequirements = requirementMapper.selectBatchIds(rootIds);
rootRequirements.sort((a, b) -> b.getCreateTime().compareTo(a.getCreateTime()));
// 第四步:对根节点列表进行内存分页
int pageNo = pageReqVO.getPageNo() != null ? pageReqVO.getPageNo() : 1;
int pageSize = pageReqVO.getPageSize() != null ? pageReqVO.getPageSize() : 10;
int total = rootRequirements.size();
int fromIndex = (pageNo - 1) * pageSize;
int toIndex = Math.min(fromIndex + pageSize, total);
if (fromIndex >= total) {
return new PageResult<>(Collections.emptyList(), (long) total);
}
List<ProductRequirementDO> pagedRootRequirements = rootRequirements.subList(fromIndex, toIndex);
// 第五步:构建树形结构(只包含路径上的节点)
List<ProductRequirementRespVO> list = pagedRootRequirements.stream()
.map(req -> buildRequirementRespVOWithPathChildren(req, pathNodeIds))
.collect(Collectors.toList());
return new PageResult<>(list, (long) total);
}
/**
* 向上追溯需求的根节点ID同时收集路径上的所有节点ID
* @param requirement 起始需求
* @param cache 需求缓存(避免重复查询)
* @param pathNodeIds 路径节点ID集合输出参数
* @return 根节点IDparentId = 0L 的需求ID
*/
private Long findRootRequirementIdAndCollectPath(ProductRequirementDO requirement,
Map<Long, ProductRequirementDO> cache,
Set<Long> pathNodeIds) {
if (requirement.getParentId() == null || requirement.getParentId() == 0L) {
return requirement.getId();
}
// 从缓存中查找父需求,如果没有则查询数据库
ProductRequirementDO parent = cache.get(requirement.getParentId());
if (parent == null) {
parent = requirementMapper.selectById(requirement.getParentId());
if (parent != null) {
cache.put(parent.getId(), parent);
}
}
if (parent == null) {
// 父需求不存在(数据异常),返回当前需求作为根
return requirement.getId();
}
// 收集路径上的节点ID
pathNodeIds.add(parent.getId());
return findRootRequirementIdAndCollectPath(parent, cache, pathNodeIds);
}
/**
* 构建需求响应VO只包含路径上的子需求
* @param requirement 需求
* @param pathNodeIds 路径节点ID集合
* @return 需求响应VO
*/
private ProductRequirementRespVO buildRequirementRespVOWithPathChildren(ProductRequirementDO requirement,
Set<Long> pathNodeIds) {
ProductRequirementRespVO respVO = buildRequirementRespVO(requirement);
// 查询子需求
List<ProductRequirementDO> allChildren = requirementMapper.selectListByParentId(requirement.getId());
// 只保留路径上的子需求
List<ProductRequirementDO> pathChildren = allChildren.stream()
.filter(child -> pathNodeIds.contains(child.getId()))
.toList();
if (!pathChildren.isEmpty()) {
respVO.setChildren(pathChildren.stream()
.map(child -> buildRequirementRespVOWithPathChildren(child, pathNodeIds))
.collect(Collectors.toList()));
}
return respVO;
}
/**
* 判断指定模块是否为"全部需求"模块parentId = 0L 的根模块)
*/
@VisibleForTesting
boolean isAllRequirementsModule(Long moduleId) {
if (moduleId == null) {
return false;
}
ProductRequirementModuleDO module = moduleMapper.selectById(moduleId);
return module != null && module.getParentId() != null && module.getParentId() == 0L;
}
/**
* 递归获取模块及其所有子模块的ID列表
* @param moduleId 起始模块ID
* @param productId 产品ID
* @return 包含自身及所有子模块的ID列表
*/
@VisibleForTesting
List<Long> getAllModuleIdsWithChildren(Long moduleId, Long productId) {
List<Long> moduleIds = new ArrayList<>();
moduleIds.add(moduleId);
// 查询该产品下所有模块
List<ProductRequirementModuleDO> allModules = moduleMapper.selectListByProductId(productId);
// 递归查找子模块
collectChildModuleIds(moduleId, allModules, moduleIds);
return moduleIds;
}
/**
* 递归收集子模块ID
*/
private void collectChildModuleIds(Long parentId, List<ProductRequirementModuleDO> allModules, List<Long> result) {
for (ProductRequirementModuleDO module : allModules) {
if (Objects.equals(module.getParentId(), parentId)) {
result.add(module.getId());
collectChildModuleIds(module.getId(), allModules, result);
}
}
}
/**
* 递归获取需求及其所有子需求(包含子子需求)
* @param requirementId 起始需求ID
* @return 包含自身及所有后代需求的列表
*/
@VisibleForTesting
List<ProductRequirementDO> getAllRequirementsWithChildren(Long requirementId) {
List<ProductRequirementDO> allRequirements = new ArrayList<>();
ProductRequirementDO requirement = validateRequirementExists(requirementId);
allRequirements.add(requirement);
collectChildRequirements(requirementId, allRequirements);
return allRequirements;
}
/**
* 递归收集子需求
*/
private void collectChildRequirements(Long parentId, List<ProductRequirementDO> result) {
List<ProductRequirementDO> children = requirementMapper.selectListByParentId(parentId);
for (ProductRequirementDO child : children) {
result.add(child);
collectChildRequirements(child.getId(), result);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#reqVO.productId",
permission = PRODUCT_STATUS_PERMISSION)
public void changeRequirementStatus(ProductRequirementStatusActionReqVO reqVO) {
ProductRequirementDO requirement = validateRequirementExists(reqVO.getId());
String actionCode = reqVO.getActionCode().trim();
Long implementProjectId = reqVO.getImplementProjectId();
String fromStatus = requirement.getStatusCode();
// 校验状态流转是否合法
ObjectStatusTransitionDO transition = validateRequirementTransition(fromStatus, actionCode);
String reason = normalizeNullableText(reqVO.getReason());
// 校验是否需要填写原因
validateTransitionReason(transition, reason);
//下一状态
String toStatus = transition.getToStatusCode();
// accept和close动作时校验所有子需求包括子子需求是否处于允许状态
if ("accept".equals(actionCode) || "close".equals(actionCode)) {
validateAllChildrenAllowCloseOrAccept(reqVO.getId());
}
// close动作时递归关闭所有已验收的子需求包括子子需求
if ("close".equals(actionCode)) {
closeAllAcceptedChildren(reqVO.getId(), reason);
}
// 带并发控制的状态更新支持同时更新实现项目ID
int updateCount = requirementMapper.updateStatusByIdAndStatusWithProject(requirement.getId(), fromStatus, toStatus, reason, implementProjectId);
if (updateCount != 1) {
throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_CONCURRENT_MODIFIED);
}
requirement.setStatusCode(toStatus);
requirement.setLastStatusReason(reason);
// 写入状态变更日志
writeRequirementStatusLog(requirement, actionCode, fromStatus, toStatus, reason);
// 写入业务审计日志
writeBizAuditLog(requirement, actionCode, fromStatus, toStatus, null, reason);
}
/**
* 校验需求的所有子需求(包括子子需求)是否处于允许关闭或验收的状态
*/
@VisibleForTesting
void validateAllChildrenAllowCloseOrAccept(Long requirementId) {
List<ProductRequirementDO> allChildren = getAllRequirementsWithChildren(requirementId);
// 排除自身,只校验子需求
for (ProductRequirementDO req : allChildren) {
if (!Objects.equals(req.getId(), requirementId)) {
if (!CHILD_ALLOW_CLOSE_STATUSES.contains(req.getStatusCode())) {
throw exception(ErrorCodeConstants.REQUIREMENT_CHILD_NOT_ALLOW_CLOSE);
}
}
}
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_DELETE_PERMISSION)
public void deleteRequirement(Long id, Long productId) {
ProductRequirementDO requirement = validateRequirementExists(id);
String fromStatus = requirement.getStatusCode();
// 校验是否存在子需求
List<ProductRequirementDO> children = requirementMapper.selectListByParentId(id);
if (!children.isEmpty()) {
throw exception(ErrorCodeConstants.REQUIREMENT_HAS_CHILDREN);
}
// 校验状态是否允许删除(只有待确认、待评审、待分流状态才能删除)
if (!ALLOW_DELETE_STATUSES.contains(fromStatus)) {
throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_NOT_ALLOW_DELETE);
}
// 带并发控制的删除(以当前状态作为条件)
int deleteCount = requirementMapper.deleteByIdAndStatus(id, fromStatus);
if (deleteCount != 1) {
throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_CONCURRENT_MODIFIED);
}
writeBizAuditLog(requirement, ACTION_DELETE, fromStatus, null, null, null);
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#reqVO.productId",
permission = PRODUCT_SPLIT_PERMISSION)
public Long splitRequirement(ProductRequirementSplitReqVO reqVO) {
// 校验父需求是否存在
ProductRequirementDO parentRequirement = validateRequirementExists(reqVO.getParentId());
// 校验父需求状态是否允许拆分(只能是待分流或实施中)
validateParentAllowSplit(parentRequirement);
// 创建子需求
ProductRequirementDO childRequirement = new ProductRequirementDO();
childRequirement.setParentId(reqVO.getParentId());
childRequirement.setProductId(parentRequirement.getProductId()); // 子需求继承父需求的产品
childRequirement.setModuleId(parentRequirement.getModuleId()); // 子需求继承父需求的模块
childRequirement.setReviewRequired(reqVO.getReviewRequired()); // 子需求默认不需要评审
childRequirement.setTitle(reqVO.getTitle().trim());
childRequirement.setDescription(normalizeNullableText(reqVO.getDescription()));
childRequirement.setCategory(reqVO.getCategory());
childRequirement.setSourceType(parentRequirement.getSourceType()); // 继承父需求来源类型
childRequirement.setPriority(reqVO.getPriority());
// 子需求初始状态为待分流
// 根据是否需要评审确定初始状态
String initialStatus = Objects.equals(reqVO.getReviewRequired(), 1)
? STATUS_PENDING_REVIEW : STATUS_PENDING_DISPATCH;
childRequirement.setStatusCode(initialStatus);
childRequirement.setProposerId(parentRequirement.getProposerId()); // 继承父需求提出人
childRequirement.setCurrentHandlerUserId(reqVO.getCurrentHandlerUserId());
childRequirement.setImplementProjectId(reqVO.getImplementProjectId());
childRequirement.setCompletionDate(reqVO.getCompletionDate());
childRequirement.setSort(reqVO.getSort() != null ? reqVO.getSort() : 0);
requirementMapper.insert(childRequirement);
// 父需求状态从待分流变为实施中
if (STATUS_PENDING_DISPATCH.equals(parentRequirement.getStatusCode())) {
String fromStatus = parentRequirement.getStatusCode();
int updateCount = requirementMapper.updateStatusByIdAndStatus(
parentRequirement.getId(), fromStatus, STATUS_IMPLEMENTING, null);
if (updateCount == 1) {
parentRequirement.setStatusCode(STATUS_IMPLEMENTING);
writeRequirementStatusLog(parentRequirement, ACTION_SPLIT, fromStatus, STATUS_IMPLEMENTING, null);
writeBizAuditLog(parentRequirement, ACTION_SPLIT, fromStatus, STATUS_IMPLEMENTING, null, null);
}
}
// 写入子需求的业务审计日志
writeBizAuditLog(childRequirement, ACTION_CREATE, null, initialStatus, null, null);
return childRequirement.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#reqVO.productId",
permission = PRODUCT_STATUS_PERMISSION)
public void closeRequirement(ProductRequirementCloseReqVO reqVO) {
ProductRequirementDO requirement = validateRequirementExists(reqVO.getId());
String fromStatus = requirement.getStatusCode();
String reason = reqVO.getReason().trim();
// 校验当前状态是否为已验收(只有已验收才能关闭)
if (!STATUS_ACCEPTED.equals(fromStatus)) {
throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_NOT_ALLOW_CLOSE);
}
// 校验所有子需求(包括子子需求)是否允许关闭
validateAllChildrenAllowCloseOrAccept(reqVO.getId());
// 递归关闭所有已验收的子需求(包括子子需求)
closeAllAcceptedChildren(reqVO.getId(), reason);
// 关闭当前需求
int updateCount = requirementMapper.updateStatusByIdAndStatus(
requirement.getId(), fromStatus, STATUS_CLOSED, reason);
if (updateCount != 1) {
throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_CONCURRENT_MODIFIED);
}
requirement.setStatusCode(STATUS_CLOSED);
requirement.setLastStatusReason(reason);
writeRequirementStatusLog(requirement, ACTION_CLOSE, fromStatus, STATUS_CLOSED, reason);
writeBizAuditLog(requirement, ACTION_CLOSE, fromStatus, STATUS_CLOSED, null, reason);
}
/**
* 递归关闭所有已验收的子需求(包括子子需求)
*/
private void closeAllAcceptedChildren(Long parentId, String reason) {
List<ProductRequirementDO> children = requirementMapper.selectListByParentId(parentId);
for (ProductRequirementDO child : children) {
// 递归处理子需求的子需求
closeAllAcceptedChildren(child.getId(), reason);
// 如果子需求已验收,则关闭
if (STATUS_ACCEPTED.equals(child.getStatusCode())) {
int updateCount = requirementMapper.updateStatusByIdAndStatus(
child.getId(), STATUS_ACCEPTED, STATUS_CLOSED, reason);
if (updateCount == 1) {
writeRequirementStatusLog(child, ACTION_CLOSE, STATUS_ACCEPTED, STATUS_CLOSED, reason);
writeBizAuditLog(child, ACTION_CLOSE, STATUS_ACCEPTED, STATUS_CLOSED, null, reason);
}
}
}
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_QUERY_PERMISSION)
public List<ProductRequirementStatusTransitionRespVO> getAllowedTransitions(Long requirementId, Long productId) {
ProductRequirementDO requirement = validateRequirementExists(requirementId);
String currentStatus = requirement.getStatusCode();
// 查询当前状态允许的所有流转
List<ObjectStatusTransitionDO> transitions = statusTransitionMapper
.selectListByObjectTypeAndFromStatus(REQUIREMENT_OBJECT_TYPE, currentStatus);
return transitions.stream().map(transition -> {
ProductRequirementStatusTransitionRespVO vo = new ProductRequirementStatusTransitionRespVO();
vo.setActionCode(transition.getActionCode());
vo.setActionName(transition.getActionName());
vo.setToStatusCode(transition.getToStatusCode());
// 查询目标状态名称
ObjectStatusModelDO statusModel = statusModelMapper
.selectByObjectTypeAndStatusCode(REQUIREMENT_OBJECT_TYPE, transition.getToStatusCode());
vo.setToStatusName(statusModel != null ? statusModel.getStatusName() : transition.getToStatusCode());
vo.setNeedReason(transition.getNeedReason());
return vo;
}).collect(Collectors.toList());
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_QUERY_PERMISSION)
public ProductRequirementLifecycleRespVO getRequirementLifecycle(Long requirementId, Long productId) {
ProductRequirementDO requirement = validateRequirementExists(requirementId);
String currentStatus = requirement.getStatusCode();
// 查询当前状态定义
ObjectStatusModelDO statusModel = statusModelMapper
.selectByObjectTypeAndStatusCodeEnabled(REQUIREMENT_OBJECT_TYPE, currentStatus);
if (statusModel == null) {
throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED);
}
ProductRequirementLifecycleRespVO lifecycle = new ProductRequirementLifecycleRespVO();
lifecycle.setStatusCode(statusModel.getStatusCode());
lifecycle.setStatusName(statusModel.getStatusName());
lifecycle.setTerminal(statusModel.getTerminalFlag());
lifecycle.setAllowEdit(statusModel.getAllowEdit());
lifecycle.setLastStatusReason(requirement.getLastStatusReason());
lifecycle.setAvailableActions(getAllowedTransitions(requirementId, productId));
return lifecycle;
}
// ========== 模块管理 ==========
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#reqVO.productId",
permission = PRODUCT_CREATE_PERMISSION)
public Long createRequirementModule(ProductRequirementModuleReqVO reqVO) {
// 校验模块名称在同一产品下是否唯一
validateModuleNameUnique(reqVO.getProductId(), null, reqVO.getModuleName());
ProductRequirementModuleDO module = new ProductRequirementModuleDO();
module.setParentId(reqVO.getParentId() != null ? reqVO.getParentId() : 0L);
module.setProductId(reqVO.getProductId());
module.setModuleName(reqVO.getModuleName().trim());
module.setRemark(normalizeNullableText(reqVO.getRemark()));
module.setIcon(normalizeNullableText(reqVO.getIcon()));
module.setSort(reqVO.getSort() != null ? reqVO.getSort() : 0);
moduleMapper.insert(module);
return module.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#reqVO.productId",
permission = PRODUCT_UPDATE_PERMISSION)
public void updateRequirementModule(ProductRequirementModuleReqVO reqVO) {
if (reqVO.getId() == null) {
throw invalidParamException("模块编号不能为空");
}
ProductRequirementModuleDO module = validateModuleExists(reqVO.getId());
// 校验模块名称唯一性
validateModuleNameUnique(reqVO.getProductId(), reqVO.getId(), reqVO.getModuleName());
module.setModuleName(reqVO.getModuleName().trim());
module.setRemark(normalizeNullableText(reqVO.getRemark()));
module.setIcon(normalizeNullableText(reqVO.getIcon()));
module.setSort(reqVO.getSort() != null ? reqVO.getSort() : 0);
moduleMapper.updateById(module);
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_DELETE_PERMISSION)
public void deleteRequirementModule(Long moduleId, Long productId) {
validateModuleExists(moduleId);
// 校验是否存在子模块
List<ProductRequirementModuleDO> childModules = moduleMapper.selectListByParentId(moduleId);
if (!childModules.isEmpty()) {
throw exception(ErrorCodeConstants.REQUIREMENT_MODULE_HAS_CHILDREN);
}
// 校验模块下是否存在需求
List<ProductRequirementDO> requirements = requirementMapper.selectListByModuleId(moduleId);
if (!requirements.isEmpty()) {
throw exception(ErrorCodeConstants.REQUIREMENT_MODULE_HAS_REQUIREMENTS);
}
// 删除模块
moduleMapper.deleteById(moduleId);
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_QUERY_PERMISSION)
public List<ProductRequirementModuleRespVO> getRequirementModuleTree(Long productId) {
List<ProductRequirementModuleDO> modules = moduleMapper.selectListByProductId(productId);
return buildModuleTree(modules, 0L);
}
@Override
public List<ProductRequirementStatusDictRespVO> getRequirementStatusDict() {
List<ObjectStatusModelDO> statusModels = statusModelMapper.selectListByObjectType(REQUIREMENT_OBJECT_TYPE);
return statusModels.stream()
.map(this::buildStatusDictRespVO)
.collect(Collectors.toList());
}
@Override
public List<ProductRequirementStatusDictRespVO> getRequirementTerminalStatusDict() {
List<ObjectStatusModelDO> statusModels = statusModelMapper.selectListByObjectType(REQUIREMENT_OBJECT_TYPE);
return statusModels.stream()
.filter(ObjectStatusModelDO::getTerminalFlag)
.map(this::buildStatusDictRespVO)
.collect(Collectors.toList());
}
/**
* 构建状态字典响应VO
*/
private ProductRequirementStatusDictRespVO buildStatusDictRespVO(ObjectStatusModelDO statusModel) {
ProductRequirementStatusDictRespVO respVO = new ProductRequirementStatusDictRespVO();
respVO.setStatusCode(statusModel.getStatusCode());
respVO.setStatusName(statusModel.getStatusName());
respVO.setSort(statusModel.getSort());
respVO.setInitialFlag(statusModel.getInitialFlag());
respVO.setTerminalFlag(statusModel.getTerminalFlag());
return respVO;
}
// ========== 私有辅助方法 ==========
/**
* 构建需求响应VO不含子需求
*/
private ProductRequirementRespVO buildRequirementRespVO(ProductRequirementDO requirement) {
ProductRequirementRespVO respVO = BeanUtils.toBean(requirement, ProductRequirementRespVO.class);
// 查询状态名称
ObjectStatusModelDO statusModel = statusModelMapper
.selectByObjectTypeAndStatusCode(REQUIREMENT_OBJECT_TYPE, requirement.getStatusCode());
if (statusModel != null) {
respVO.setStatusName(statusModel.getStatusName());
respVO.setTerminal(statusModel.getTerminalFlag());
}
// 设置是否终态
if (respVO.getTerminal() == null) {
respVO.setTerminal(TERMINAL_STATUSES.contains(requirement.getStatusCode()));
}
return respVO;
}
/**
* 构建模块树
*/
private List<ProductRequirementModuleRespVO> buildModuleTree(List<ProductRequirementModuleDO> modules, Long parentId) {
return modules.stream()
.filter(m -> Objects.equals(m.getParentId(), parentId))
.map(m -> {
ProductRequirementModuleRespVO vo = BeanUtils.toBean(m, ProductRequirementModuleRespVO.class);
vo.setChildren(buildModuleTree(modules, m.getId()));
return vo;
})
.collect(Collectors.toList());
}
/**
* 校验需求是否存在
*/
@VisibleForTesting
ProductRequirementDO validateRequirementExists(Long id) {
if (id == null) {
throw exception(ErrorCodeConstants.REQUIREMENT_NOT_EXISTS);
}
ProductRequirementDO requirement = requirementMapper.selectById(id);
if (requirement == null) {
throw exception(ErrorCodeConstants.REQUIREMENT_NOT_EXISTS);
}
return requirement;
}
/**
* 校验需求是否允许编辑(终态不允许编辑)
*/
@VisibleForTesting
void validateRequirementEditable(ProductRequirementDO requirement) {
if (TERMINAL_STATUSES.contains(requirement.getStatusCode())) {
throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_NOT_ALLOW_EDIT);
}
}
/**
* 校验父需求是否允许拆分
*/
@VisibleForTesting
void validateParentAllowSplit(ProductRequirementDO parentRequirement) {
String status = parentRequirement.getStatusCode();
if (!STATUS_PENDING_DISPATCH.equals(status) && !STATUS_IMPLEMENTING.equals(status)) {
throw exception(ErrorCodeConstants.REQUIREMENT_PARENT_NOT_ALLOW_SPLIT);
}
}
/**
* 校验状态流转是否合法
*/
@VisibleForTesting
ObjectStatusTransitionDO validateRequirementTransition(String fromStatusCode, String actionCode) {
ObjectStatusTransitionDO transition = statusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(REQUIREMENT_OBJECT_TYPE, fromStatusCode, actionCode);
if (transition == null) {
throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_NOT_ALLOWED, actionCode);
}
return transition;
}
/**
* 校验状态流转是否需要填写原因
*/
@VisibleForTesting
void validateTransitionReason(ObjectStatusTransitionDO transition, String reason) {
if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) {
throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_REASON_REQUIRED, transition.getActionCode());
}
}
/**
* 校验模块是否存在
*/
@VisibleForTesting
ProductRequirementModuleDO validateModuleExists(Long id) {
if (id == null) {
throw exception(ErrorCodeConstants.REQUIREMENT_MODULE_NOT_EXISTS);
}
ProductRequirementModuleDO module = moduleMapper.selectById(id);
if (module == null) {
throw exception(ErrorCodeConstants.REQUIREMENT_MODULE_NOT_EXISTS);
}
return module;
}
/**
* 校验模块名称在同一产品下是否唯一
*/
@VisibleForTesting
void validateModuleNameUnique(Long productId, Long moduleId, String moduleName) {
if (!StringUtils.hasText(moduleName)) {
return;
}
ProductRequirementModuleDO existModule = moduleMapper
.selectByProductIdAndModuleName(productId, moduleName.trim());
if (existModule == null) {
return;
}
if (!existModule.getId().equals(moduleId)) {
throw exception(ErrorCodeConstants.REQUIREMENT_MODULE_NAME_DUPLICATE, moduleName.trim());
}
}
/**
* 解析模块ID当未选择模块时自动归属到该产品的"全部需求"模块
* "全部需求"模块的标志性特征是 parentId = 0L
*/
@VisibleForTesting
Long resolveModuleId(Long moduleId, Long productId) {
if (moduleId != null) {
return moduleId;
}
// 查询该产品的"全部需求"模块parentId = 0L 的根模块)
ProductRequirementModuleDO defaultModule = moduleMapper
.selectByProductIdAndParentId(productId, 0L);
if (defaultModule == null) {
throw exception(ErrorCodeConstants.REQUIREMENT_MODULE_NOT_EXISTS);
}
return defaultModule.getId();
}
/**
* 校验模块是否属于当前产品
*/
@VisibleForTesting
void validateModuleBelongsToProduct(Long moduleId, Long productId) {
if (moduleId == null) {
return; // 全部需求模块不需要校验
}
ProductRequirementModuleDO module = moduleMapper.selectById(moduleId);
if (module == null) {
throw exception(ErrorCodeConstants.REQUIREMENT_MODULE_NOT_EXISTS);
}
if (!Objects.equals(module.getProductId(), productId)) {
throw exception(ErrorCodeConstants.REQUIREMENT_MODULE_NOT_BELONG_TO_PRODUCT);
}
}
/**
* 写入需求状态变更日志
*/
private void writeRequirementStatusLog(ProductRequirementDO requirement, String actionType,
String fromStatus, String toStatus, String reason) {
ProductRequirementStatusLogDO statusLog = new ProductRequirementStatusLogDO();
statusLog.setRequirementId(requirement.getId());
statusLog.setActionType(actionType);
statusLog.setFromStatus(fromStatus);
statusLog.setToStatus(toStatus);
statusLog.setReason(defaultText(reason));
statusLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
statusLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
statusLog.setRequirementTitleSnapshot(requirement.getTitle());
statusLogMapper.insert(statusLog);
}
/**
* 写入业务审计日志
*/
private void writeBizAuditLog(ProductRequirementDO requirement, String actionType, String fromStatus,
String toStatus, String fieldChanges, String reason) {
BizAuditLogDO auditLog = new BizAuditLogDO();
auditLog.setBizType(BIZ_TYPE_REQUIREMENT);
auditLog.setBizId(requirement.getId());
auditLog.setActionType(actionType);
auditLog.setFromStatus(fromStatus);
auditLog.setToStatus(toStatus);
auditLog.setFieldChanges(fieldChanges);
auditLog.setReason(reason);
auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
bizAuditLogMapper.insert(auditLog);
}
/**
* 处理可能为空的文本去除首尾空格后若为空则返回null
*/
private String normalizeNullableText(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return value.trim();
}
/**
* 处理可能为空的文本,若为空则返回空字符串
*/
private String defaultText(String value) {
return StringUtils.hasText(value) ? value : "";
}
}

View File

@@ -1,10 +1,12 @@
package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductStatusActionReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingBaseInfoUpdateReqVO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
/**
@@ -27,6 +29,14 @@ public interface ProductService {
*/
void updateProduct(ProductSaveReqVO updateReqVO);
/**
* 更新产品设置页基础信息
*
* @param productId 产品编号
* @param reqVO 更新请求
*/
void updateProductBaseInfo(Long productId, ProductSettingBaseInfoUpdateReqVO reqVO);
/**
* 获取产品详情
*
@@ -35,6 +45,14 @@ public interface ProductService {
*/
ProductDO getProduct(Long id);
/**
* 获取产品上下文
*
* @param id 产品编号
* @return 产品上下文
*/
ProductContextRespVO getProductContext(Long id);
/**
* 获取产品分页
*

View File

@@ -5,30 +5,39 @@ import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductStatusActionReqVO;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.*;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingBaseInfoUpdateReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductStatusLogDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementModuleMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductStatusLogMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectMenuRespDTO;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRolePermissionRespDTO;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.enums.permission.MenuTypeEnum;
import com.njcn.rdms.module.system.enums.permission.PermissionScopeTypeEnum;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
@@ -40,16 +49,22 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
public class ProductServiceImpl implements ProductService {
private static final String PRODUCT_OBJECT_TYPE = "product";
private static final String ROLE_SCOPE_OBJECT = PermissionScopeTypeEnum.OBJECT.getScopeType();
private static final String PRODUCT_MANAGER_ROLE_CODE = "product_manager";
private static final String PRODUCT_ACTIVE_STATUS = "active";
private static final String PRODUCT_PAUSED_STATUS = "paused";
private static final String PRODUCT_ARCHIVED_STATUS = "archived";
private static final String PRODUCT_ABANDONED_STATUS = "abandoned";
private static final String PRODUCT_CREATE_ACTION = "create";
private static final String PRODUCT_UPDATE_ACTION = "update";
private static final String PRODUCT_DELETE_ACTION = "delete";
private static final Integer MEMBER_STATUS_ACTIVE = 0;
private static final String PRODUCT_CODE_PREFIX = "CNPD";
private static final String PRODUCT_QUERY_PERMISSION = "project:product:query";
private static final String PRODUCT_UPDATE_PERMISSION = "project:product:update";
private static final String PRODUCT_STATUS_PERMISSION = "project:product:status";
private static final String PRODUCT_DELETE_PERMISSION = "project:product:delete";
private static final String PRODUCT_DELETE_CONFIRM_TEXT = "DELETE";
@Resource
private ProductMapper productMapper;
@@ -60,7 +75,13 @@ public class ProductServiceImpl implements ProductService {
@Resource
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
@Resource
private UserObjectRoleMapper userObjectRoleMapper;
@Resource
private ObjectPermissionApi objectPermissionApi;
@Resource
private AdminUserApi adminUserApi;
@Resource
private ProductRequirementModuleMapper requirementModuleMapper;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -75,44 +96,74 @@ public class ProductServiceImpl implements ProductService {
product.setName(createReqVO.getName().trim());
product.setManagerUserId(createReqVO.getManagerUserId());
product.setDescription(normalizeNullableText(createReqVO.getDescription()));
product.setRemark(normalizeNullableText(createReqVO.getRemark()));
productMapper.insert(product);
writeBizAuditLog(product, PRODUCT_CREATE_ACTION, null, PRODUCT_ACTIVE_STATUS,
buildFieldChanges(null, product), null);
initManagerMemberRelation(product);
initDefaultRequirementModule(product);
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_CREATE, null, PRODUCT_ACTIVE_STATUS,
buildProductFieldChanges(null, product), null);
return product.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#updateReqVO.id",
permission = PRODUCT_UPDATE_PERMISSION)
public void updateProduct(ProductSaveReqVO updateReqVO) {
if (updateReqVO.getId() == null) {
throw invalidParamException("产品编号不能为空");
}
ProductDO product = validateProductExists(updateReqVO.getId());
validateManagerUser(updateReqVO.getManagerUserId());
validateProductCodeUnchanged(product, updateReqVO.getCode());
validateProductEditable(product, updateReqVO);
validateProductNameUnique(updateReqVO.getId(), updateReqVO.getName());
ProductDO before = BeanUtils.toBean(product, ProductDO.class);
product.setDirectionCode(updateReqVO.getDirectionCode());
product.setName(updateReqVO.getName().trim());
product.setManagerUserId(updateReqVO.getManagerUserId());
product.setDescription(normalizeNullableText(updateReqVO.getDescription()));
product.setRemark(normalizeNullableText(updateReqVO.getRemark()));
productMapper.updateById(product);
writeBizAuditLog(product, PRODUCT_UPDATE_ACTION, product.getStatusCode(), product.getStatusCode(),
buildFieldChanges(before, product), null);
validateManagerUserUnchanged(product, updateReqVO.getManagerUserId());
applyProductBaseInfoUpdate(product, updateReqVO.getDirectionCode(), updateReqVO.getName(), updateReqVO.getDescription());
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_UPDATE_PERMISSION)
public void updateProductBaseInfo(Long productId, ProductSettingBaseInfoUpdateReqVO reqVO) {
ProductDO product = validateProductExists(productId);
applyProductBaseInfoUpdate(product, reqVO.getDirectionCode(), reqVO.getName(), reqVO.getDescription());
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#id",
permission = PRODUCT_QUERY_PERMISSION)
public ProductDO getProduct(Long id) {
return validateProductExists(id);
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#id", memberOnly = true)
public ProductContextRespVO getProductContext(Long id) {
ProductDO product = validateProductExists(id);
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
UserObjectRoleDO currentMember = userObjectRoleMapper
.selectActiveByObjectAndUserId(PRODUCT_OBJECT_TYPE, id, loginUserId);
ProductContextRespVO respVO = new ProductContextRespVO();
respVO.setCurrentProduct(buildCurrentProduct(product));
if (currentMember == null) {
respVO.setNavs(Collections.emptyList());
respVO.setButtons(Collections.emptyList());
return respVO;
}
ObjectRolePermissionRespDTO permissionDetail = objectPermissionApi
.getObjectRolePermissionDetail(currentMember.getRoleId(), ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE)
.getCheckedData();
ObjectRoleRespDTO currentRole = permissionDetail == null ? null : permissionDetail.getCurrentRole();
List<ObjectMenuRespDTO> menus = permissionDetail == null || permissionDetail.getMenus() == null
? Collections.emptyList()
: permissionDetail.getMenus();
respVO.setCurrentRole(buildCurrentRole(currentMember, currentRole));
respVO.setNavs(buildContextNavs(menus));
respVO.setButtons(buildContextButtons(menus));
return respVO;
}
@Override
public PageResult<ProductDO> getProductPage(ProductPageReqVO pageReqVO) {
return productMapper.selectPage(pageReqVO);
@@ -120,6 +171,8 @@ public class ProductServiceImpl implements ProductService {
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#reqVO.id",
permission = PRODUCT_STATUS_PERMISSION)
public void changeProductStatus(ProductStatusActionReqVO reqVO) {
ProductDO product = validateProductExists(reqVO.getId());
String actionCode = reqVO.getActionCode().trim();
@@ -129,9 +182,12 @@ public class ProductServiceImpl implements ProductService {
String fromStatus = product.getStatusCode();
String toStatus = transition.getToStatusCode();
int updateCount = productMapper.updateStatusByIdAndStatus(product.getId(), fromStatus, toStatus, reason);
if (updateCount != 1) {
throw exception(ErrorCodeConstants.PRODUCT_STATUS_CONCURRENT_MODIFIED);
}
product.setStatusCode(toStatus);
product.setLastStatusReason(reason);
productMapper.updateById(product);
writeProductStatusLog(product, actionCode, fromStatus, toStatus, reason);
writeBizAuditLog(product, actionCode, fromStatus, toStatus, null, reason);
@@ -139,18 +195,24 @@ public class ProductServiceImpl implements ProductService {
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#reqVO.id",
permission = PRODUCT_DELETE_PERMISSION)
public void deleteProduct(ProductDeleteReqVO reqVO) {
ProductDO product = validateProductExists(reqVO.getId());
validateDeleteConfirmText(reqVO.getConfirmText());
if (!Objects.equals(product.getName(), reqVO.getProductName().trim())) {
throw exception(ErrorCodeConstants.PRODUCT_DELETE_NAME_MISMATCH);
}
String reason = reqVO.getReason().trim();
String fromStatus = product.getStatusCode();
productMapper.deleteById(reqVO.getId());
int deleteCount = productMapper.deleteByIdAndStatus(reqVO.getId(), fromStatus);
if (deleteCount != 1) {
throw exception(ErrorCodeConstants.PRODUCT_STATUS_CONCURRENT_MODIFIED);
}
writeProductStatusLog(product, PRODUCT_DELETE_ACTION, fromStatus, null, reason);
writeBizAuditLog(product, PRODUCT_DELETE_ACTION, fromStatus, null, null, reason);
writeProductStatusLog(product, ObjectActivityConstants.PRODUCT_ACTION_DELETE, fromStatus, null, reason);
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_DELETE, fromStatus, null, null, reason);
}
@VisibleForTesting
@@ -213,8 +275,20 @@ public class ProductServiceImpl implements ProductService {
}
}
@VisibleForTesting
void validateManagerUserUnchanged(ProductDO product, Long managerUserId) {
if (!Objects.equals(product.getManagerUserId(), managerUserId)) {
throw exception(ErrorCodeConstants.PRODUCT_MANAGER_NOT_MODIFIABLE);
}
}
@VisibleForTesting
void validateProductEditable(ProductDO product, ProductSaveReqVO updateReqVO) {
validateProductEditable(product, updateReqVO.getDirectionCode(), updateReqVO.getName());
}
@VisibleForTesting
void validateProductEditable(ProductDO product, String directionCode, String name) {
if (PRODUCT_ARCHIVED_STATUS.equals(product.getStatusCode())
|| PRODUCT_ABANDONED_STATUS.equals(product.getStatusCode())) {
throw exception(ErrorCodeConstants.PRODUCT_STATUS_NOT_ALLOW_EDIT);
@@ -222,8 +296,8 @@ public class ProductServiceImpl implements ProductService {
if (!PRODUCT_PAUSED_STATUS.equals(product.getStatusCode())) {
return;
}
if (!Objects.equals(product.getDirectionCode(), updateReqVO.getDirectionCode())
|| !Objects.equals(product.getName(), updateReqVO.getName().trim())) {
if (!Objects.equals(product.getDirectionCode(), directionCode)
|| !Objects.equals(product.getName(), name.trim())) {
throw exception(ErrorCodeConstants.PRODUCT_PAUSED_ONLY_ALLOW_LIMITED_UPDATE);
}
}
@@ -245,6 +319,14 @@ public class ProductServiceImpl implements ProductService {
}
}
@VisibleForTesting
void validateDeleteConfirmText(String confirmText) {
String normalizedConfirmText = normalizeNullableText(confirmText);
if (!Objects.equals(PRODUCT_DELETE_CONFIRM_TEXT, normalizedConfirmText)) {
throw exception(ErrorCodeConstants.PRODUCT_DELETE_CONFIRM_TEXT_INVALID);
}
}
private String generateProductCode(String code) {
String normalizedCode = normalizeNullableText(code);
if (StringUtils.hasText(normalizedCode)) {
@@ -271,6 +353,91 @@ public class ProductServiceImpl implements ProductService {
return generatedCode;
}
private void initDefaultRequirementModule(ProductDO product) {
com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementModuleDO module =
new com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementModuleDO();
module.setParentId(0L);
module.setProductId(product.getId());
module.setModuleName("全部需求");
module.setRemark("自动创建的模块");
module.setSort(0);
requirementModuleMapper.insert(module);
}
private void initManagerMemberRelation(ProductDO product) {
ObjectRoleRespDTO managerRole = objectPermissionApi
.getObjectRoleByCode(PRODUCT_MANAGER_ROLE_CODE, ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE)
.getCheckedData();
if (managerRole == null) {
throw invalidParamException("未找到产品经理对象角色配置:{}", PRODUCT_MANAGER_ROLE_CODE);
}
UserObjectRoleDO member = new UserObjectRoleDO();
member.setUserId(product.getManagerUserId());
member.setObjectType(PRODUCT_OBJECT_TYPE);
member.setObjectId(product.getId());
member.setRoleId(managerRole.getId());
member.setStatus(MEMBER_STATUS_ACTIVE);
member.setJoinedTime(LocalDateTime.now());
member.setLeftTime(null);
userObjectRoleMapper.insert(member);
writeMemberInitAuditLog(member);
writeManagerInitAuditLog(product.getId(), product.getManagerUserId());
}
private ProductContextProductRespVO buildCurrentProduct(ProductDO product) {
return BeanUtils.toBean(product, ProductContextProductRespVO.class);
}
private ProductContextRoleRespVO buildCurrentRole(UserObjectRoleDO currentMember, ObjectRoleRespDTO currentRole) {
ProductContextRoleRespVO roleRespVO = new ProductContextRoleRespVO();
roleRespVO.setRoleId(currentMember.getRoleId());
if (currentRole != null) {
roleRespVO.setRoleCode(currentRole.getCode());
roleRespVO.setRoleName(currentRole.getName());
}
return roleRespVO;
}
private List<ProductContextNavRespVO> buildContextNavs(List<ObjectMenuRespDTO> menus) {
if (menus.isEmpty()) {
return Collections.emptyList();
}
List<ProductContextNavRespVO> navs = menus.stream()
.filter(menu -> !MenuTypeEnum.BUTTON.getType().equals(menu.getType()))
.filter(menu -> !Boolean.FALSE.equals(menu.getVisible()))
.map(menu -> {
ProductContextNavRespVO nav = new ProductContextNavRespVO();
nav.setId(menu.getId());
nav.setName(menu.getName());
nav.setPath(menu.getPath());
nav.setIcon(menu.getIcon());
nav.setSort(menu.getSort());
return nav;
})
.collect(Collectors.toCollection(ArrayList::new));
navs.sort(Comparator.comparing(ProductContextNavRespVO::getSort, Comparator.nullsLast(Integer::compareTo))
.thenComparing(ProductContextNavRespVO::getId, Comparator.nullsLast(Long::compareTo)));
return navs;
}
private List<String> buildContextButtons(List<ObjectMenuRespDTO> menus) {
if (menus.isEmpty()) {
return Collections.emptyList();
}
return menus.stream()
.filter(menu -> MenuTypeEnum.BUTTON.getType().equals(menu.getType()))
.map(ObjectMenuRespDTO::getPermission)
.filter(StringUtils::hasText)
.map(String::trim)
.distinct()
.sorted()
.collect(Collectors.toList());
}
private void writeProductStatusLog(ProductDO product, String actionType, String fromStatus,
String toStatus, String reason) {
ProductStatusLogDO statusLog = new ProductStatusLogDO();
@@ -301,7 +468,56 @@ public class ProductServiceImpl implements ProductService {
bizAuditLogMapper.insert(auditLog);
}
private String buildFieldChanges(ProductDO before, ProductDO after) {
private void writeMemberInitAuditLog(UserObjectRoleDO member) {
BizAuditLogDO auditLog = new BizAuditLogDO();
auditLog.setBizType(ObjectActivityConstants.MEMBER_BIZ_TYPE);
auditLog.setBizId(member.getId());
auditLog.setActionType(ObjectActivityConstants.MEMBER_ACTION_ADD);
auditLog.setFieldChanges(buildMemberFieldChanges(member));
auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
bizAuditLogMapper.insert(auditLog);
}
private void writeManagerInitAuditLog(Long productId, Long managerUserId) {
BizAuditLogDO auditLog = new BizAuditLogDO();
auditLog.setBizType(PRODUCT_OBJECT_TYPE);
auditLog.setBizId(productId);
auditLog.setActionType(ObjectActivityConstants.PRODUCT_ACTION_CHANGE_MANAGER);
auditLog.setFieldChanges(buildManagerFieldChanges(null, managerUserId));
auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
bizAuditLogMapper.insert(auditLog);
}
private ProductDO cloneProduct(ProductDO source) {
ProductDO target = new ProductDO();
target.setId(source.getId());
target.setCode(source.getCode());
target.setDirectionCode(source.getDirectionCode());
target.setStatusCode(source.getStatusCode());
target.setName(source.getName());
target.setManagerUserId(source.getManagerUserId());
target.setDescription(source.getDescription());
target.setLastStatusReason(source.getLastStatusReason());
return target;
}
private void applyProductBaseInfoUpdate(ProductDO product, String directionCode, String name, String description) {
validateProductEditable(product, directionCode, name);
validateProductNameUnique(product.getId(), name);
ProductDO before = cloneProduct(product);
product.setDirectionCode(directionCode);
product.setName(name.trim());
product.setDescription(normalizeNullableText(description));
productMapper.updateById(product);
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_UPDATE, before.getStatusCode(), product.getStatusCode(),
buildProductFieldChanges(before, product), null);
}
private String buildProductFieldChanges(ProductDO before, ProductDO after) {
Map<String, Object> fieldChanges = new LinkedHashMap<>();
appendFieldChange(fieldChanges, "code", valueOf(before, ProductDO::getCode), valueOf(after, ProductDO::getCode));
appendFieldChange(fieldChanges, "directionCode", valueOf(before, ProductDO::getDirectionCode),
@@ -315,10 +531,26 @@ public class ProductServiceImpl implements ProductService {
valueOf(after, ProductDO::getDescription));
appendFieldChange(fieldChanges, "lastStatusReason", valueOf(before, ProductDO::getLastStatusReason),
valueOf(after, ProductDO::getLastStatusReason));
appendFieldChange(fieldChanges, "remark", valueOf(before, ProductDO::getRemark), valueOf(after, ProductDO::getRemark));
return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges);
}
private String buildMemberFieldChanges(UserObjectRoleDO member) {
Map<String, Object> fieldChanges = new LinkedHashMap<>();
appendFieldChange(fieldChanges, "userId", null, member.getUserId());
appendFieldChange(fieldChanges, "roleId", null, member.getRoleId());
appendFieldChange(fieldChanges, "status", null, member.getStatus());
appendFieldChange(fieldChanges, "joinedTime", null, member.getJoinedTime());
appendFieldChange(fieldChanges, "leftTime", null, member.getLeftTime());
appendFieldChange(fieldChanges, "remark", null, member.getRemark());
return JsonUtils.toJsonString(fieldChanges);
}
private String buildManagerFieldChanges(Long beforeManagerUserId, Long afterManagerUserId) {
Map<String, Object> fieldChanges = new LinkedHashMap<>();
appendFieldChange(fieldChanges, "managerUserId", beforeManagerUserId, afterManagerUserId);
return JsonUtils.toJsonString(fieldChanges);
}
private <T> T valueOf(ProductDO product, Function<ProductDO, T> getter) {
return product == null ? null : getter.apply(product);
}

View File

@@ -0,0 +1,42 @@
package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityPageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelinePageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelineRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingRespVO;
/**
* 产品设置 Service 接口
*/
public interface ProductSettingService {
/**
* 获取产品设置
*
* @param productId 产品编号
* @return 产品设置
*/
ProductSettingRespVO getProductSettings(Long productId);
/**
* 获取产品动态
*
* @param productId 产品编号
* @param reqVO 查询参数
* @return 产品动态分页
*/
PageResult<ProductActivityRespVO> getProductActivities(Long productId, ProductActivityPageReqVO reqVO);
/**
* 获取产品动态时间线分页
*
* @param productId 产品编号
* @param reqVO 查询参数
* @return 产品动态时间线分页
*/
PageResult<ProductActivityTimelineRespVO> getProductActivityTimelinePage(
Long productId, ProductActivityTimelinePageReqVO reqVO);
}

View File

@@ -0,0 +1,106 @@
package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityPageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelinePageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelineRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingBaseInfoRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingLifecycleRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingRespVO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
import com.njcn.rdms.module.project.dal.mysql.product.ProductMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
@Service
public class ProductSettingServiceImpl implements ProductSettingService {
private static final String PRODUCT_OBJECT_TYPE = "product";
private static final String PRODUCT_QUERY_PERMISSION = "project:product:query";
@Resource
private ProductMapper productMapper;
@Resource
private AdminUserApi adminUserApi;
@Resource
private ProductStatusViewService productStatusViewService;
@Resource
private ProductActivityQueryService productActivityQueryService;
@Resource
private ProductActivityTimelineQueryService productActivityTimelineQueryService;
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_QUERY_PERMISSION)
public ProductSettingRespVO getProductSettings(Long productId) {
ProductDO product = validateProductExists(productId);
ProductSettingRespVO respVO = new ProductSettingRespVO();
respVO.setBaseInfo(buildBaseInfo(product));
respVO.setLifecycle(buildLifecycle(product));
return respVO;
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_QUERY_PERMISSION)
public PageResult<ProductActivityRespVO> getProductActivities(Long productId, ProductActivityPageReqVO reqVO) {
validateProductExists(productId);
return productActivityQueryService.getProductActivities(productId, reqVO);
}
@Override
@CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId",
permission = PRODUCT_QUERY_PERMISSION)
public PageResult<ProductActivityTimelineRespVO> getProductActivityTimelinePage(
Long productId, ProductActivityTimelinePageReqVO reqVO) {
validateProductExists(productId);
return productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
}
private ProductDO validateProductExists(Long productId) {
if (productId == null) {
throw exception(ErrorCodeConstants.PRODUCT_NOT_EXISTS);
}
ProductDO product = productMapper.selectById(productId);
if (product == null) {
throw exception(ErrorCodeConstants.PRODUCT_NOT_EXISTS);
}
return product;
}
private ProductSettingBaseInfoRespVO buildBaseInfo(ProductDO product) {
ProductSettingBaseInfoRespVO baseInfo = new ProductSettingBaseInfoRespVO();
baseInfo.setId(product.getId());
baseInfo.setCode(product.getCode());
baseInfo.setDirectionCode(product.getDirectionCode());
baseInfo.setName(product.getName());
baseInfo.setManagerUserId(product.getManagerUserId());
baseInfo.setManagerUserNickname(getManagerNickname(product.getManagerUserId()));
baseInfo.setDescription(product.getDescription());
baseInfo.setStatusCode(product.getStatusCode());
baseInfo.setLastStatusReason(product.getLastStatusReason());
return baseInfo;
}
private ProductSettingLifecycleRespVO buildLifecycle(ProductDO product) {
return productStatusViewService.getLifecycle(product.getStatusCode(), product.getLastStatusReason());
}
private String getManagerNickname(Long managerUserId) {
if (managerUserId == null) {
return null;
}
CommonResult<AdminUserRespDTO> result = adminUserApi.getUser(managerUserId);
AdminUserRespDTO user = result == null ? null : result.getCheckedData();
return user == null ? null : user.getNickname();
}
}

View File

@@ -0,0 +1,60 @@
package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingActionRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingLifecycleRespVO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
@Service
public class ProductStatusViewService {
private static final String PRODUCT_OBJECT_TYPE = "product";
@Resource
private ObjectStatusModelMapper objectStatusModelMapper;
@Resource
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
public ProductSettingLifecycleRespVO getLifecycle(String statusCode, String lastStatusReason) {
ObjectStatusModelDO statusModel = objectStatusModelMapper
.selectByObjectTypeAndStatusCodeEnabled(PRODUCT_OBJECT_TYPE, statusCode);
if (statusModel == null) {
throw exception(ErrorCodeConstants.PRODUCT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED);
}
ProductSettingLifecycleRespVO lifecycle = new ProductSettingLifecycleRespVO();
lifecycle.setStatusCode(statusModel.getStatusCode());
lifecycle.setStatusName(statusModel.getStatusName());
lifecycle.setTerminal(statusModel.getTerminalFlag());
lifecycle.setAllowEdit(statusModel.getAllowEdit());
lifecycle.setLastStatusReason(lastStatusReason);
lifecycle.setAvailableActions(buildAvailableActions(statusCode));
return lifecycle;
}
private List<ProductSettingActionRespVO> buildAvailableActions(String statusCode) {
List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper
.selectListByObjectTypeAndFromStatus(PRODUCT_OBJECT_TYPE, statusCode);
if (transitions == null || transitions.isEmpty()) {
return Collections.emptyList();
}
return transitions.stream().map(transition -> {
ProductSettingActionRespVO action = new ProductSettingActionRespVO();
action.setActionCode(transition.getActionCode());
action.setActionName(transition.getActionName());
action.setNeedReason(transition.getNeedReason());
return action;
}).toList();
}
}

View File

@@ -0,0 +1,80 @@
package com.njcn.rdms.module.project.framework.security.aop;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import com.njcn.rdms.module.project.framework.security.service.ObjectPermissionService;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.aop.aspectj.annotation.AspectJProxyFactory;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ObjectPermissionAspectTest extends BaseMockitoUnitTest {
@Mock
private ObjectPermissionService objectPermissionService;
@Test
void around_shouldResolveProductIdFromMethodArgument() {
when(objectPermissionService.getObjectType()).thenReturn("product");
DemoService proxy = createProxy();
String result = proxy.getProduct(1001L);
assertEquals("ok", result);
verify(objectPermissionService, times(1))
.checkPermission(1001L, "project:product:query", false);
}
@Test
void around_shouldResolveProductIdFromReqVoAndPassMemberOnlyFlag() {
when(objectPermissionService.getObjectType()).thenReturn("product");
DemoService proxy = createProxy();
DemoReqVO reqVO = new DemoReqVO();
reqVO.setId(1002L);
String result = proxy.getProductContext(reqVO);
assertEquals("context", result);
verify(objectPermissionService, times(1))
.checkPermission(1002L, "", true);
}
private DemoService createProxy() {
AspectJProxyFactory proxyFactory = new AspectJProxyFactory(new DemoService());
proxyFactory.addAspect(new ObjectPermissionAspect(java.util.List.of(objectPermissionService)));
return proxyFactory.getProxy();
}
static class DemoService {
@CheckObjectPermission(objectType = "product", objectId = "#productId", permission = "project:product:query")
public String getProduct(Long productId) {
return "ok";
}
@CheckObjectPermission(objectType = "product", objectId = "#reqVO.id", memberOnly = true)
public String getProductContext(DemoReqVO reqVO) {
return "context";
}
}
static class DemoReqVO {
private Long id;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
}
}

View File

@@ -0,0 +1,114 @@
package com.njcn.rdms.module.project.framework.security.service;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.enums.permission.PermissionScopeTypeEnum;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.util.Set;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
@InjectMocks
private ProductObjectPermissionService permissionService;
@Mock
private UserObjectRoleMapper userObjectRoleMapper;
@Mock
private ObjectPermissionApi objectPermissionApi;
@Test
void checkPermission_whenMemberOnlyAndCurrentUserIsMember_shouldPass() {
Long productId = 1001L;
Long loginUserId = 2001L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
.thenReturn(createMember(productId, loginUserId, 3001L));
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
assertDoesNotThrow(() -> permissionService.checkPermission(productId, null, true));
}
verifyNoInteractions(objectPermissionApi);
}
@Test
void checkPermission_whenCurrentRolePermissionsContainTarget_shouldPass() {
Long productId = 1002L;
Long loginUserId = 2002L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
.thenReturn(createMember(productId, loginUserId, 3002L));
when(objectPermissionApi.getObjectRolePermissions(3002L,
PermissionScopeTypeEnum.OBJECT.getScopeType(), "product"))
.thenReturn(success(Set.of("project:product:query")));
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
assertDoesNotThrow(() -> permissionService.checkPermission(productId, "project:product:query", false));
}
}
@Test
void checkPermission_whenCurrentRoleDoesNotContainPermission_shouldThrowException() {
Long productId = 1003L;
Long loginUserId = 2003L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
.thenReturn(createMember(productId, loginUserId, 3003L));
when(objectPermissionApi.getObjectRolePermissions(3003L,
PermissionScopeTypeEnum.OBJECT.getScopeType(), "product"))
.thenReturn(success(Set.of("project:product:update")));
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
ServiceException ex = assertThrows(ServiceException.class,
() -> permissionService.checkPermission(productId, "project:product:delete", false));
assertEquals(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode());
}
}
@Test
void checkPermission_whenCurrentUserIsNotMember_shouldThrowException() {
Long productId = 1004L;
Long loginUserId = 2004L;
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
.thenReturn(null);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
ServiceException ex = assertThrows(ServiceException.class,
() -> permissionService.checkPermission(productId, "project:product:query", false));
assertEquals(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode());
}
verifyNoInteractions(objectPermissionApi);
}
private UserObjectRoleDO createMember(Long productId, Long loginUserId, Long roleId) {
UserObjectRoleDO member = new UserObjectRoleDO();
member.setId(9001L);
member.setUserId(loginUserId);
member.setObjectType("product");
member.setObjectId(productId);
member.setRoleId(roleId);
member.setStatus(0);
return member;
}
private MockedStatic<SecurityFrameworkUtils> mockLoginUser(Long loginUserId) {
MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class);
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
return mockedStatic;
}
}

View File

@@ -0,0 +1,213 @@
package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityPageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityRespVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductStatusLogDO;
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductStatusLogMapper;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
import org.junit.jupiter.api.Test;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ProductActivityQueryServiceTest extends BaseMockitoUnitTest {
@InjectMocks
private ProductActivityQueryService productActivityQueryService;
@Mock
private ProductStatusLogMapper productStatusLogMapper;
@Mock
private BizAuditLogMapper bizAuditLogMapper;
@Mock
private UserObjectRoleMapper userObjectRoleMapper;
@Mock
private CacheManager cacheManager;
@Mock
private Cache roleNameCache;
@Mock
private ObjectPermissionApi objectPermissionApi;
@Test
void getProductActivities_shouldMergeStatusProductAndMemberActivities() {
Long productId = 1001L;
ProductActivityPageReqVO reqVO = new ProductActivityPageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
ProductStatusLogDO statusLog = new ProductStatusLogDO();
statusLog.setId(11L);
statusLog.setProductId(productId);
statusLog.setActionType("pause");
statusLog.setOperatorName("张三");
statusLog.setReason("资源不足");
statusLog.setCreateTime(LocalDateTime.of(2026, 4, 21, 10, 0, 0));
BizAuditLogDO productAudit = new BizAuditLogDO();
productAudit.setId(22L);
productAudit.setBizType("product");
productAudit.setBizId(productId);
productAudit.setActionType("update");
productAudit.setOperatorName("李四");
productAudit.setCreateTime(LocalDateTime.of(2026, 4, 21, 11, 0, 0));
BizAuditLogDO memberAudit = new BizAuditLogDO();
memberAudit.setId(33L);
memberAudit.setBizType("rdms_user_object_role");
memberAudit.setBizId(9001L);
memberAudit.setActionType("add_member");
memberAudit.setOperatorName("王五");
memberAudit.setCreateTime(LocalDateTime.of(2026, 4, 21, 12, 0, 0));
UserObjectRoleDO member = new UserObjectRoleDO();
member.setId(9001L);
member.setObjectType("product");
member.setObjectId(productId);
when(productStatusLogMapper.selectListByProductId(productId, null, null))
.thenReturn(List.of(statusLog));
when(bizAuditLogMapper.selectListByBiz("product", productId, null, null))
.thenReturn(List.of(productAudit));
when(bizAuditLogMapper.selectListByBizType("rdms_user_object_role", null, null))
.thenReturn(List.of(memberAudit));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9001L), "product", productId))
.thenReturn(List.of(member));
PageResult<ProductActivityRespVO> result = productActivityQueryService.getProductActivities(productId, reqVO);
assertEquals(3L, result.getTotal());
assertEquals("member", result.getList().get(0).getType());
assertEquals("product", result.getList().get(1).getType());
assertEquals("status", result.getList().get(2).getType());
}
@Test
void getProductActivities_shouldIgnoreOrphanMemberAuditAndFilterByType() {
Long productId = 1002L;
ProductActivityPageReqVO reqVO = new ProductActivityPageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("member");
BizAuditLogDO orphanMemberAudit = new BizAuditLogDO();
orphanMemberAudit.setId(44L);
orphanMemberAudit.setBizType("rdms_user_object_role");
orphanMemberAudit.setBizId(9900L);
orphanMemberAudit.setActionType("remove_member");
orphanMemberAudit.setCreateTime(LocalDateTime.of(2026, 4, 21, 9, 0, 0));
when(bizAuditLogMapper.selectListByBizType("rdms_user_object_role", null, null))
.thenReturn(List.of(orphanMemberAudit));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9900L), "product", productId))
.thenReturn(List.of());
PageResult<ProductActivityRespVO> result = productActivityQueryService.getProductActivities(productId, reqVO);
assertEquals(0L, result.getTotal());
assertEquals(0, result.getList().size());
}
@Test
void getProductActivities_shouldAppendRoleNameToMemberDetails() {
Long productId = 1003L;
ProductActivityPageReqVO reqVO = new ProductActivityPageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("member");
BizAuditLogDO memberAudit = new BizAuditLogDO();
memberAudit.setId(55L);
memberAudit.setBizType("rdms_user_object_role");
memberAudit.setBizId(9002L);
memberAudit.setActionType("update_member");
memberAudit.setFieldChanges("{\"roleId\":{\"before\":3201,\"after\":3202}}");
memberAudit.setCreateTime(LocalDateTime.of(2026, 4, 21, 13, 0, 0));
UserObjectRoleDO member = new UserObjectRoleDO();
member.setId(9002L);
member.setObjectType("product");
member.setObjectId(productId);
when(bizAuditLogMapper.selectListByBizType("rdms_user_object_role", null, null))
.thenReturn(List.of(memberAudit));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9002L), "product", productId))
.thenReturn(List.of(member));
when(cacheManager.getCache("project_activity_role_name#10m")).thenReturn(roleNameCache);
when(roleNameCache.get(3201L, String.class)).thenReturn("产品经理");
when(roleNameCache.get(3202L, String.class)).thenReturn("产品成员");
PageResult<ProductActivityRespVO> result = productActivityQueryService.getProductActivities(productId, reqVO);
assertEquals("产品经理", JsonUtils.parseTree(result.getList().get(0).getDetails())
.path("roleName").path("before").asText());
assertEquals("产品成员", JsonUtils.parseTree(result.getList().get(0).getDetails())
.path("roleName").path("after").asText());
verify(objectPermissionApi, never()).getObjectRoleMap(anySet(), any(), any());
}
@Test
void getProductActivities_shouldLoadAndCacheRoleNameWhenCacheMiss() {
Long productId = 1004L;
ProductActivityPageReqVO reqVO = new ProductActivityPageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("member");
BizAuditLogDO memberAudit = new BizAuditLogDO();
memberAudit.setId(66L);
memberAudit.setBizType("rdms_user_object_role");
memberAudit.setBizId(9003L);
memberAudit.setActionType("add_member");
memberAudit.setFieldChanges("{\"roleId\":{\"before\":null,\"after\":3203}}");
memberAudit.setCreateTime(LocalDateTime.of(2026, 4, 21, 14, 0, 0));
UserObjectRoleDO member = new UserObjectRoleDO();
member.setId(9003L);
member.setObjectType("product");
member.setObjectId(productId);
when(bizAuditLogMapper.selectListByBizType("rdms_user_object_role", null, null))
.thenReturn(List.of(memberAudit));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9003L), "product", productId))
.thenReturn(List.of(member));
when(cacheManager.getCache("project_activity_role_name#10m")).thenReturn(roleNameCache);
when(roleNameCache.get(3203L, String.class)).thenReturn(null);
when(objectPermissionApi.getObjectRoleMap(java.util.Set.of(3203L), "object", "product"))
.thenReturn(Map.of(3203L, buildRole(3203L, "观察者")));
PageResult<ProductActivityRespVO> result = productActivityQueryService.getProductActivities(productId, reqVO);
assertEquals("观察者", JsonUtils.parseTree(result.getList().get(0).getDetails())
.path("roleName").path("after").asText());
verify(roleNameCache).put(3203L, "观察者");
}
private ObjectRoleRespDTO buildRole(Long id, String name) {
ObjectRoleRespDTO role = new ObjectRoleRespDTO();
role.setId(id);
role.setName(name);
role.setScopeType("object");
role.setObjectType("product");
return role;
}
}

View File

@@ -0,0 +1,424 @@
package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelinePageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelineRespVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductStatusLogDO;
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductStatusLogMapper;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import org.junit.jupiter.api.Test;
import org.springframework.beans.BeanWrapperImpl;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ProductActivityTimelineQueryServiceTest extends BaseMockitoUnitTest {
@InjectMocks
private ProductActivityTimelineQueryService productActivityTimelineQueryService;
@Mock
private ProductStatusLogMapper productStatusLogMapper;
@Mock
private BizAuditLogMapper bizAuditLogMapper;
@Mock
private UserObjectRoleMapper userObjectRoleMapper;
@Mock
private CacheManager cacheManager;
@Mock
private Cache userNicknameCache;
@Mock
private Cache roleNameCache;
@Mock
private AdminUserApi adminUserApi;
@Mock
private ObjectPermissionApi objectPermissionApi;
@Test
void getProductActivityTimelinePage_whenTimeRangeMissingEnd_shouldThrowInvalidParam() {
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setStartTime(LocalDateTime.of(2026, 4, 1, 0, 0, 0));
ServiceException ex = assertThrows(ServiceException.class,
() -> productActivityTimelineQueryService.getProductActivityTimelinePage(1001L, reqVO));
assertTrue(ex.getMessage().contains("开始时间和结束时间必须同时传入"));
}
@Test
void getProductActivityTimelinePage_shouldUseDefaultRecent30DaysAndActionTypes() {
Long productId = 1001L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("status");
reqVO.setActionTypes(List.of("pause", "archive"));
when(productStatusLogMapper.selectListByProductIdAndActions(eq(productId), eq(List.of("pause", "archive")),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of());
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
ArgumentCaptor<LocalDateTime> startCaptor = ArgumentCaptor.forClass(LocalDateTime.class);
ArgumentCaptor<LocalDateTime> endCaptor = ArgumentCaptor.forClass(LocalDateTime.class);
verify(productStatusLogMapper).selectListByProductIdAndActions(eq(productId), eq(List.of("pause", "archive")),
startCaptor.capture(), endCaptor.capture());
assertEquals(0L, result.getTotal());
assertTrue(Duration.between(startCaptor.getValue(), endCaptor.getValue()).toDays() >= 29);
assertTrue(Duration.between(startCaptor.getValue(), endCaptor.getValue()).toDays() <= 31);
}
@Test
void getProductActivityTimelinePage_shouldKeepSingleCreateAndIgnoreInitNoise() {
Long productId = 1002L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
BizAuditLogDO createLog = buildProductLog(21L, productId, "create",
"{\"managerUserId\":{\"before\":null,\"after\":2001}}",
LocalDateTime.of(2026, 4, 23, 10, 0, 0));
BizAuditLogDO managerInitLog = buildProductLog(22L, productId, "change_manager",
"{\"managerUserId\":{\"before\":null,\"after\":2001}}",
LocalDateTime.of(2026, 4, 23, 10, 0, 0));
BizAuditLogDO memberInitLog = buildMemberLog(31L, 9001L, "add_member",
"{\"userId\":{\"before\":null,\"after\":2001}}",
LocalDateTime.of(2026, 4, 23, 10, 0, 0));
when(productStatusLogMapper.selectListByProductIdAndActions(eq(productId), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of());
when(bizAuditLogMapper.selectListByBizAndActions(eq("product"), eq(productId), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(managerInitLog, createLog));
when(bizAuditLogMapper.selectListByBizTypeAndActions(eq("rdms_user_object_role"), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(memberInitLog));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9001L), "product", productId))
.thenReturn(List.of(buildMember(9001L, productId, 2001L)));
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
assertEquals(1L, result.getTotal());
assertEquals("product", result.getList().get(0).getType());
assertEquals("create", result.getList().get(0).getActionType());
}
@Test
void getProductActivityTimelinePage_shouldPreferStatusLogOverProductStatusAudit() {
Long productId = 1003L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
ProductStatusLogDO statusLog = buildStatusLog(11L, productId, "pause",
"active", "paused", LocalDateTime.of(2026, 4, 22, 9, 0, 0));
BizAuditLogDO statusAudit = buildProductLog(12L, productId, "pause", null,
LocalDateTime.of(2026, 4, 22, 9, 0, 0));
when(productStatusLogMapper.selectListByProductIdAndActions(eq(productId), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(statusLog));
when(bizAuditLogMapper.selectListByBizAndActions(eq("product"), eq(productId), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(statusAudit));
when(bizAuditLogMapper.selectListByBizTypeAndActions(eq("rdms_user_object_role"), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of());
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
assertEquals(1L, result.getTotal());
assertEquals("status:11", result.getList().get(0).getId());
}
@Test
void getProductActivityTimelinePage_shouldFilterMemberActionsByIntersection() {
Long productId = 1004L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("member");
reqVO.setActionTypes(List.of("remove_member"));
when(bizAuditLogMapper.selectListByBizTypeAndActions(eq("rdms_user_object_role"), eq(List.of("remove_member")),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of());
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
assertEquals(0L, result.getTotal());
verify(bizAuditLogMapper).selectListByBizTypeAndActions(eq("rdms_user_object_role"),
eq(List.of("remove_member")), any(LocalDateTime.class), any(LocalDateTime.class));
}
@Test
void getProductActivityTimelinePage_shouldExcludeUpdateMemberAction() {
Long productId = 1005L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
BizAuditLogDO updateMemberLog = buildMemberLog(41L, 9002L, "update_member",
"{\"remark\":{\"before\":\"\",\"after\":\"\"}}",
LocalDateTime.of(2026, 4, 21, 15, 0, 0));
when(productStatusLogMapper.selectListByProductIdAndActions(eq(productId), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of());
when(bizAuditLogMapper.selectListByBizAndActions(eq("product"), eq(productId), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of());
when(bizAuditLogMapper.selectListByBizTypeAndActions(eq("rdms_user_object_role"), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(updateMemberLog));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9002L), "product", productId))
.thenReturn(List.of(buildMember(9002L, productId, 2002L)));
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
assertEquals(0L, result.getTotal());
}
@Test
void getProductActivityTimelinePage_shouldExposeTargetUserIdForMemberActivity() {
Long productId = 1006L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("member");
BizAuditLogDO addMemberLog = buildMemberLog(51L, 9003L, "add_member",
"{\"userId\":{\"before\":null,\"after\":2003}}",
LocalDateTime.of(2026, 4, 21, 16, 0, 0));
UserObjectRoleDO member = buildMember(9003L, productId, 2003L);
when(bizAuditLogMapper.selectListByBizTypeAndActions(eq("rdms_user_object_role"), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(addMemberLog));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9003L), "product", productId))
.thenReturn(List.of(member));
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
assertEquals(1L, result.getTotal());
assertEquals(2003L, result.getList().get(0).getTargetUserId());
}
@Test
void getProductActivityTimelinePage_shouldReadTargetUserNameFromCacheFirst() {
Long productId = 1007L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("member");
BizAuditLogDO addMemberLog = buildMemberLog(61L, 9004L, "add_member",
"{\"userId\":{\"before\":null,\"after\":2004}}",
LocalDateTime.of(2026, 4, 21, 17, 0, 0));
UserObjectRoleDO member = buildMember(9004L, productId, 2004L);
when(bizAuditLogMapper.selectListByBizTypeAndActions(eq("rdms_user_object_role"), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(addMemberLog));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9004L), "product", productId))
.thenReturn(List.of(member));
when(cacheManager.getCache("project_timeline_user_nickname#10m")).thenReturn(userNicknameCache);
when(userNicknameCache.get(2004L, String.class)).thenReturn("成员丁");
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
assertEquals("成员丁", new BeanWrapperImpl(result.getList().get(0)).getPropertyValue("targetUserName"));
verify(adminUserApi, never()).getUserMap(anySet());
}
@Test
void getProductActivityTimelinePage_shouldLoadAndCacheTargetUserNameWhenCacheMiss() {
Long productId = 1008L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("member");
BizAuditLogDO removeMemberLog = buildMemberLog(71L, 9005L, "remove_member",
"{\"userId\":{\"before\":2005,\"after\":2005}}",
LocalDateTime.of(2026, 4, 21, 18, 0, 0));
UserObjectRoleDO member = buildMember(9005L, productId, 2005L);
when(bizAuditLogMapper.selectListByBizTypeAndActions(eq("rdms_user_object_role"), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(removeMemberLog));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9005L), "product", productId))
.thenReturn(List.of(member));
when(cacheManager.getCache("project_timeline_user_nickname#10m")).thenReturn(userNicknameCache);
when(userNicknameCache.get(2005L, String.class)).thenReturn(null);
when(adminUserApi.getUserMap(java.util.Set.of(2005L))).thenReturn(Map.of(2005L, buildUser(2005L, "成员戊")));
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
assertEquals("成员戊", new BeanWrapperImpl(result.getList().get(0)).getPropertyValue("targetUserName"));
verify(userNicknameCache).put(2005L, "成员戊");
}
@Test
void getProductActivityTimelinePage_shouldReadRoleNameFromCacheFirst() {
Long productId = 1009L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("member");
BizAuditLogDO addMemberLog = buildMemberLog(81L, 9006L, "add_member",
"{\"roleId\":{\"before\":null,\"after\":3101},\"userId\":{\"before\":null,\"after\":2006}}",
LocalDateTime.of(2026, 4, 21, 19, 0, 0));
when(bizAuditLogMapper.selectListByBizTypeAndActions(eq("rdms_user_object_role"), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(addMemberLog));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9006L), "product", productId))
.thenReturn(List.of(buildMember(9006L, productId, 2006L)));
when(cacheManager.getCache("project_timeline_user_nickname#10m")).thenReturn(userNicknameCache);
when(cacheManager.getCache("project_timeline_role_name#10m")).thenReturn(roleNameCache);
when(roleNameCache.get(3101L, String.class)).thenReturn("产品经理");
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
assertEquals("产品经理", JsonUtils.parseTree(result.getList().get(0).getDetails())
.path("roleName").path("after").asText());
verify(objectPermissionApi, never()).getObjectRoleMap(anySet(), any(), any());
}
@Test
void getProductActivityTimelinePage_shouldLoadAndCacheRoleNameWhenCacheMiss() {
Long productId = 1010L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setActivityType("member");
BizAuditLogDO removeMemberLog = buildMemberLog(91L, 9007L, "remove_member",
"{\"roleId\":{\"before\":3102,\"after\":3102},\"userId\":{\"before\":2007,\"after\":2007}}",
LocalDateTime.of(2026, 4, 21, 20, 0, 0));
when(bizAuditLogMapper.selectListByBizTypeAndActions(eq("rdms_user_object_role"), any(),
any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(List.of(removeMemberLog));
when(userObjectRoleMapper.selectListByIdsAndObject(List.of(9007L), "product", productId))
.thenReturn(List.of(buildMember(9007L, productId, 2007L)));
when(cacheManager.getCache("project_timeline_user_nickname#10m")).thenReturn(userNicknameCache);
when(cacheManager.getCache("project_timeline_role_name#10m")).thenReturn(roleNameCache);
when(roleNameCache.get(3102L, String.class)).thenReturn(null);
when(objectPermissionApi.getObjectRoleMap(java.util.Set.of(3102L), "object", "product"))
.thenReturn(Map.of(3102L, buildRole(3102L, "产品成员")));
PageResult<ProductActivityTimelineRespVO> result =
productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO);
assertEquals("产品成员", JsonUtils.parseTree(result.getList().get(0).getDetails())
.path("roleName").path("before").asText());
verify(roleNameCache).put(3102L, "产品成员");
}
private BizAuditLogDO buildProductLog(Long id, Long productId, String actionType,
String fieldChanges, LocalDateTime createTime) {
BizAuditLogDO log = new BizAuditLogDO();
log.setId(id);
log.setBizType("product");
log.setBizId(productId);
log.setActionType(actionType);
log.setFieldChanges(fieldChanges);
log.setOperatorUserId(100L);
log.setOperatorName("张三");
log.setCreateTime(createTime);
return log;
}
private BizAuditLogDO buildMemberLog(Long id, Long memberId, String actionType,
String fieldChanges, LocalDateTime createTime) {
BizAuditLogDO log = new BizAuditLogDO();
log.setId(id);
log.setBizType("rdms_user_object_role");
log.setBizId(memberId);
log.setActionType(actionType);
log.setFieldChanges(fieldChanges);
log.setOperatorUserId(100L);
log.setOperatorName("张三");
log.setCreateTime(createTime);
return log;
}
private ProductStatusLogDO buildStatusLog(Long id, Long productId, String actionType,
String fromStatus, String toStatus, LocalDateTime createTime) {
ProductStatusLogDO log = new ProductStatusLogDO();
log.setId(id);
log.setProductId(productId);
log.setActionType(actionType);
log.setFromStatus(fromStatus);
log.setToStatus(toStatus);
log.setOperatorUserId(101L);
log.setOperatorName("李四");
log.setCreateTime(createTime);
return log;
}
private UserObjectRoleDO buildMember(Long id, Long productId, Long userId) {
UserObjectRoleDO member = new UserObjectRoleDO();
member.setId(id);
member.setObjectType("product");
member.setObjectId(productId);
member.setUserId(userId);
return member;
}
private AdminUserRespDTO buildUser(Long id, String nickname) {
AdminUserRespDTO user = new AdminUserRespDTO();
user.setId(id);
user.setNickname(nickname);
return user;
}
private ObjectRoleRespDTO buildRole(Long id, String name) {
ObjectRoleRespDTO role = new ObjectRoleRespDTO();
role.setId(id);
role.setName(name);
role.setScopeType("object");
role.setObjectType("product");
return role;
}
}

View File

@@ -0,0 +1,166 @@
package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberUpdateReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ProductMemberServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ProductMemberServiceImpl productMemberService;
@Mock
private ProductMapper productMapper;
@Mock
private UserObjectRoleMapper userObjectRoleMapper;
@Mock
private ObjectPermissionApi objectPermissionApi;
@Mock
private BizAuditLogMapper bizAuditLogMapper;
@Mock
private AdminUserApi adminUserApi;
@Test
void getProductMemberList_shouldFillRoleNameAndRoleCodeFromObjectPermissionApi() {
Long productId = 1001L;
ProductDO product = createProduct(productId, 2001L);
UserObjectRoleDO manager = createMember(9001L, productId, 2001L, 3101L, 0);
UserObjectRoleDO member = createMember(9002L, productId, 2002L, 3102L, 0);
when(productMapper.selectById(productId)).thenReturn(product);
when(userObjectRoleMapper.selectListByObject("product", productId)).thenReturn(List.of(manager, member));
when(objectPermissionApi.getObjectRoleList(Set.of(3101L, 3102L), "object", "product"))
.thenReturn(success(List.of(
createRole(3101L, "product_manager", "产品经理"),
createRole(3102L, "product_member", "产品成员")
)));
when(adminUserApi.getUserMap(Set.of(2001L, 2002L))).thenReturn(Map.of(
2001L, createUser("经理甲"),
2002L, createUser("成员乙")
));
List<ProductMemberRespVO> respVOList = productMemberService.getProductMemberList(productId);
assertEquals(2, respVOList.size());
assertEquals("产品经理", respVOList.get(0).getRoleName());
assertEquals("product_manager", respVOList.get(0).getRoleCode());
assertEquals(Boolean.TRUE, respVOList.get(0).getManagerFlag());
assertEquals("产品成员", respVOList.get(1).getRoleName());
assertEquals("product_member", respVOList.get(1).getRoleCode());
assertFalse(respVOList.get(1).getManagerFlag());
}
@Test
void createProductMember_whenRoleSummaryMissing_shouldThrowRoleInvalid() {
Long productId = 1002L;
ProductMemberSaveReqVO reqVO = new ProductMemberSaveReqVO();
reqVO.setUserId(2003L);
reqVO.setRoleId(3999L);
when(productMapper.selectById(productId)).thenReturn(createProduct(productId, 2001L));
when(objectPermissionApi.getObjectRoleById(3999L, "object", "product")).thenReturn(success(null));
ServiceException ex = assertThrows(ServiceException.class,
() -> productMemberService.createProductMember(productId, reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_MEMBER_ROLE_INVALID.getCode(), ex.getCode());
verify(userObjectRoleMapper, never()).selectByObjectAndUserId(any(), any(), any());
}
@Test
void updateProductMember_whenPreviousManagerRoleCodeStillManager_shouldThrowException() {
Long productId = 1003L;
Long memberId = 9003L;
Long currentManagerUserId = 2001L;
Long targetManagerRoleId = 3201L;
Long previousManagerRoleId = 3202L;
ProductMemberUpdateReqVO reqVO = new ProductMemberUpdateReqVO();
reqVO.setRoleId(targetManagerRoleId);
reqVO.setPreviousManagerUserId(currentManagerUserId);
reqVO.setPreviousManagerRoleId(previousManagerRoleId);
reqVO.setReason("角色交接");
ProductDO product = createProduct(productId, currentManagerUserId);
UserObjectRoleDO member = createMember(memberId, productId, 2002L, 3203L, 0);
when(productMapper.selectById(productId)).thenReturn(product);
when(userObjectRoleMapper.selectByIdAndObject(memberId, "product", productId)).thenReturn(member);
when(objectPermissionApi.getObjectRoleById(targetManagerRoleId, "object", "product"))
.thenReturn(success(createRole(targetManagerRoleId, "product_manager", "产品经理")));
when(objectPermissionApi.getObjectRoleById(previousManagerRoleId, "object", "product"))
.thenReturn(success(createRole(previousManagerRoleId, "product_manager", "产品经理")));
ServiceException ex = assertThrows(ServiceException.class,
() -> productMemberService.updateProductMember(productId, memberId, reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_MANAGER_TRANSFER_ROLE_INVALID.getCode(), ex.getCode());
verify(userObjectRoleMapper).updateById(member);
verify(productMapper, never()).updateById(any(ProductDO.class));
verify(bizAuditLogMapper, never()).insert(any(BizAuditLogDO.class));
}
private ProductDO createProduct(Long productId, Long managerUserId) {
ProductDO product = new ProductDO();
product.setId(productId);
product.setManagerUserId(managerUserId);
product.setStatusCode("active");
product.setName("测试产品");
product.setCode("CNPD2026001");
return product;
}
private UserObjectRoleDO createMember(Long memberId, Long productId, Long userId, Long roleId, Integer status) {
UserObjectRoleDO member = new UserObjectRoleDO();
member.setId(memberId);
member.setObjectType("product");
member.setObjectId(productId);
member.setUserId(userId);
member.setRoleId(roleId);
member.setStatus(status);
member.setJoinedTime(LocalDateTime.now());
return member;
}
private ObjectRoleRespDTO createRole(Long roleId, String roleCode, String roleName) {
ObjectRoleRespDTO role = new ObjectRoleRespDTO();
role.setId(roleId);
role.setCode(roleCode);
role.setName(roleName);
role.setScopeType("object");
role.setObjectType("product");
return role;
}
private AdminUserRespDTO createUser(String nickname) {
AdminUserRespDTO user = new AdminUserRespDTO();
user.setNickname(nickname);
return user;
}
}

View File

@@ -0,0 +1,671 @@
package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.product.vo.requirement.*;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementModuleDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementStatusLogDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementModuleMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementStatusLogMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* 产品需求 Service 单元测试
*/
class ProductRequirementServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ProductRequirementServiceImpl requirementService;
@Mock
private ProductRequirementMapper requirementMapper;
@Mock
private ProductRequirementModuleMapper moduleMapper;
@Mock
private ProductRequirementStatusLogMapper statusLogMapper;
@Mock
private BizAuditLogMapper bizAuditLogMapper;
@Mock
private ObjectStatusTransitionMapper statusTransitionMapper;
@Mock
private ObjectStatusModelMapper statusModelMapper;
// ========== 创建需求测试 ==========
@Test
void createRequirement_withoutReview_shouldCreateWithPendingDispatchStatus() {
Long loginUserId = 1001L;
Long defaultModuleId = 50L;
ProductRequirementSaveReqVO reqVO = new ProductRequirementSaveReqVO();
reqVO.setProductId(100L);
reqVO.setTitle("测试需求");
reqVO.setDescription("测试描述");
reqVO.setCategory("function");
reqVO.setReviewRequired(0); // 不需要评审
reqVO.setPriority(1);
reqVO.setProposerId(2001L);
reqVO.setCurrentHandlerUserId(2002L);
reqVO.setCompletionDate(LocalDateTime.now());
// 模拟"全部需求"模块存在parentId = 0L 的根模块)
ProductRequirementModuleDO defaultModule = createDefaultModule(defaultModuleId, 100L);
when(moduleMapper.selectByProductIdAndParentId(100L, 0L)).thenReturn(defaultModule);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.createRequirement(reqVO);
}
ArgumentCaptor<ProductRequirementDO> captor = ArgumentCaptor.forClass(ProductRequirementDO.class);
verify(requirementMapper, times(1)).insert(captor.capture());
ProductRequirementDO created = captor.getValue();
assertEquals("pending_dispatch", created.getStatusCode()); // 不需要评审时初始状态为待分流
assertEquals("manual", created.getSourceType());
assertEquals(0L, created.getParentId());
assertEquals("测试需求", created.getTitle());
assertEquals(defaultModuleId, created.getModuleId()); // 未选择模块时自动归属到"全部需求"模块
assertEquals(100L, created.getProductId());
}
@Test
void createRequirement_withReview_shouldCreateWithPendingReviewStatus() {
Long loginUserId = 1001L;
Long defaultModuleId = 50L;
ProductRequirementSaveReqVO reqVO = new ProductRequirementSaveReqVO();
reqVO.setProductId(100L);
reqVO.setTitle("需评审的需求");
reqVO.setCategory("function");
reqVO.setReviewRequired(1); // 需要评审
reqVO.setPriority(2);
reqVO.setProposerId(2001L);
reqVO.setCompletionDate(LocalDateTime.now());
// 模拟"全部需求"模块存在parentId = 0L 的根模块)
ProductRequirementModuleDO defaultModule = createDefaultModule(defaultModuleId, 100L);
when(moduleMapper.selectByProductIdAndParentId(100L, 0L)).thenReturn(defaultModule);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.createRequirement(reqVO);
}
ArgumentCaptor<ProductRequirementDO> captor = ArgumentCaptor.forClass(ProductRequirementDO.class);
verify(requirementMapper, times(1)).insert(captor.capture());
ProductRequirementDO created = captor.getValue();
assertEquals("pending_review", created.getStatusCode()); // 需要评审时初始状态为待评审
assertEquals(defaultModuleId, created.getModuleId()); // 未选择模块时自动归属到"全部需求"模块
}
// ========== 更新需求测试 ==========
@Test
void updateRequirement_whenTerminalStatus_shouldThrowException() {
Long requirementId = 1001L;
ProductRequirementDO requirement = createRequirement(requirementId, 100L, "已关闭需求",
"closed", 0L, 0);
ProductRequirementUpdateReqVO reqVO = new ProductRequirementUpdateReqVO();
reqVO.setId(requirementId);
reqVO.setProductId(100L);
reqVO.setTitle("修改标题");
reqVO.setCategory("function");
reqVO.setReviewRequired(0);
reqVO.setPriority(1);
reqVO.setProposerId(2001L);
reqVO.setCompletionDate(LocalDateTime.now());
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.updateRequirement(reqVO));
assertEquals(ErrorCodeConstants.REQUIREMENT_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode());
verify(requirementMapper, never()).updateById(any(ProductRequirementDO.class));
}
@Test
void updateRequirement_whenNormalStatus_shouldUpdateSuccessfully() {
Long requirementId = 1002L;
Long loginUserId = 1001L;
Long defaultModuleId = 50L;
ProductRequirementDO requirement = createRequirement(requirementId, 100L, "待分流需求",
"pending_dispatch", 0L, 0);
ProductRequirementUpdateReqVO reqVO = new ProductRequirementUpdateReqVO();
reqVO.setId(requirementId);
reqVO.setProductId(100L);
reqVO.setTitle("修改后的标题");
reqVO.setCategory("security");
reqVO.setReviewRequired(0);
reqVO.setPriority(2);
reqVO.setProposerId(2001L);
reqVO.setCompletionDate(LocalDateTime.now());
// 模拟"全部需求"模块存在未选择模块时自动归属parentId = 0L 的根模块)
ProductRequirementModuleDO defaultModule = createDefaultModule(defaultModuleId, 100L);
when(moduleMapper.selectByProductIdAndParentId(100L, 0L)).thenReturn(defaultModule);
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.updateRequirement(reqVO);
}
ArgumentCaptor<ProductRequirementDO> captor = ArgumentCaptor.forClass(ProductRequirementDO.class);
verify(requirementMapper, times(1)).updateById(captor.capture());
assertEquals(defaultModuleId, captor.getValue().getModuleId()); // 未选择模块时自动归属到"全部需求"模块
verify(bizAuditLogMapper, times(1)).insert(any(BizAuditLogDO.class));
}
// ========== 状态变更测试 ==========
@Test
void changeRequirementStatus_whenActionAllowed_shouldUpdateStatusAndWriteLogs() {
Long requirementId = 1003L;
Long loginUserId = 1001L;
ProductRequirementDO requirement = createRequirement(requirementId, 100L, "待分流需求",
"pending_dispatch", 0L, 0);
ProductRequirementStatusActionReqVO reqVO = new ProductRequirementStatusActionReqVO();
reqVO.setId(requirementId);
reqVO.setActionCode("dispatch");
reqVO.setReason(null);
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
when(statusTransitionMapper.selectByObjectTypeAndFromStatusAndAction(
"product_requirement", "pending_dispatch", "dispatch"))
.thenReturn(createTransition("dispatch", "implementing", false));
when(requirementMapper.updateStatusByIdAndStatus(requirementId, "pending_dispatch", "implementing", null))
.thenReturn(1);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.changeRequirementStatus(reqVO);
}
verify(requirementMapper, times(1))
.updateStatusByIdAndStatus(requirementId, "pending_dispatch", "implementing", null);
verify(statusLogMapper, times(1)).insert(any(ProductRequirementStatusLogDO.class));
verify(bizAuditLogMapper, times(1)).insert(any(BizAuditLogDO.class));
}
@Test
void changeRequirementStatus_whenActionNotAllowed_shouldThrowException() {
Long requirementId = 1004L;
ProductRequirementDO requirement = createRequirement(requirementId, 100L, "已关闭需求",
"closed", 0L, 0);
ProductRequirementStatusActionReqVO reqVO = new ProductRequirementStatusActionReqVO();
reqVO.setId(requirementId);
reqVO.setActionCode("dispatch");
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
when(statusTransitionMapper.selectByObjectTypeAndFromStatusAndAction(
"product_requirement", "closed", "dispatch"))
.thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.changeRequirementStatus(reqVO));
assertEquals(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_NOT_ALLOWED.getCode(), ex.getCode());
}
@Test
void changeRequirementStatus_whenReasonRequiredButMissing_shouldThrowException() {
Long requirementId = 1005L;
ProductRequirementDO requirement = createRequirement(requirementId, 100L, "待分流需求",
"pending_dispatch", 0L, 0);
ProductRequirementStatusActionReqVO reqVO = new ProductRequirementStatusActionReqVO();
reqVO.setId(requirementId);
reqVO.setActionCode("cancel");
reqVO.setReason(" "); // 空原因
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
when(statusTransitionMapper.selectByObjectTypeAndFromStatusAndAction(
"product_requirement", "pending_dispatch", "cancel"))
.thenReturn(createTransition("cancel", "cancelled", true));
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.changeRequirementStatus(reqVO));
assertEquals(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_REASON_REQUIRED.getCode(), ex.getCode());
}
@Test
void changeRequirementStatus_whenConcurrentModified_shouldThrowException() {
Long requirementId = 1006L;
Long loginUserId = 1001L;
ProductRequirementDO requirement = createRequirement(requirementId, 100L, "待分流需求",
"pending_dispatch", 0L, 0);
ProductRequirementStatusActionReqVO reqVO = new ProductRequirementStatusActionReqVO();
reqVO.setId(requirementId);
reqVO.setActionCode("dispatch");
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
when(statusTransitionMapper.selectByObjectTypeAndFromStatusAndAction(
"product_requirement", "pending_dispatch", "dispatch"))
.thenReturn(createTransition("dispatch", "implementing", false));
when(requirementMapper.updateStatusByIdAndStatus(requirementId, "pending_dispatch", "implementing", null))
.thenReturn(0); // 并发修改,更新失败
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.changeRequirementStatus(reqVO));
assertEquals(ErrorCodeConstants.REQUIREMENT_STATUS_CONCURRENT_MODIFIED.getCode(), ex.getCode());
}
verify(statusLogMapper, never()).insert(any(ProductRequirementStatusLogDO.class));
verify(bizAuditLogMapper, never()).insert(any(BizAuditLogDO.class));
}
// ========== 删除需求测试 ==========
@Test
void deleteRequirement_shouldDeleteByConditionAndWriteLogs() {
Long requirementId = 1007L;
Long loginUserId = 1001L;
ProductRequirementDO requirement = createRequirement(requirementId, 100L, "待分流需求",
"pending_dispatch", 0L, 0);
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
when(requirementMapper.deleteByIdAndStatus(requirementId, "pending_dispatch")).thenReturn(1);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.deleteRequirement(requirementId, 100L);
}
verify(requirementMapper, times(1)).deleteByIdAndStatus(requirementId, "pending_dispatch");
verify(bizAuditLogMapper, times(1)).insert(any(BizAuditLogDO.class));
}
// ========== 拆分需求测试 ==========
@Test
void splitRequirement_whenParentPendingDispatch_shouldChangeParentToImplementing() {
Long parentId = 1008L;
Long loginUserId = 1001L;
ProductRequirementDO parent = createRequirement(parentId, 100L, "大需求",
"pending_dispatch", 0L, 0);
parent.setModuleId(50L);
parent.setProposerId(2001L);
ProductRequirementSplitReqVO reqVO = new ProductRequirementSplitReqVO();
reqVO.setParentId(parentId);
reqVO.setProductId(100L);
reqVO.setTitle("子需求");
reqVO.setCategory("function");
reqVO.setPriority(1);
reqVO.setCompletionDate(LocalDateTime.now());
when(requirementMapper.selectById(parentId)).thenReturn(parent);
when(requirementMapper.updateStatusByIdAndStatus(parentId, "pending_dispatch", "implementing", null))
.thenReturn(1);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.splitRequirement(reqVO);
}
// 验证子需求被创建
ArgumentCaptor<ProductRequirementDO> childCaptor = ArgumentCaptor.forClass(ProductRequirementDO.class);
verify(requirementMapper, times(1)).insert(childCaptor.capture());
ProductRequirementDO child = childCaptor.getValue();
assertEquals(parentId, child.getParentId());
assertEquals("pending_dispatch", child.getStatusCode());
assertEquals(parent.getModuleId(), child.getModuleId());
assertEquals(parent.getProposerId(), child.getProposerId());
// 验证父需求状态变为实施中
verify(requirementMapper, times(1))
.updateStatusByIdAndStatus(parentId, "pending_dispatch", "implementing", null);
verify(statusLogMapper, times(1)).insert(any(ProductRequirementStatusLogDO.class));
}
@Test
void splitRequirement_whenParentImplementing_shouldNotChangeParentStatus() {
Long parentId = 1009L;
Long loginUserId = 1001L;
ProductRequirementDO parent = createRequirement(parentId, 100L, "实施中的大需求",
"implementing", 0L, 0);
parent.setModuleId(50L);
parent.setProposerId(2001L);
ProductRequirementSplitReqVO reqVO = new ProductRequirementSplitReqVO();
reqVO.setParentId(parentId);
reqVO.setProductId(100L);
reqVO.setTitle("子需求2");
reqVO.setCategory("function");
reqVO.setPriority(1);
reqVO.setCompletionDate(LocalDateTime.now());
when(requirementMapper.selectById(parentId)).thenReturn(parent);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.splitRequirement(reqVO);
}
// 父需求已经是实施中,不需要再更新状态
verify(requirementMapper, never())
.updateStatusByIdAndStatus(parentId, "implementing", "implementing", null);
}
@Test
void splitRequirement_whenParentNotAllowSplit_shouldThrowException() {
Long parentId = 1010L;
ProductRequirementDO parent = createRequirement(parentId, 100L, "已验收的大需求",
"accepted", 0L, 0);
ProductRequirementSplitReqVO reqVO = new ProductRequirementSplitReqVO();
reqVO.setParentId(parentId);
reqVO.setProductId(100L);
reqVO.setTitle("子需求");
reqVO.setCategory("function");
reqVO.setPriority(1);
reqVO.setCompletionDate(LocalDateTime.now());
when(requirementMapper.selectById(parentId)).thenReturn(parent);
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.splitRequirement(reqVO));
assertEquals(ErrorCodeConstants.REQUIREMENT_PARENT_NOT_ALLOW_SPLIT.getCode(), ex.getCode());
verify(requirementMapper, never()).insert(any(ProductRequirementDO.class));
}
// ========== 关闭需求测试 ==========
@Test
void closeRequirement_whenAccepted_shouldCloseSuccessfully() {
Long requirementId = 1011L;
Long loginUserId = 1001L;
ProductRequirementDO requirement = createRequirement(requirementId, 100L, "已验收需求",
"accepted", 0L, 0);
ProductRequirementCloseReqVO reqVO = new ProductRequirementCloseReqVO();
reqVO.setId(requirementId);
reqVO.setProductId(100L);
reqVO.setReason("需求已完成");
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
when(requirementMapper.selectListByParentId(requirementId)).thenReturn(Collections.emptyList());
when(requirementMapper.updateStatusByIdAndStatus(requirementId, "accepted", "closed", "需求已完成"))
.thenReturn(1);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.closeRequirement(reqVO);
}
verify(requirementMapper, times(1))
.updateStatusByIdAndStatus(requirementId, "accepted", "closed", "需求已完成");
verify(statusLogMapper, times(1)).insert(any(ProductRequirementStatusLogDO.class));
verify(bizAuditLogMapper, times(1)).insert(any(BizAuditLogDO.class));
}
@Test
void closeRequirement_whenNotAccepted_shouldThrowException() {
Long requirementId = 1012L;
ProductRequirementDO requirement = createRequirement(requirementId, 100L, "实施中需求",
"implementing", 0L, 0);
ProductRequirementCloseReqVO reqVO = new ProductRequirementCloseReqVO();
reqVO.setId(requirementId);
reqVO.setProductId(100L);
reqVO.setReason("需求已完成");
when(requirementMapper.selectById(requirementId)).thenReturn(requirement);
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.closeRequirement(reqVO));
assertEquals(ErrorCodeConstants.REQUIREMENT_STATUS_NOT_ALLOW_CLOSE.getCode(), ex.getCode());
}
@Test
void closeRequirement_withChildren_shouldCascadeCloseAcceptedChildren() {
Long parentId = 1013L;
Long childId = 1014L;
Long loginUserId = 1001L;
ProductRequirementDO parent = createRequirement(parentId, 100L, "父需求",
"accepted", 0L, 0);
ProductRequirementDO child = createRequirement(childId, 100L, "子需求",
"accepted", parentId, 0);
ProductRequirementCloseReqVO reqVO = new ProductRequirementCloseReqVO();
reqVO.setId(parentId);
reqVO.setProductId(100L);
reqVO.setReason("全部验收完成");
when(requirementMapper.selectById(parentId)).thenReturn(parent);
when(requirementMapper.selectListByParentId(parentId)).thenReturn(List.of(child));
when(requirementMapper.updateStatusByIdAndStatus(childId, "accepted", "closed", "全部验收完成"))
.thenReturn(1);
when(requirementMapper.updateStatusByIdAndStatus(parentId, "accepted", "closed", "全部验收完成"))
.thenReturn(1);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.closeRequirement(reqVO);
}
// 子需求被关闭
verify(requirementMapper, times(1))
.updateStatusByIdAndStatus(childId, "accepted", "closed", "全部验收完成");
// 父需求被关闭
verify(requirementMapper, times(1))
.updateStatusByIdAndStatus(parentId, "accepted", "closed", "全部验收完成");
}
@Test
void closeRequirement_withChildNotAllowClose_shouldThrowException() {
Long parentId = 1015L;
ProductRequirementDO parent = createRequirement(parentId, 100L, "父需求",
"accepted", 0L, 0);
ProductRequirementDO child = createRequirement(1016L, 100L, "实施中子需求",
"implementing", parentId, 0);
ProductRequirementCloseReqVO reqVO = new ProductRequirementCloseReqVO();
reqVO.setId(parentId);
reqVO.setProductId(100L);
reqVO.setReason("全部验收完成");
when(requirementMapper.selectById(parentId)).thenReturn(parent);
when(requirementMapper.selectListByParentId(parentId)).thenReturn(List.of(child));
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.closeRequirement(reqVO));
assertEquals(ErrorCodeConstants.REQUIREMENT_CHILD_NOT_ALLOW_CLOSE.getCode(), ex.getCode());
}
// ========== 模块管理测试 ==========
@Test
void createRequirementModule_whenNameDuplicate_shouldThrowException() {
Long productId = 100L;
ProductRequirementModuleDO existModule = new ProductRequirementModuleDO();
existModule.setId(50L);
existModule.setProductId(productId);
existModule.setModuleName("核心功能");
ProductRequirementModuleReqVO reqVO = new ProductRequirementModuleReqVO();
reqVO.setProductId(productId);
reqVO.setModuleName("核心功能");
when(moduleMapper.selectByProductIdAndModuleName(productId, "核心功能")).thenReturn(existModule);
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.createRequirementModule(reqVO));
assertEquals(ErrorCodeConstants.REQUIREMENT_MODULE_NAME_DUPLICATE.getCode(), ex.getCode());
}
@Test
void deleteRequirementModule_whenHasNonTerminalRequirements_shouldThrowException() {
Long moduleId = 50L;
Long productId = 100L;
ProductRequirementModuleDO module = new ProductRequirementModuleDO();
module.setId(moduleId);
module.setProductId(productId);
module.setModuleName("核心功能");
ProductRequirementDO nonTerminalReq = createRequirement(2001L, productId, "实施中需求",
"implementing", 0L, 0);
nonTerminalReq.setModuleId(moduleId);
when(moduleMapper.selectById(moduleId)).thenReturn(module);
when(requirementMapper.selectListByModuleId(moduleId)).thenReturn(List.of(nonTerminalReq));
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.deleteRequirementModule(moduleId, productId));
assertEquals(ErrorCodeConstants.REQUIREMENT_MODULE_HAS_NON_TERMINAL_REQUIREMENTS.getCode(), ex.getCode());
verify(moduleMapper, never()).deleteById(moduleId);
}
@Test
void deleteRequirementModule_whenAllTerminal_shouldDeleteSuccessfully() {
Long moduleId = 50L;
Long productId = 100L;
Long loginUserId = 1001L;
ProductRequirementModuleDO module = new ProductRequirementModuleDO();
module.setId(moduleId);
module.setProductId(productId);
module.setModuleName("核心功能");
ProductRequirementDO closedReq = createRequirement(2001L, productId, "已关闭需求",
"closed", 0L, 0);
closedReq.setModuleId(moduleId);
when(moduleMapper.selectById(moduleId)).thenReturn(module);
when(requirementMapper.selectListByModuleId(moduleId)).thenReturn(List.of(closedReq));
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
requirementService.deleteRequirementModule(moduleId, productId);
}
verify(requirementMapper, times(1)).deleteById(2001L);
verify(moduleMapper, times(1)).deleteById(moduleId);
}
// ========== resolveModuleId 测试 ==========
@Test
void resolveModuleId_whenModuleIdIsNull_shouldReturnDefaultModuleId() {
Long defaultModuleId = 50L;
Long productId = 100L;
ProductRequirementModuleDO defaultModule = createDefaultModule(defaultModuleId, productId);
when(moduleMapper.selectByProductIdAndParentId(productId, 0L)).thenReturn(defaultModule);
Long result = requirementService.resolveModuleId(null, productId);
assertEquals(defaultModuleId, result);
}
@Test
void resolveModuleId_whenModuleIdProvided_shouldReturnSameId() {
Long moduleId = 60L;
Long productId = 100L;
// 传入有效moduleId时直接返回不查询数据库
Long result = requirementService.resolveModuleId(moduleId, productId);
assertEquals(moduleId, result);
verify(moduleMapper, never()).selectByProductIdAndParentId(any(), any());
}
// ========== isAllRequirementsModule 测试 ==========
@Test
void isAllRequirementsModule_whenRootModule_shouldReturnTrue() {
Long moduleId = 50L;
ProductRequirementModuleDO rootModule = createDefaultModule(moduleId, 100L);
when(moduleMapper.selectById(moduleId)).thenReturn(rootModule);
boolean result = requirementService.isAllRequirementsModule(moduleId);
assertEquals(true, result);
}
@Test
void isAllRequirementsModule_whenChildModule_shouldReturnFalse() {
Long moduleId = 60L;
ProductRequirementModuleDO childModule = new ProductRequirementModuleDO();
childModule.setId(moduleId);
childModule.setProductId(100L);
childModule.setParentId(50L); // parentId != 0
childModule.setModuleName("子模块");
when(moduleMapper.selectById(moduleId)).thenReturn(childModule);
boolean result = requirementService.isAllRequirementsModule(moduleId);
assertEquals(false, result);
}
@Test
void isAllRequirementsModule_whenModuleNotExists_shouldReturnFalse() {
Long moduleId = 70L;
when(moduleMapper.selectById(moduleId)).thenReturn(null);
boolean result = requirementService.isAllRequirementsModule(moduleId);
assertEquals(false, result);
}
@Test
void resolveModuleId_whenDefaultModuleNotExists_shouldThrowException() {
Long productId = 100L;
when(moduleMapper.selectByProductIdAndParentId(productId, 0L)).thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> requirementService.resolveModuleId(null, productId));
assertEquals(ErrorCodeConstants.REQUIREMENT_MODULE_NOT_EXISTS.getCode(), ex.getCode());
}
// ========== 辅助方法 ==========
private ProductRequirementDO createRequirement(Long id, Long productId, String title,
String statusCode, Long parentId, Integer reviewRequired) {
ProductRequirementDO requirement = new ProductRequirementDO();
requirement.setId(id);
requirement.setProductId(productId);
requirement.setTitle(title);
requirement.setStatusCode(statusCode);
requirement.setParentId(parentId);
requirement.setReviewRequired(reviewRequired);
requirement.setCategory("function");
requirement.setSourceType("manual");
requirement.setPriority(1);
requirement.setProposerId(2001L);
requirement.setSort(0);
return requirement;
}
private ProductRequirementModuleDO createDefaultModule(Long id, Long productId) {
ProductRequirementModuleDO module = new ProductRequirementModuleDO();
module.setId(id);
module.setProductId(productId);
module.setParentId(0L);
module.setModuleName("全部需求");
module.setRemark("自动创建的模块");
module.setSort(0);
return module;
}
private ObjectStatusTransitionDO createTransition(String actionCode, String toStatus, boolean needReason) {
ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
transition.setActionCode(actionCode);
transition.setToStatusCode(toStatus);
transition.setNeedReason(needReason);
return transition;
}
private MockedStatic<SecurityFrameworkUtils> mockLoginUser(Long loginUserId, String nickname) {
MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class);
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
mockedStatic.when(SecurityFrameworkUtils::getLoginUserNickname).thenReturn(nickname);
return mockedStatic;
}
}

View File

@@ -0,0 +1,521 @@
package com.njcn.rdms.module.project.service.product;
import com.fasterxml.jackson.databind.JsonNode;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductStatusActionReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingBaseInfoUpdateReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductStatusLogDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductStatusLogMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectMenuRespDTO;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRolePermissionRespDTO;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.MockedStatic;
import org.mockito.Mock;
import java.util.List;
import java.util.Set;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ProductServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ProductServiceImpl productService;
@Mock
private ProductMapper productMapper;
@Mock
private ProductStatusLogMapper productStatusLogMapper;
@Mock
private BizAuditLogMapper bizAuditLogMapper;
@Mock
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
@Mock
private UserObjectRoleMapper userObjectRoleMapper;
@Mock
private ObjectPermissionApi objectPermissionApi;
@Mock
private AdminUserApi adminUserApi;
@Mock
private com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementModuleMapper requirementModuleMapper;
@Test
void createProduct_shouldCreateDefaultRequirementModule() {
Long loginUserId = 3001L;
ProductSaveReqVO reqVO = new ProductSaveReqVO();
reqVO.setDirectionCode("direction_value");
reqVO.setName("新产品");
reqVO.setManagerUserId(2001L);
reqVO.setDescription("产品描述");
when(productMapper.selectByName("新产品")).thenReturn(null);
when(adminUserApi.getUser(2001L)).thenReturn(
success(new com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO()));
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
productService.createProduct(reqVO);
}
// 验证产品已创建
ArgumentCaptor<ProductDO> productCaptor = ArgumentCaptor.forClass(ProductDO.class);
verify(productMapper, times(1)).insert(productCaptor.capture());
ProductDO createdProduct = productCaptor.getValue();
assertEquals("新产品", createdProduct.getName());
// 验证"全部需求"模块已自动创建
ArgumentCaptor<com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementModuleDO> moduleCaptor =
ArgumentCaptor.forClass(com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementModuleDO.class);
verify(requirementModuleMapper, times(1)).insert(moduleCaptor.capture());
com.njcn.rdms.module.project.dal.dataobject.product.ProductRequirementModuleDO defaultModule = moduleCaptor.getValue();
assertEquals(0L, defaultModule.getParentId());
assertEquals(createdProduct.getId(), defaultModule.getProductId());
assertEquals("全部需求", defaultModule.getModuleName());
assertEquals("自动创建的模块", defaultModule.getRemark());
assertEquals(0, defaultModule.getSort());
}
@Test
void updateProductBaseInfo_shouldOnlyUpdateBaseInfoAndRecordFieldChanges() {
Long productId = 1002L;
ProductDO product = createProduct(productId, "direction_value", "旧产品", 2003L, "旧描述", "active");
ProductSettingBaseInfoUpdateReqVO reqVO = new ProductSettingBaseInfoUpdateReqVO();
reqVO.setDirectionCode("direction_value_updated");
reqVO.setName("新产品");
reqVO.setDescription(" 新描述 ");
when(productMapper.selectById(productId)).thenReturn(product);
when(productMapper.selectByName("新产品")).thenReturn(null);
productService.updateProductBaseInfo(productId, reqVO);
ProductDO updated = captureUpdatedProduct();
assertProductBaseInfoUpdated(updated, "CNPD2026002", 2003L, "active",
"direction_value_updated", "新产品", "新描述");
BizAuditLogDO auditLog = captureAuditLog();
assertAuditFieldChanges(auditLog, productId, "direction_value", "direction_value_updated",
"旧产品", "新产品", "旧描述", "新描述");
}
@Test
void updateProduct_shouldReuseBaseInfoUpdateFlow() {
Long productId = 1003L;
ProductDO product = createProduct(productId, "direction_value", "旧产品", 2004L, "旧描述", "active");
ProductSaveReqVO reqVO = new ProductSaveReqVO();
reqVO.setId(productId);
reqVO.setCode("CNPD2026003");
reqVO.setDirectionCode("direction_value_updated");
reqVO.setName("新产品");
reqVO.setManagerUserId(2004L);
reqVO.setDescription(" 新描述 ");
when(productMapper.selectById(productId)).thenReturn(product);
when(productMapper.selectByName("新产品")).thenReturn(null);
productService.updateProduct(reqVO);
ProductDO updated = captureUpdatedProduct();
assertProductBaseInfoUpdated(updated, "CNPD2026003", 2004L, "active",
"direction_value_updated", "新产品", "新描述");
BizAuditLogDO auditLog = captureAuditLog();
assertAuditFieldChanges(auditLog, productId, "direction_value", "direction_value_updated",
"旧产品", "新产品", "旧描述", "新描述");
}
@Test
void updateProduct_whenManagerUserIdChanged_shouldThrowException() {
Long productId = 1002L;
ProductDO product = createProduct(productId, "direction_value", "旧产品", 2005L, "旧描述", "active");
ProductSaveReqVO reqVO = new ProductSaveReqVO();
reqVO.setId(productId);
reqVO.setCode("CNPD2026002");
reqVO.setDirectionCode("direction_value_updated");
reqVO.setName("新产品");
reqVO.setManagerUserId(2006L);
reqVO.setDescription("新描述");
when(productMapper.selectById(productId)).thenReturn(product);
ServiceException ex = assertThrows(ServiceException.class,
() -> productService.updateProduct(reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_MANAGER_NOT_MODIFIABLE.getCode(), ex.getCode());
verify(productMapper, never()).updateById(any(ProductDO.class));
verify(bizAuditLogMapper, never()).insert(any(BizAuditLogDO.class));
}
@Test
void updateProductBaseInfo_whenPausedAndDirectionOrNameChanged_shouldThrowException() {
Long productId = 1004L;
ProductDO product = createProduct(productId, "direction_value", "暂停产品", 2007L, "旧描述", "paused");
ProductSettingBaseInfoUpdateReqVO reqVO = new ProductSettingBaseInfoUpdateReqVO();
reqVO.setDirectionCode("direction_value_updated");
reqVO.setName("暂停产品");
reqVO.setDescription("新描述");
when(productMapper.selectById(productId)).thenReturn(product);
ServiceException ex = assertThrows(ServiceException.class,
() -> productService.updateProductBaseInfo(productId, reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_PAUSED_ONLY_ALLOW_LIMITED_UPDATE.getCode(), ex.getCode());
verify(productMapper, never()).updateById(any(ProductDO.class));
}
@Test
void changeProductStatus_shouldUpdateStatusByConditionAndWriteLogs() {
Long productId = 1006L;
Long loginUserId = 3002L;
ProductDO product = createProduct(productId, "direction_value", "产品B", 2009L, "旧描述", "active");
ProductStatusActionReqVO reqVO = new ProductStatusActionReqVO();
reqVO.setId(productId);
reqVO.setActionCode("pause");
reqVO.setReason("环境受限");
when(productMapper.selectById(productId)).thenReturn(product);
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("product", "active", "pause"))
.thenReturn(createTransition("pause", "paused", true));
when(productMapper.updateStatusByIdAndStatus(productId, "active", "paused", "环境受限")).thenReturn(1);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
productService.changeProductStatus(reqVO);
}
verify(productMapper, times(1)).updateStatusByIdAndStatus(productId, "active", "paused", "环境受限");
ArgumentCaptor<ProductStatusLogDO> statusLogCaptor = ArgumentCaptor.forClass(ProductStatusLogDO.class);
verify(productStatusLogMapper, times(1)).insert(statusLogCaptor.capture());
assertEquals("pause", statusLogCaptor.getValue().getActionType());
assertEquals("active", statusLogCaptor.getValue().getFromStatus());
assertEquals("paused", statusLogCaptor.getValue().getToStatus());
assertEquals("环境受限", statusLogCaptor.getValue().getReason());
BizAuditLogDO auditLog = captureAuditLog();
assertEquals("pause", auditLog.getActionType());
assertEquals("active", auditLog.getFromStatus());
assertEquals("paused", auditLog.getToStatus());
assertEquals("环境受限", auditLog.getReason());
}
@Test
void changeProductStatus_whenConditionalUpdateReturnsZero_shouldThrowException() {
Long productId = 1007L;
Long loginUserId = 3003L;
ProductDO product = createProduct(productId, "direction_value", "产品C", 2010L, "旧描述", "active");
ProductStatusActionReqVO reqVO = new ProductStatusActionReqVO();
reqVO.setId(productId);
reqVO.setActionCode("pause");
reqVO.setReason("环境受限");
when(productMapper.selectById(productId)).thenReturn(product);
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("product", "active", "pause"))
.thenReturn(createTransition("pause", "paused", true));
when(productMapper.updateStatusByIdAndStatus(productId, "active", "paused", "环境受限")).thenReturn(0);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
ServiceException ex = assertThrows(ServiceException.class,
() -> productService.changeProductStatus(reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_STATUS_CONCURRENT_MODIFIED.getCode(), ex.getCode());
verify(productStatusLogMapper, never()).insert(any(ProductStatusLogDO.class));
verify(bizAuditLogMapper, never()).insert(any(BizAuditLogDO.class));
}
}
@Test
void changeProductStatus_whenActionNotAllowed_shouldThrowException() {
Long productId = 1010L;
ProductDO product = createProduct(productId, "direction_value", "产品F", 2013L, "旧描述", "archived");
ProductStatusActionReqVO reqVO = new ProductStatusActionReqVO();
reqVO.setId(productId);
reqVO.setActionCode("pause");
reqVO.setReason("再次暂停");
when(productMapper.selectById(productId)).thenReturn(product);
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("product", "archived", "pause"))
.thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> productService.changeProductStatus(reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_STATUS_ACTION_NOT_ALLOWED.getCode(), ex.getCode());
}
@Test
void changeProductStatus_whenReasonRequiredButMissing_shouldThrowException() {
Long productId = 1011L;
ProductDO product = createProduct(productId, "direction_value", "产品G", 2014L, "旧描述", "active");
ProductStatusActionReqVO reqVO = new ProductStatusActionReqVO();
reqVO.setId(productId);
reqVO.setActionCode("pause");
reqVO.setReason(" ");
when(productMapper.selectById(productId)).thenReturn(product);
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("product", "active", "pause"))
.thenReturn(createTransition("pause", "paused", true));
ServiceException ex = assertThrows(ServiceException.class,
() -> productService.changeProductStatus(reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_STATUS_ACTION_REASON_REQUIRED.getCode(), ex.getCode());
}
@Test
void deleteProduct_whenConfirmTextInvalid_shouldThrowException() {
Long productId = 1008L;
Long loginUserId = 3004L;
ProductDO product = createProduct(productId, "direction_value", "产品D", 2011L, "旧描述", "active");
ProductDeleteReqVO reqVO = new ProductDeleteReqVO();
reqVO.setId(productId);
reqVO.setProductName("产品D");
reqVO.setReason("录入错误");
reqVO.setConfirmText("WRONG");
when(productMapper.selectById(productId)).thenReturn(product);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
ServiceException ex = assertThrows(ServiceException.class,
() -> productService.deleteProduct(reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_DELETE_CONFIRM_TEXT_INVALID.getCode(), ex.getCode());
verify(productMapper, never()).deleteByIdAndStatus(any(Long.class), any(String.class));
verify(productStatusLogMapper, never()).insert(any(ProductStatusLogDO.class));
verify(bizAuditLogMapper, never()).insert(any(BizAuditLogDO.class));
}
}
@Test
void deleteProduct_whenProductNameMismatch_shouldThrowException() {
Long productId = 1012L;
ProductDO product = createProduct(productId, "direction_value", "产品H", 2015L, "旧描述", "active");
ProductDeleteReqVO reqVO = new ProductDeleteReqVO();
reqVO.setId(productId);
reqVO.setProductName("错误名称");
reqVO.setReason("录入错误");
reqVO.setConfirmText("DELETE");
when(productMapper.selectById(productId)).thenReturn(product);
ServiceException ex = assertThrows(ServiceException.class,
() -> productService.deleteProduct(reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_DELETE_NAME_MISMATCH.getCode(), ex.getCode());
}
@Test
void deleteProduct_shouldDeleteByConditionAndWriteLogs() {
Long productId = 1009L;
Long loginUserId = 3005L;
ProductDO product = createProduct(productId, "direction_value", "产品E", 2012L, "旧描述", "active");
ProductDeleteReqVO reqVO = new ProductDeleteReqVO();
reqVO.setId(productId);
reqVO.setProductName("产品E");
reqVO.setReason("录入错误");
reqVO.setConfirmText("DELETE");
when(productMapper.selectById(productId)).thenReturn(product);
when(productMapper.deleteByIdAndStatus(productId, "active")).thenReturn(1);
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
productService.deleteProduct(reqVO);
}
verify(productMapper, times(1)).deleteByIdAndStatus(productId, "active");
ArgumentCaptor<ProductStatusLogDO> statusLogCaptor = ArgumentCaptor.forClass(ProductStatusLogDO.class);
verify(productStatusLogMapper, times(1)).insert(statusLogCaptor.capture());
assertEquals("delete", statusLogCaptor.getValue().getActionType());
assertEquals("active", statusLogCaptor.getValue().getFromStatus());
assertNull(statusLogCaptor.getValue().getToStatus());
assertEquals("录入错误", statusLogCaptor.getValue().getReason());
BizAuditLogDO auditLog = captureAuditLog();
assertEquals("delete", auditLog.getActionType());
assertEquals("active", auditLog.getFromStatus());
assertNull(auditLog.getToStatus());
assertEquals("录入错误", auditLog.getReason());
}
@Test
void updateProductBaseInfo_whenArchived_shouldThrowException() {
Long productId = 1013L;
ProductDO product = createProduct(productId, "direction_value", "归档产品", 2016L, "旧描述", "archived");
ProductSettingBaseInfoUpdateReqVO reqVO = new ProductSettingBaseInfoUpdateReqVO();
reqVO.setDirectionCode("direction_value");
reqVO.setName("归档产品");
reqVO.setDescription("新描述");
when(productMapper.selectById(productId)).thenReturn(product);
ServiceException ex = assertThrows(ServiceException.class,
() -> productService.updateProductBaseInfo(productId, reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode());
}
@Test
void getProductContext_shouldAssembleCurrentRoleNavsAndButtonsFromObjectPermissionApi() {
Long productId = 1014L;
Long loginUserId = 3014L;
Long roleId = 9101L;
ProductDO product = createProduct(productId, "direction_value", "产品上下文", 2017L, "旧描述", "active");
UserObjectRoleDO currentMember = createMember(productId, loginUserId, roleId);
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
detail.setCurrentRole(createRole(roleId, "product_manager", "产品经理"));
detail.setMenus(List.of(
createMenu(9303L, "保存", "project:product:update", 3, 30, null, null, true),
createMenu(9302L, "隐藏导航", null, 2, 20, "/product/hidden", "mdi:hidden", false),
createMenu(9301L, "概览", null, 2, 10, "/product/overview", "mdi:view-dashboard-outline", true)
));
detail.setPermissions(Set.of("project:product:update"));
when(productMapper.selectById(productId)).thenReturn(product);
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
.thenReturn(currentMember);
when(objectPermissionApi.getObjectRolePermissionDetail(roleId, "object", "product"))
.thenReturn(success(detail));
ProductContextRespVO respVO;
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "测试人")) {
respVO = productService.getProductContext(productId);
}
assertNotNull(respVO.getCurrentProduct());
assertEquals(roleId, respVO.getCurrentRole().getRoleId());
assertEquals("product_manager", respVO.getCurrentRole().getRoleCode());
assertEquals("产品经理", respVO.getCurrentRole().getRoleName());
assertEquals(1, respVO.getNavs().size());
assertEquals(9301L, respVO.getNavs().get(0).getId());
assertEquals(List.of("project:product:update"), respVO.getButtons());
}
private ProductDO createProduct(Long id, String directionCode, String name, Long managerUserId,
String description, String statusCode) {
ProductDO product = new ProductDO();
product.setId(id);
product.setCode("CNPD202600" + (id % 10));
product.setDirectionCode(directionCode);
product.setStatusCode(statusCode);
product.setName(name);
product.setManagerUserId(managerUserId);
product.setDescription(description);
return product;
}
private UserObjectRoleDO createMember(Long productId, Long userId, Long roleId) {
UserObjectRoleDO currentMember = new UserObjectRoleDO();
currentMember.setId(9001L);
currentMember.setUserId(userId);
currentMember.setObjectType("product");
currentMember.setObjectId(productId);
currentMember.setRoleId(roleId);
currentMember.setStatus(0);
return currentMember;
}
private ObjectRoleRespDTO createRole(Long roleId, String roleCode, String roleName) {
ObjectRoleRespDTO role = new ObjectRoleRespDTO();
role.setId(roleId);
role.setCode(roleCode);
role.setName(roleName);
role.setScopeType("object");
role.setObjectType("product");
return role;
}
private ObjectMenuRespDTO createMenu(Long menuId, String name, String permission, Integer type, Integer sort,
String path, String icon, Boolean visible) {
ObjectMenuRespDTO menu = new ObjectMenuRespDTO();
menu.setId(menuId);
menu.setName(name);
menu.setPermission(permission);
menu.setType(type);
menu.setSort(sort);
menu.setPath(path);
menu.setIcon(icon);
menu.setVisible(visible);
return menu;
}
private ObjectStatusTransitionDO createTransition(String actionCode, String toStatus, boolean needReason) {
ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
transition.setActionCode(actionCode);
transition.setToStatusCode(toStatus);
transition.setNeedReason(needReason);
return transition;
}
private MockedStatic<SecurityFrameworkUtils> mockLoginUser(Long loginUserId, String nickname) {
MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class);
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
mockedStatic.when(SecurityFrameworkUtils::getLoginUserNickname).thenReturn(nickname);
return mockedStatic;
}
private ProductDO captureUpdatedProduct() {
ArgumentCaptor<ProductDO> productCaptor = ArgumentCaptor.forClass(ProductDO.class);
verify(productMapper, times(1)).updateById(productCaptor.capture());
return productCaptor.getValue();
}
private BizAuditLogDO captureAuditLog() {
ArgumentCaptor<BizAuditLogDO> auditCaptor = ArgumentCaptor.forClass(BizAuditLogDO.class);
verify(bizAuditLogMapper, times(1)).insert(auditCaptor.capture());
return auditCaptor.getValue();
}
private void assertProductBaseInfoUpdated(ProductDO updated, String code, Long managerUserId,
String statusCode, String directionCode,
String name, String description) {
assertEquals(code, updated.getCode());
assertEquals(managerUserId, updated.getManagerUserId());
assertEquals(statusCode, updated.getStatusCode());
assertEquals(directionCode, updated.getDirectionCode());
assertEquals(name, updated.getName());
assertEquals(description, updated.getDescription());
assertNull(updated.getLastStatusReason());
}
private void assertAuditFieldChanges(BizAuditLogDO auditLog, Long productId, String beforeDirectionCode,
String afterDirectionCode, String beforeName, String afterName,
String beforeDescription, String afterDescription) {
assertEquals("product", auditLog.getBizType());
assertEquals(productId, auditLog.getBizId());
assertEquals("update", auditLog.getActionType());
assertEquals("active", auditLog.getFromStatus());
assertEquals("active", auditLog.getToStatus());
JsonNode fieldChanges = JsonUtils.parseTree(auditLog.getFieldChanges());
assertEquals(beforeDirectionCode, fieldChanges.path("directionCode").path("before").asText());
assertEquals(afterDirectionCode, fieldChanges.path("directionCode").path("after").asText());
assertEquals(beforeName, fieldChanges.path("name").path("before").asText());
assertEquals(afterName, fieldChanges.path("name").path("after").asText());
assertEquals(beforeDescription, fieldChanges.path("description").path("before").asText());
assertEquals(afterDescription, fieldChanges.path("description").path("after").asText());
assertFalse(fieldChanges.has("managerUserId"));
}
}

View File

@@ -0,0 +1,215 @@
package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityPageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelinePageReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.activity.ProductActivityTimelineRespVO;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingLifecycleRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingRespVO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
import com.njcn.rdms.module.project.dal.mysql.product.ProductMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class ProductSettingServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ProductSettingServiceImpl productSettingService;
@Mock
private AdminUserApi adminUserApi;
@Mock
private ProductMapper productMapper;
@Mock
private ProductStatusViewService productStatusViewService;
@Mock
private ProductActivityQueryService productActivityQueryService;
@Mock
private ProductActivityTimelineQueryService productActivityTimelineQueryService;
@Test
void getProductSettings_shouldAssembleBaseInfoAndLifecycleActions() {
Long productId = 1001L;
Long managerUserId = 2002L;
ProductDO product = new ProductDO();
product.setId(productId);
product.setCode("CNPD2026001");
product.setDirectionCode("direction_value");
product.setName("统一交付平台");
product.setManagerUserId(managerUserId);
product.setDescription("产品描述");
product.setStatusCode("active");
product.setLastStatusReason("恢复正常推进");
AdminUserRespDTO manager = new AdminUserRespDTO();
manager.setId(managerUserId);
manager.setNickname("张三");
ProductSettingLifecycleRespVO lifecycle = new ProductSettingLifecycleRespVO();
lifecycle.setStatusCode("active");
lifecycle.setStatusName("启用");
lifecycle.setTerminal(false);
lifecycle.setAllowEdit(true);
lifecycle.setLastStatusReason("恢复正常推进");
lifecycle.setAvailableActions(Collections.emptyList());
when(productMapper.selectById(productId)).thenReturn(product);
when(adminUserApi.getUser(managerUserId)).thenReturn(CommonResult.success(manager));
when(productStatusViewService.getLifecycle("active", "恢复正常推进")).thenReturn(lifecycle);
ProductSettingRespVO result = productSettingService.getProductSettings(productId);
assertEquals("统一交付平台", result.getBaseInfo().getName());
assertEquals("张三", result.getBaseInfo().getManagerUserNickname());
assertEquals("启用", result.getLifecycle().getStatusName());
assertEquals(false, result.getLifecycle().getTerminal());
assertEquals(true, result.getLifecycle().getAllowEdit());
}
@Test
void getProductSettings_shouldReturnNullManagerNicknameWhenManagerUserIdIsNull() {
Long productId = 1002L;
ProductDO product = new ProductDO();
product.setId(productId);
product.setCode("CNPD2026002");
product.setDirectionCode("direction_value");
product.setName("产品A");
product.setManagerUserId(null);
product.setStatusCode("active");
ProductSettingLifecycleRespVO lifecycle = new ProductSettingLifecycleRespVO();
lifecycle.setStatusCode("active");
lifecycle.setAvailableActions(Collections.emptyList());
when(productMapper.selectById(productId)).thenReturn(product);
when(productStatusViewService.getLifecycle("active", null)).thenReturn(lifecycle);
ProductSettingRespVO result = productSettingService.getProductSettings(productId);
assertNull(result.getBaseInfo().getManagerUserNickname());
verifyNoInteractions(adminUserApi);
}
@Test
void getProductSettings_shouldReturnEmptyActionsWhenNoStatusTransitions() {
Long productId = 1003L;
Long managerUserId = 2003L;
ProductDO product = new ProductDO();
product.setId(productId);
product.setCode("CNPD2026003");
product.setDirectionCode("direction_value");
product.setName("产品B");
product.setManagerUserId(managerUserId);
product.setStatusCode("paused");
AdminUserRespDTO manager = new AdminUserRespDTO();
manager.setId(managerUserId);
manager.setNickname("李四");
ProductSettingLifecycleRespVO lifecycle = new ProductSettingLifecycleRespVO();
lifecycle.setStatusCode("paused");
lifecycle.setAvailableActions(Collections.emptyList());
when(productMapper.selectById(productId)).thenReturn(product);
when(adminUserApi.getUser(managerUserId)).thenReturn(CommonResult.success(manager));
when(productStatusViewService.getLifecycle("paused", null)).thenReturn(lifecycle);
ProductSettingRespVO result = productSettingService.getProductSettings(productId);
assertNotNull(result.getLifecycle().getAvailableActions());
assertTrue(result.getLifecycle().getAvailableActions().isEmpty());
}
@Test
void getProductActivities_shouldValidateProductAndDelegate() {
Long productId = 1004L;
ProductDO product = new ProductDO();
product.setId(productId);
ProductActivityPageReqVO reqVO = new ProductActivityPageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
ProductActivityRespVO respVO = new ProductActivityRespVO();
respVO.setType("status");
PageResult<ProductActivityRespVO> pageResult = new PageResult<>(List.of(respVO), 1L);
when(productMapper.selectById(productId)).thenReturn(product);
when(productActivityQueryService.getProductActivities(productId, reqVO)).thenReturn(pageResult);
PageResult<ProductActivityRespVO> result = productSettingService.getProductActivities(productId, reqVO);
assertEquals(1L, result.getTotal());
assertEquals("status", result.getList().get(0).getType());
}
@Test
void getProductActivities_whenProductMissing_shouldThrowException() {
Long productId = 1005L;
ProductActivityPageReqVO reqVO = new ProductActivityPageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
when(productMapper.selectById(productId)).thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> productSettingService.getProductActivities(productId, reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_NOT_EXISTS.getCode(), ex.getCode());
}
@Test
void getProductActivityTimelinePage_shouldValidateProductAndDelegate() {
Long productId = 1006L;
ProductDO product = new ProductDO();
product.setId(productId);
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
ProductActivityTimelineRespVO respVO = new ProductActivityTimelineRespVO();
respVO.setId("status:11");
PageResult<ProductActivityTimelineRespVO> pageResult = new PageResult<>(List.of(respVO), 1L);
when(productMapper.selectById(productId)).thenReturn(product);
when(productActivityTimelineQueryService.getProductActivityTimelinePage(productId, reqVO))
.thenReturn(pageResult);
PageResult<ProductActivityTimelineRespVO> result =
productSettingService.getProductActivityTimelinePage(productId, reqVO);
assertEquals(1L, result.getTotal());
assertEquals("status:11", result.getList().get(0).getId());
}
@Test
void getProductActivityTimelinePage_whenProductMissing_shouldThrowException() {
Long productId = 1007L;
ProductActivityTimelinePageReqVO reqVO = new ProductActivityTimelinePageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
when(productMapper.selectById(productId)).thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> productSettingService.getProductActivityTimelinePage(productId, reqVO));
assertEquals(ErrorCodeConstants.PRODUCT_NOT_EXISTS.getCode(), ex.getCode());
}
}

View File

@@ -0,0 +1,74 @@
package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingLifecycleRespVO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
class ProductStatusViewServiceTest extends BaseMockitoUnitTest {
@InjectMocks
private ProductStatusViewService productStatusViewService;
@Mock
private ObjectStatusModelMapper objectStatusModelMapper;
@Mock
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
@Test
void getLifecycle_shouldReturnStatusMetadataAndActions() {
ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
statusModel.setObjectType("product");
statusModel.setStatusCode("active");
statusModel.setStatusName("启用");
statusModel.setTerminalFlag(false);
statusModel.setAllowEdit(true);
statusModel.setStatus(0);
ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
transition.setActionCode("pause");
transition.setActionName("暂停");
transition.setNeedReason(true);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
.thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("product", "active"))
.thenReturn(List.of(transition));
ProductSettingLifecycleRespVO result =
productStatusViewService.getLifecycle("active", "恢复正常推进");
assertEquals("active", result.getStatusCode());
assertEquals("启用", result.getStatusName());
assertFalse(result.getTerminal());
assertTrue(result.getAllowEdit());
assertEquals("恢复正常推进", result.getLastStatusReason());
assertEquals(1, result.getAvailableActions().size());
assertEquals("pause", result.getAvailableActions().get(0).getActionCode());
}
@Test
void getLifecycle_whenStatusModelMissing_shouldThrowException() {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
.thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> productStatusViewService.getLifecycle("active", null));
assertEquals(ErrorCodeConstants.PRODUCT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED.getCode(), ex.getCode());
}
}

View File

@@ -0,0 +1,89 @@
package com.njcn.rdms.module.system.api.permission;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.map.MapUtil;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.util.collection.CollectionUtils;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRolePermissionRespDTO;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
import com.njcn.rdms.module.system.enums.ApiConstants;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
@FeignClient(name = ApiConstants.NAME)
@Tag(name = "RPC 服务 - 对象权限")
public interface ObjectPermissionApi {
String PREFIX = ApiConstants.PREFIX + "/permission/object";
@GetMapping(PREFIX + "/role-by-id")
@Operation(summary = "按角色 ID 查询对象作用域角色摘要")
@Parameter(name = "roleId", description = "角色 ID", example = "1024", required = true)
@Parameter(name = "scopeType", description = "权限作用域类型", example = "object", required = true)
@Parameter(name = "objectType", description = "对象类型", example = "product", required = true)
CommonResult<ObjectRoleRespDTO> getObjectRoleById(@RequestParam("roleId") Long roleId,
@RequestParam("scopeType") String scopeType,
@RequestParam("objectType") String objectType);
@GetMapping(PREFIX + "/role-by-code")
@Operation(summary = "按角色编码查询对象作用域角色摘要")
@Parameter(name = "roleCode", description = "角色编码", example = "product_manager", required = true)
@Parameter(name = "scopeType", description = "权限作用域类型", example = "object", required = true)
@Parameter(name = "objectType", description = "对象类型", example = "product", required = true)
CommonResult<ObjectRoleRespDTO> getObjectRoleByCode(@RequestParam("roleCode") String roleCode,
@RequestParam("scopeType") String scopeType,
@RequestParam("objectType") String objectType);
@GetMapping(PREFIX + "/role-list")
@Operation(summary = "按角色 ID 批量查询对象作用域角色摘要")
@Parameter(name = "roleIds", description = "角色 ID 集合", example = "1,2", required = true)
@Parameter(name = "scopeType", description = "权限作用域类型", example = "object", required = true)
@Parameter(name = "objectType", description = "对象类型", example = "product", required = true)
CommonResult<List<ObjectRoleRespDTO>> getObjectRoleList(@RequestParam("roleIds") Collection<Long> roleIds,
@RequestParam("scopeType") String scopeType,
@RequestParam("objectType") String objectType);
@GetMapping(PREFIX + "/permissions")
@Operation(summary = "查询对象作用域角色权限集合")
@Parameter(name = "roleId", description = "角色 ID", example = "1024", required = true)
@Parameter(name = "scopeType", description = "权限作用域类型", example = "object", required = true)
@Parameter(name = "objectType", description = "对象类型", example = "product", required = true)
CommonResult<Set<String>> getObjectRolePermissions(@RequestParam("roleId") Long roleId,
@RequestParam("scopeType") String scopeType,
@RequestParam("objectType") String objectType);
@GetMapping(PREFIX + "/role-permission-detail")
@Operation(summary = "查询对象作用域角色菜单与权限聚合结果")
@Parameter(name = "roleId", description = "角色 ID", example = "1024", required = true)
@Parameter(name = "scopeType", description = "权限作用域类型", example = "object", required = true)
@Parameter(name = "objectType", description = "对象类型", example = "product", required = true)
CommonResult<ObjectRolePermissionRespDTO> getObjectRolePermissionDetail(@RequestParam("roleId") Long roleId,
@RequestParam("scopeType") String scopeType,
@RequestParam("objectType") String objectType);
/**
* 按角色 ID 返回对象作用域角色摘要映射,便于业务模块批量对齐本地成员数据。
*
* @param roleIds 角色 ID 集合
* @param scopeType 权限作用域类型
* @param objectType 对象类型
* @return 角色摘要映射
*/
default Map<Long, ObjectRoleRespDTO> getObjectRoleMap(Collection<Long> roleIds, String scopeType, String objectType) {
if (CollUtil.isEmpty(roleIds)) {
return MapUtil.empty();
}
List<ObjectRoleRespDTO> roles = getObjectRoleList(roleIds, scopeType, objectType).getCheckedData();
return CollectionUtils.convertMap(roles, ObjectRoleRespDTO::getId);
}
}

View File

@@ -0,0 +1,34 @@
package com.njcn.rdms.module.system.api.permission.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "RPC 服务 - 对象作用域菜单 Response DTO")
@Data
public class ObjectMenuRespDTO {
@Schema(description = "菜单 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "2001")
private Long id;
@Schema(description = "菜单名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品设置")
private String name;
@Schema(description = "权限标识", example = "project:product:update")
private String permission;
@Schema(description = "菜单类型;允许返回目录、菜单、按钮,业务侧可按 type 再拆分导航或按钮", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
private Integer type;
@Schema(description = "显示排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
private Integer sort;
@Schema(description = "路由路径", example = "/project/product/detail")
private String path;
@Schema(description = "菜单图标", example = "ep:box")
private String icon;
@Schema(description = "是否可见", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
private Boolean visible;
}

View File

@@ -0,0 +1,23 @@
package com.njcn.rdms.module.system.api.permission.dto;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
import java.util.Set;
@Schema(description = "RPC 服务 - 对象作用域角色权限聚合 Response DTO")
@Data
public class ObjectRolePermissionRespDTO {
@Schema(description = "当前查询 roleId 对应的角色摘要,不表示登录态上下文里的当前用户角色")
private ObjectRoleRespDTO currentRole;
@ArraySchema(schema = @Schema(description = "当前查询角色在指定 scopeType/objectType 下可消费的已启用菜单资源明细;允许包含目录、菜单、按钮,业务侧可按 type 再拆分导航或按钮"))
private List<ObjectMenuRespDTO> menus;
@ArraySchema(schema = @Schema(description = "基于同一批有效菜单资源归一化提取出的权限标识集合,供对象权限校验直接消费", example = "project:product:query"))
private Set<String> permissions;
}

View File

@@ -0,0 +1,25 @@
package com.njcn.rdms.module.system.api.permission.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "RPC 服务 - 对象作用域角色摘要 Response DTO")
@Data
public class ObjectRoleRespDTO {
@Schema(description = "角色 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long id;
@Schema(description = "角色编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "product_manager")
private String code;
@Schema(description = "角色名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品经理")
private String name;
@Schema(description = "权限作用域类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "object")
private String scopeType;
@Schema(description = "对象类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "product")
private String objectType;
}

View File

@@ -12,6 +12,7 @@ public interface DictTypeConstants {
String USER_SEX = "system_user_sex";
String LOGIN_TYPE = "system_login_type";
String LOGIN_RESULT = "system_login_result";
String OBJECT_DIRECTION = "rdms_object_direction";
String JOB_STATUS = "infra_job_status";
String JOB_LOG_STATUS = "infra_job_log_status";

View File

@@ -0,0 +1,122 @@
package com.njcn.rdms.module.system.api.permission;
import com.njcn.rdms.framework.common.enums.CommonStatusEnum;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.system.api.permission.dto.ObjectMenuRespDTO;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRolePermissionRespDTO;
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
import com.njcn.rdms.module.system.dal.dataobject.permission.MenuDO;
import com.njcn.rdms.module.system.dal.dataobject.permission.RoleDO;
import com.njcn.rdms.module.system.service.permission.PermissionService;
import com.njcn.rdms.module.system.service.permission.RoleService;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Primary;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
@RestController
@Validated
@Primary
@Hidden
public class ObjectPermissionApiImpl implements ObjectPermissionApi {
@Resource
private RoleService roleService;
@Resource
private PermissionService permissionService;
@Override
public CommonResult<ObjectRoleRespDTO> getObjectRoleById(Long roleId, String scopeType, String objectType) {
return success(convertRole(roleService.getRole(roleId, scopeType, objectType)));
}
@Override
public CommonResult<ObjectRoleRespDTO> getObjectRoleByCode(String roleCode, String scopeType, String objectType) {
return success(convertRole(roleService.getRoleByCode(roleCode, scopeType, objectType)));
}
@Override
public CommonResult<List<ObjectRoleRespDTO>> getObjectRoleList(Collection<Long> roleIds, String scopeType, String objectType) {
List<RoleDO> roles = roleService.getRoleList(roleIds, scopeType, objectType);
return success(roles.stream().map(this::convertRole).toList());
}
@Override
public CommonResult<Set<String>> getObjectRolePermissions(Long roleId, String scopeType, String objectType) {
RoleDO role = getEnabledScopedRole(roleId, scopeType, objectType);
if (role == null) {
return success(Collections.emptySet());
}
return success(new LinkedHashSet<>(permissionService.getScopedPermissionsByRoleId(roleId, scopeType, objectType)));
}
@Override
public CommonResult<ObjectRolePermissionRespDTO> getObjectRolePermissionDetail(Long roleId, String scopeType, String objectType) {
RoleDO role = getEnabledScopedRole(roleId, scopeType, objectType);
if (role == null) {
return success(emptyPermissionDetail());
}
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
detail.setCurrentRole(convertRole(role));
detail.setMenus(permissionService.getScopedMenusByRoleId(roleId, scopeType, objectType)
.stream()
.map(this::convertMenu)
.toList());
detail.setPermissions(new LinkedHashSet<>(
permissionService.getScopedPermissionsByRoleId(roleId, scopeType, objectType)));
return success(detail);
}
private ObjectRolePermissionRespDTO emptyPermissionDetail() {
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
detail.setCurrentRole(null);
detail.setMenus(Collections.emptyList());
detail.setPermissions(Collections.emptySet());
return detail;
}
private RoleDO getEnabledScopedRole(Long roleId, String scopeType, String objectType) {
RoleDO role = roleService.getRole(roleId, scopeType, objectType);
if (role == null || !CommonStatusEnum.ENABLE.getStatus().equals(role.getStatus())) {
return null;
}
return role;
}
private ObjectRoleRespDTO convertRole(RoleDO role) {
if (role == null) {
return null;
}
ObjectRoleRespDTO dto = new ObjectRoleRespDTO();
dto.setId(role.getId());
dto.setCode(role.getCode());
dto.setName(role.getName());
dto.setScopeType(role.getScopeType());
dto.setObjectType(role.getObjectType());
return dto;
}
private ObjectMenuRespDTO convertMenu(MenuDO menu) {
ObjectMenuRespDTO dto = new ObjectMenuRespDTO();
dto.setId(menu.getId());
dto.setName(menu.getName());
dto.setPermission(menu.getPermission());
dto.setType(menu.getType());
dto.setSort(menu.getSort());
dto.setPath(menu.getPath());
dto.setIcon(menu.getIcon());
dto.setVisible(menu.getVisible());
return dto;
}
}

Some files were not shown because too many files have changed in this diff Show More