From 2943a6255bb08d183f7afb56a0bd47456e693e1d Mon Sep 17 00:00:00 2001 From: hongawen <83944980@qq.com> Date: Wed, 22 Apr 2026 18:18:38 +0800 Subject: [PATCH] =?UTF-8?q?docs(product):=20=E5=88=A0=E9=99=A4=E4=BA=A7?= =?UTF-8?q?=E5=93=81=E7=AE=A1=E7=90=86SQL=E5=8F=A3=E5=BE=84=E5=92=8C?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除02-产品管理SQL已确认口径文档 - 移除02-产品管理业务设计文档 - 清理产品管理模块的详细设计说明 - 删除产品需求状态字段口径定义 - 移除来源承接与需求拆分口径说明 - 清理需求终态原因承接口径内容 - 删除产品生命周期管理设计 - 移除产品团队权限管理规范 - 清理产品与项目关系约束说明 - 删除轻量需求管理业务规则 - 移除产品状态机与流程设计 - 清理权限与动作矩阵定义 --- ...pendencies-bom-jdk17-1.0.0.pom.lastUpdated | 4 + AGENTS.md | 8 + .../validation/InDictCollectionValidator.java | 9 +- .../dict/validation/InDictValidator.java | 9 +- .../project/enums/ErrorCodeConstants.java | 16 +- .../enums/ProjectDictTypeConstants.java | 13 - .../product/02-产品管理_SQL已确认口径.md | 49 - .../product/02-产品管理_业务设计.md | 1057 ----------------- .../03-工单到任务全链路与工作流方案.md | 478 -------- .../product/04-产品管理_编码前必看清单.md | 221 ---- .../05-产品管理_前端联调最小闭环清单.md | 130 -- .../product/06-产品主数据_API接口文档.md | 536 --------- .../product/rdms_biz_audit_log.sql | 287 ----- .../admin/product/ProductController.java | 12 +- .../product/ProductMemberController.java | 63 + .../product/ProductSettingController.java | 61 + .../vo/activity/ProductActivityPageReqVO.java | 31 + .../vo/activity/ProductActivityRespVO.java | 45 + .../vo/member/ProductMemberInactiveReqVO.java | 15 + .../vo/member/ProductMemberRespVO.java | 45 + .../vo/member/ProductMemberSaveReqVO.java | 30 + .../vo/member/ProductMemberUpdateReqVO.java | 30 + .../vo/product/ProductContextNavRespVO.java | 25 + .../product/ProductContextProductRespVO.java | 28 + .../vo/product/ProductContextRespVO.java | 26 + .../vo/product/ProductContextRoleRespVO.java | 19 + .../vo/product/ProductDeleteReqVO.java | 5 + .../product/vo/product/ProductPageReqVO.java | 6 +- .../product/vo/product/ProductRespVO.java | 5 +- .../product/vo/product/ProductSaveReqVO.java | 10 +- .../setting/ProductSettingActionRespVO.java | 21 + .../setting/ProductSettingBaseInfoRespVO.java | 39 + .../ProductSettingBaseInfoUpdateReqVO.java | 28 + .../ProductSettingLifecycleRespVO.java | 32 + .../vo/setting/ProductSettingRespVO.java | 18 + .../dataobject/member/UserObjectRoleDO.java | 57 + .../dataobject/permission/SystemMenuDO.java | 42 + .../dataobject/permission/SystemRoleDO.java | 55 + .../permission/SystemRoleMenuDO.java | 24 + .../dal/dataobject/product/ProductDO.java | 4 - .../status/ObjectStatusModelDO.java | 8 - .../dal/mysql/audit/BizAuditLogMapper.java | 25 + .../mysql/member/UserObjectRoleMapper.java | 55 + .../mysql/permission/SystemMenuMapper.java | 23 + .../mysql/permission/SystemRoleMapper.java | 48 + .../permission/SystemRoleMenuMapper.java | 16 + .../dal/mysql/product/ProductMapper.java | 15 + .../mysql/product/ProductStatusLogMapper.java | 15 + .../mysql/status/ObjectStatusModelMapper.java | 7 + .../annotation/CheckObjectPermission.java | 35 + .../security/aop/ObjectPermissionAspect.java | 67 ++ .../service/ObjectPermissionService.java | 22 + .../ProductObjectPermissionService.java | 104 ++ .../product/ProductActivityQueryService.java | 187 +++ .../service/product/ProductMemberService.java | 50 + .../product/ProductMemberServiceImpl.java | 414 +++++++ .../service/product/ProductService.java | 18 + .../service/product/ProductServiceImpl.java | 309 ++++- .../product/ProductSettingService.java | 30 + .../product/ProductSettingServiceImpl.java | 93 ++ .../product/ProductStatusViewService.java | 60 + .../aop/ObjectPermissionAspectTest.java | 80 ++ .../ProductObjectPermissionServiceTest.java | 128 ++ .../ProductActivityQueryServiceTest.java | 113 ++ .../product/ProductServiceImplTest.java | 448 +++++++ .../ProductSettingServiceImplTest.java | 172 +++ .../product/ProductStatusViewServiceTest.java | 74 ++ .../system/enums/DictTypeConstants.java | 1 + .../admin/dict/DictDataController.java | 18 +- .../dict/vo/data/DictDataSimpleRespVO.java | 3 + .../cache/PermissionCacheStartupCleaner.java | 45 + .../service/permission/PermissionService.java | 6 +- .../permission/PermissionServiceImpl.java | 23 +- .../PermissionCacheStartupCleanerTest.java | 57 + 74 files changed, 3527 insertions(+), 2835 deletions(-) create mode 100644 .m2-temp/com/njcn/njcn-dependencies-bom-jdk17/1.0.0/njcn-dependencies-bom-jdk17-1.0.0.pom.lastUpdated delete mode 100644 rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ProjectDictTypeConstants.java delete mode 100644 rdms-project/rdms-project-boot/product/02-产品管理_SQL已确认口径.md delete mode 100644 rdms-project/rdms-project-boot/product/02-产品管理_业务设计.md delete mode 100644 rdms-project/rdms-project-boot/product/03-工单到任务全链路与工作流方案.md delete mode 100644 rdms-project/rdms-project-boot/product/04-产品管理_编码前必看清单.md delete mode 100644 rdms-project/rdms-project-boot/product/05-产品管理_前端联调最小闭环清单.md delete mode 100644 rdms-project/rdms-project-boot/product/06-产品主数据_API接口文档.md delete mode 100644 rdms-project/rdms-project-boot/product/rdms_biz_audit_log.sql create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductMemberController.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductSettingController.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/activity/ProductActivityPageReqVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/activity/ProductActivityRespVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberInactiveReqVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberRespVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberSaveReqVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberUpdateReqVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextNavRespVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextProductRespVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextRespVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextRoleRespVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingActionRespVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingBaseInfoRespVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingBaseInfoUpdateReqVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingLifecycleRespVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingRespVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/member/UserObjectRoleDO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/permission/SystemMenuDO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/permission/SystemRoleDO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/permission/SystemRoleMenuDO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/member/UserObjectRoleMapper.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/permission/SystemMenuMapper.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/permission/SystemRoleMapper.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/permission/SystemRoleMenuMapper.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/annotation/CheckObjectPermission.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/aop/ObjectPermissionAspect.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ObjectPermissionService.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionService.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryService.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberService.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImpl.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingService.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImpl.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductStatusViewService.java create mode 100644 rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/aop/ObjectPermissionAspectTest.java create mode 100644 rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionServiceTest.java create mode 100644 rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryServiceTest.java create mode 100644 rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductServiceImplTest.java create mode 100644 rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImplTest.java create mode 100644 rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductStatusViewServiceTest.java create mode 100644 rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/framework/cache/PermissionCacheStartupCleaner.java create mode 100644 rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/framework/cache/PermissionCacheStartupCleanerTest.java diff --git a/.m2-temp/com/njcn/njcn-dependencies-bom-jdk17/1.0.0/njcn-dependencies-bom-jdk17-1.0.0.pom.lastUpdated b/.m2-temp/com/njcn/njcn-dependencies-bom-jdk17/1.0.0/njcn-dependencies-bom-jdk17-1.0.0.pom.lastUpdated new file mode 100644 index 0000000..dd6e779 --- /dev/null +++ b/.m2-temp/com/njcn/njcn-dependencies-bom-jdk17/1.0.0/njcn-dependencies-bom-jdk17-1.0.0.pom.lastUpdated @@ -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= diff --git a/AGENTS.md b/AGENTS.md index cd4c152..c6680a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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、命令保持原始技术标识,不做意译。 ## 工作规则 diff --git a/rdms-framework/rdms-spring-boot-starter-excel/src/main/java/com/njcn/rdms/framework/dict/validation/InDictCollectionValidator.java b/rdms-framework/rdms-spring-boot-starter-excel/src/main/java/com/njcn/rdms/framework/dict/validation/InDictCollectionValidator.java index db7de31..ed0d9e2 100644 --- a/rdms-framework/rdms-spring-boot-starter-excel/src/main/java/com/njcn/rdms/framework/dict/validation/InDictCollectionValidator.java +++ b/rdms-framework/rdms-spring-boot-starter-excel/src/main/java/com/njcn/rdms/framework/dict/validation/InDictCollectionValidator.java @@ -10,6 +10,8 @@ import java.util.List; public class InDictCollectionValidator implements ConstraintValidator> { + private static final String MISSING_DICT_DATA_MESSAGE = "字典数据缺失,请联系管理员维护"; + private String dictType; @Override @@ -25,6 +27,12 @@ public class InDictCollectionValidator implements ConstraintValidator 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 { + private static final String MISSING_DICT_DATA_MESSAGE = "字典数据缺失,请联系管理员维护"; + private String dictType; @Override @@ -24,6 +26,12 @@ public class InDictValidator implements ConstraintValidator { } // 校验通过 final List 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 { } } - diff --git a/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java b/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java index 0be0d7e..3a65626 100644 --- a/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java +++ b/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java @@ -18,6 +18,20 @@ 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, "产品状态定义不存在或已停用"); } diff --git a/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ProjectDictTypeConstants.java b/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ProjectDictTypeConstants.java deleted file mode 100644 index e26fe3e..0000000 --- a/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ProjectDictTypeConstants.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.njcn.rdms.module.project.enums; - -/** - * 项目交付域字典类型常量 - */ -public interface ProjectDictTypeConstants { - - /** - * 产品方向 - */ - String PRODUCT_DIRECTION = "rdms_product_direction"; - -} diff --git a/rdms-project/rdms-project-boot/product/02-产品管理_SQL已确认口径.md b/rdms-project/rdms-project-boot/product/02-产品管理_SQL已确认口径.md deleted file mode 100644 index 00f6ecc..0000000 --- a/rdms-project/rdms-project-boot/product/02-产品管理_SQL已确认口径.md +++ /dev/null @@ -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 项:主表补终态结果字段,审计日志继续完整留痕 diff --git a/rdms-project/rdms-project-boot/product/02-产品管理_业务设计.md b/rdms-project/rdms-project-boot/product/02-产品管理_业务设计.md deleted file mode 100644 index d409d7b..0000000 --- a/rdms-project/rdms-project-boot/product/02-产品管理_业务设计.md +++ /dev/null @@ -1,1057 +0,0 @@ -# 02-产品管理 详细设计 - -## 0. 文档定位 - -| 项目 | 内容 | -|---|---| -| 文档目标 | 将《设计任务书》7.2 产品管理章节展开为开发、测试、接口、数据库可直接承接的详细设计 | -| 适用版本 | V0.5 | -| 设计范围 | 产品主数据、产品团队、产品与项目关系、产品生命周期、轻量需求管理 | -| 非本期范围 | 产品版本、路线图、评审流、基线、产品文档与附件、工单反馈管理 | -| 上游来源 | `04-设计阶段/设计任务书/设计任务书_V1.0.md`、`02-产品规划阶段/产品需求文档PRD_V1.0.md`、`02-产品规划阶段/术语与边界对齐_产品-项目集-项目.md`、`04-设计阶段/权限设计/权限设计方案.md` | -| 下游约束对象 | `04-设计阶段/数据库设计文档.md`、`04-设计阶段/API接口设计文档.md`、`04-设计阶段/业务设计/03-项目管理_业务设计.md`、`04-设计阶段/权限设计/` | -| 设计原则 | 先固化稳定归属和最小闭环,再扩展增强能力;产品层不承载任务、工时、周报等执行主数据 | - -本文档回答 6 个问题: - -- V0.5 产品管理到底做哪些页面、动作和字段。 -- 产品、项目、产品团队、产品需求之间怎么关联。 -- 每个动作的前置条件、校验规则、状态流转和审计要求是什么。 -- 产品可见范围、建项目资格、产品团队成员池之间如何衔接。 -- 产品需求如何与项目需求分流,不互相替代。 -- 数据库、接口、权限、测试应按什么口径承接。 - -## 1. 设计目标与范围 - -### 1.1 模块目标 - -- 建立稳定的产品主数据管理能力,作为项目归属的上层业务对象。 -- 形成“产品 -> 项目 -> 执行/任务 -> 工时/周报/统计”的稳定主链路。 -- 为产品侧长期协作提供独立于项目团队的产品团队机制。 -- 为通用需求沉淀提供轻量需求池,不把定制交付需求混入产品侧。 - -### 1.2 V0.5 包含范围 - -- 产品创建、编辑、查看、列表查询。 -- 产品经理维护与产品团队管理。 -- 产品启用、暂停、归档、废弃、逻辑删除。 -- 项目创建时强制选择所属产品。 -- 在当前产品上下文中查看关联项目。 -- 产品轻量需求的录入、编辑、查询、基础状态流转、关闭。 -- 产品研发令号按产品年度维护的数据结构预留。 -- 产品需求在 V0.5 仅承接轻量需求池管理;是否进入 `待评审` 由业务判断决定,但不引入正式审批流、工作流引擎和复杂评审编排。 - -### 1.3 V0.5 不包含范围 - -- 产品版本管理。 -- 产品路线图。 -- 基于工作流引擎的正式产品评审流、审批流。 -- 复杂产品需求增强管理,包括多级评审编排、会签/或签、退回重提等流程能力。 -- 产品研发令号独立管理界面。 -- 产品基线。 -- 产品文档与附件管理。 -- 工单/反馈与产品需求自动联动。 -- 产品维度统计看板。 - -### 1.4 当前设计口径 - -- 产品管理当前不设置产品模板或产品类型正式字段。 -- 产品方向为正式字段,统一通过系统字典承接;字典类型编码统一为 `rdms_product_direction`,业务表字段 `direction_code` 存字典 `value`,初始值为 `embedded`、`power_electronics`、`system_group`。 -- 产品需求支持按业务判断进入 `待评审` 状态;当前版本仅表达业务评审待处理语义,不展开正式评审流程、工作流引擎和复杂评审编排。 - -## 2. 上下游约束 - -### 2.1 术语约束 - -- 产品(Product)是持续交付和维护的业务对象,生命周期长。 -- 项目(Project)是围绕某次明确交付目标建立的执行单元,生命周期短于产品。 -- 项目集(ProjectSet)是多个项目的统筹容器,不直接承载任务。 -- 任务(Task)必须归属项目,不直接归属产品。 - -### 2.2 对项目管理的约束 - -- 项目创建时必须选择所属产品。 -- 项目创建后不允许改挂产品。 -- 项目负责人和项目成员必须先属于所属产品的产品团队,才能被选入项目团队。 -- 当前产品上下文中的关联项目列表必须按项目权限二次过滤。 - -### 2.3 对权限模型的约束 - -- 产品团队权限和项目团队权限分离。 -- 产品可见不等于可编辑,不等于可在该产品下建项目。 -- 高风险动作和普通协作动作分离控制。 -- 删除、归档、暂停、恢复、废弃都需要独立动作权限和审计留痕。 - -## 3. 功能架构 - -### 3.1 功能拆分 - -| 一级能力 | 二级能力 | V0.5 是否落地 | 说明 | -|---|---|---|---| -| 产品主数据 | 创建、编辑、查看、查询 | 是 | 主数据最小闭环 | -| 产品团队 | 产品经理、产品成员、产品观察者管理 | 是 | 同时承接项目团队来源池 | -| 产品生命周期 | 启用、暂停、归档、废弃、逻辑删除 | 是 | 删除为高风险动作 | -| 产品与项目关系 | 项目创建时选择产品,产品侧查看关联项目 | 是 | 关系由项目模块维护 | -| 产品需求 | 轻量需求池、基础状态流转、分流到项目 | 是 | 只承接通用需求 | -| 产品版本 | 版本主数据、版本状态、目标版本 | 否 | 当前不包含 | -| 产品路线图 | 时间线、版本计划 | 否 | 当前不包含 | - -### 3.2 页面信息架构 - -```text -产品管理 -├── 未选中产品时 -│ └── 产品对象入口页 -│ ├── 产品分组/分类 -│ ├── 产品列表 -│ └── 新建产品弹窗 -└── 已选中产品时 - ├── 当前产品概览 - ├── 产品需求 - │ ├── 待确认需求 - │ └── 产品需求维护弹窗 - ├── 产品团队 - └── 关联项目 -``` - -## 4. 页面详细设计 - -### 4.1 页面模型与布局原则 - -产品管理采用“对象上下文型业务域”布局,不采用传统后台中“列表页 -> 详情页 -> 多页签详情”的固定写法。 - -页面结构应分成两种状态: - -- 未选中产品时:头部只显示业务域锚点 `产品管理`,内容区承接产品对象入口页。 -- 已选中产品时:头部显示 `产品管理 + 当前产品`,并切换为该产品上下文下的功能导航。 - -这意味着产品管理的核心不是单独设计多少个静态页面,而是先建立“当前是否已经进入某个产品上下文”的状态。 - -V0.5 约束如下: - -- 未选中产品前,不在头部伪造一排产品功能菜单。 -- 已选中产品后,头部才显示当前产品下的功能导航。 -- 当前产品上下文内的功能项按 V0.5 已落地能力收敛,不预设额外功能带。 -- 产品新建、编辑统一采用弹窗,不拆成独立路由页;轻量需求新增和编辑可在当前产品上下文内通过弹窗完成。 - -### 4.2 未选中产品时的入口态 - -#### 4.2.1 头部状态 - -- 显示业务域锚点:`产品管理` -- 不显示当前产品对象位 -- 不显示产品上下文功能导航 - -该状态的核心目的,是让用户先完成“选择哪个产品”这一步,而不是先进入产品下的某个功能页。 - -#### 4.2.2 内容区结构 - -内容区展示产品对象入口页,主要承载: - -- 产品分类或产品分组入口 -- 产品列表 -- 搜索、筛选 -- 新建产品入口 - -产品分组入口放在内容区左侧的对象列表导航内,而不是放在头部。 - -#### 4.2.3 查询与列表 - -支持以下筛选条件: - -- 关键词:匹配产品编码、产品名称 -- 产品方向 -- 产品经理 -- 状态:启用、暂停、归档、废弃 -- 最近更新时间 - -展示以下列表字段: - -- 产品编码 -- 产品方向 -- 产品名称 -- 产品经理 -- 状态 -- 关联项目数 -- 轻量需求数 -- 更新时间 - -说明: - -- `关联项目数` 仅统计当前用户有权查看的项目数量。 -- `轻量需求数` 为产品下未删除需求数量。 -- 创建人、创建时间等审计字段不在入口态列表中展开,统一放在更多列或对象概览中查看。 - -#### 4.2.4 入口态操作 - -入口态只保留以下高频动作: - -- 新建产品 -- 进入产品 -- 编辑产品 -- 暂停 / 恢复 / 归档 / 废弃 / 删除 - -规则如下: - -- `新建产品` 为独立主按钮,仅授权管理员和具备全局创建权限的产品经理可见。 -- 点击产品名称或行主操作,表示“进入该产品上下文”,而不是进入传统详情页。 -- 编辑和状态动作可通过行内更多菜单或右侧操作区触发,避免列表区堆积过多按钮。 - -### 4.3 已选中产品时的对象上下文态 - -#### 4.3.1 头部状态 - -当用户从入口态选择某个产品后,系统建立当前产品上下文,头部应切换为: - -- 业务域锚点:`产品管理` -- 当前对象位:当前产品名称 -- 功能导航区:显示当前产品上下文下的功能入口 - -交互要求: - -- 点击业务域锚点,退出当前产品上下文并返回产品入口页。 -- 点击当前产品对象位,可打开产品切换面板,或回到产品列表重新选择。 - -#### 4.3.2 V0.5 功能导航范围 - -V0.5 下,当前产品上下文内的功能导航保持最小集合,仅承接以下内容: - -- 概览 -- 产品需求 -- 产品团队 -- 关联项目 - -说明: - -- `概览` 承接产品基本信息、状态信息、关键摘要和常用动作。 -- `产品需求` 承接轻量需求池的查询和维护。 -- `产品团队` 承接产品团队成员管理。 -- `关联项目` 承接当前产品下项目查看。 -- `产品研发令号` 不作为 V0.5 头部导航项,通过独立入口承接。 -- `操作记录` 不作为 V0.5 头部一级功能导航单独占位,统一作为概览中的区块或独立记录查看页承接。 - -#### 4.3.3 对象上下文保持规则 - -- 用户进入某个产品后,应保持当前产品上下文,直到主动切换产品或返回产品入口页。 -- 用户在产品上下文内切换功能时,不应丢失当前产品。 -- 从头部切换产品时,优先保留当前功能;若目标产品下该功能不可用,则退回目标产品默认首页。 - -### 4.4 当前产品上下文下的功能承载 - -#### 4.4.1 概览 - -`概览` 是当前产品上下文的默认首页,用于集中展示该产品的核心信息,而不是再拆成独立详情页。 - -承载以下内容: - -- 产品编码、产品方向、产品名称、产品经理、状态、描述 -- 创建人、创建时间、更新时间等审计摘要 -- 关联项目数、轻量需求数、待确认需求数等统计摘要 -- 编辑、暂停、恢复、归档、废弃、删除等常用动作入口 -- 待确认需求区块,用于提示由工单流转到当前产品、但尚未被产品负责人认领或拒绝的需求 -- 最近动态区块,用于展示该产品最近发生的关键变更 - -待确认需求展示: - -- 需求标题 -- 来源工单 -- 提交时间 -- 当前工单负责人 -- 快捷处理入口 - -待确认需求支持: - -- 认领 -- 拒绝 -- 查看来源工单 - -待确认需求规则: - -- 工单被归属到某产品后,不等于自动进入正式产品需求池。 -- 工单流转到产品侧后,应先进入该产品下的待确认需求队列。 -- 由产品负责人或授权管理员执行认领或拒绝。 -- 认领后,该需求进入正式产品需求池,状态进入 `待处理`。 -- 拒绝时必须填写原因,并保留与来源工单的追溯关系。 -- 本设计不承接工单状态回写;产品侧只承接来源追溯、认领拒绝结果和审计留痕。 - -最近动态展示: - -- 产品创建 -- 产品编辑 -- 产品经理变更 -- 产品状态变更,如暂停、恢复、归档、废弃 -- 产品团队关键调整 -- 产品需求新增、关闭和关键状态流转 - -最近动态展示字段: - -- 时间 -- 动作描述 -- 操作人 -- 原因或摘要说明 - -最近动态的边界要求: - -- 仅展示最近 `10` 条关键记录,作为概览摘要信息 -- 数据来源以 `rdms_biz_audit_log` 和 `rdms_product_status_log` 为主;其中产品暂停/恢复/归档/废弃/删除来自 `rdms_product_status_log`,产品经理变更、团队成员调整、需求认领/拒绝/关闭等来自 `rdms_biz_audit_log` -- 概览中的最近动态不替代完整审计记录查询 -- 完整留痕通过“查看更多操作记录”进入独立记录查看界面或展开面板 - -规则: - -- `编辑` 仅授权管理员和当前产品经理可见。 -- `暂停` 仅启用状态可见。 -- `恢复` 仅暂停状态可见。 -- `归档` 仅启用、暂停状态可见。 -- `废弃` 仅启用、暂停状态可见。 -- `删除` 仅授权管理员可见,且保持高风险动作的二次确认要求。 - -#### 4.4.2 产品需求 - -`产品需求` 承接当前产品下的轻量需求池管理,是 V0.5 产品上下文内最核心的功能页之一。 - -支持以下筛选条件: - -- 关键词 -- 分类 -- 来源 -- 状态 -- 优先级 -- 提出人 -- 默认实现项目 -- 是否待确认 - -展示: - -- 需求标题 -- 分类 -- 来源 -- 来源引用信息 -- 优先级 -- 状态 -- 提出人 -- 默认实现项目 -- 更新时间 - -支持: - -- 新增需求 -- 查看需求 -- 编辑需求 -- 认领工单流转需求 -- 拒绝工单流转需求 -- 状态流转 -- 关闭需求 - -规则: - -- 产品需求来源至少包括手工新增和工单流转两类。 -- 对于工单流转到产品侧的需求,需先由产品负责人或授权管理员认领或拒绝;认领后才进入正式产品需求池。 -- 来源字段应支持区分 `手工新增`、`工单流转` 等来源类型;如来源于工单,还应展示来源工单编号或来源引用信息。 -- 产品需求列表应支持按来源筛选,便于单独查看由工单流转进入产品侧的需求。 -- 即使工单流转到产品侧的需求最终被拒绝,也应继续保留在产品需求列表中,并允许按来源和状态查询追溯,不应因被拒绝而从产品侧视图中消失。 -- 产品需求既支持来源追溯,也支持父子拆分;来源追溯和拆分关系分开建模。 -- 同一产品下,同一来源工单只允许沉淀 1 条源头需求记录;该源头需求和手工新增需求都允许继续拆分为 N 条子需求。 -- 子需求不参与来源唯一约束;拆分链路统一由 `parent_requirement_id`、`root_requirement_id` 承接。 -- 录入和维护默认仅产品经理、授权管理员可执行。 -- 产品成员、产品观察者默认仅查看。 -- 已关闭、已拒绝、已取消需求默认只读。 -- V0.5 的 `产品需求` 只承接轻量需求池管理;是否进入 `待评审` 由业务判断决定,但不在当前版本展开正式审批流程、工作流节点和复杂评审编排。 - -#### 4.4.3 产品团队 - -`产品团队` 承接当前产品下的成员关系管理。 - -展示: - -- 用户姓名 -- 团队角色 -- 状态 -- 加入时间 -- 退出时间 - -支持: - -- 新增成员 -- 调整角色 -- 移出产品团队 - -规则: - -- 同一产品下,同一用户只允许一条有效团队关系。 -- 同一时刻只允许一个有效产品经理。 -- 将某成员设为产品经理时,需要同步更新产品主表的 `manager_user_id`。 -- 新增成员、调整角色、移出产品团队、变更产品经理等非状态敏感动作统一写入 `rdms_biz_audit_log`。 -- 产品团队成员退出后,不自动影响其已参与项目的历史关系。 -- 已退出产品团队的成员,不能再被该产品下的新项目继续选入。 - -#### 4.4.4 关联项目 - -`关联项目` 承接当前产品下项目的查看,不承接项目归属维护。 - -展示: - -- 项目编码 -- 项目名称 -- 项目负责人 -- 项目状态 -- 更新时间 - -规则: - -- 只展示当前用户有项目查看权限的数据。 -- 不提供项目改挂产品入口。 -- 产品经理不因负责产品而自动获得该产品下全部项目查看或编辑权限。 - -### 4.5 对象维护方式 - -#### 4.5.1 产品新建与编辑 - -产品新建和编辑作为对象维护动作处理,不单独定义独立页面。 - -承接方式: - -- 新建产品:在入口态通过弹窗完成 -- 编辑产品:在当前产品上下文的概览中触发弹窗完成 - -表单字段保持一致: - -- 产品编码 -- 产品方向 -- 产品名称 -- 产品经理 -- 产品描述 - -规则: - -- 产品编码支持手动录入或自动生成;创建后不可修改。 -- 产品名称需校验未删除范围唯一。 -- 产品方向为必填正式字段,统一通过系统字典承接;字典类型编码统一为 `rdms_product_direction`,业务表字段 `direction_code` 存字典 `value`,不存字典数据主键 ID。 -- 研发令号不作为产品主表字段维护,按年度通过独立的产品研发令号能力维护。 -- 产品经理允许调整,不强制等于创建人。 -- 若产品经理不等于创建人,创建人只保留审计意义,不自动获得持续管理权限。 -- 保存成功后自动写入创建、更新审计字段;创建时默认状态编码为 `active`,并自动建立产品经理团队关系。 - -#### 4.5.2 轻量需求维护 - -轻量需求的新增和编辑,通过当前产品上下文内的弹窗完成,不拆为独立页面。 - -表单字段包括: - -- 需求标题 -- 需求分类 -- 需求来源 -- 来源引用信息 -- 优先级 -- 需求描述 -- 提出人 -- 默认实现项目 - -规则: - -- 产品需求来源至少包括手工新增和工单流转两类。 -- 手工新增需求通过新增弹窗录入,默认状态编码为 `pending_process`,显示状态为 `待处理`。 -- 工单流转到产品侧的需求,不通过普通新增弹窗直接进入正式需求池,而是先进入待确认队列,由产品负责人或授权管理员认领或拒绝。 -- 认领后,该需求进入正式产品需求池,状态进入 `待处理`;拒绝时必须填写原因,并保留来源工单追溯关系。 -- 工单来源需求在同一产品下只允许形成 1 条源头需求记录;后续如需细化,统一从该源头需求继续拆分子需求。 -- 手工新增需求也允许继续拆分子需求;来源追溯和拆分关系分开承接。 -- 是否进入 `待评审` 由业务判断决定;需要评审的需求进入 `待评审`,不需要评审的需求直接进入 `待分流`。 -- 一个产品需求只能归属一个产品。 -- `需求来源` 用于标识来源类型,如 `手工新增`、`工单流转`;如来源于工单,还应在 `来源引用信息` 中展示来源工单编号或引用信息。 -- `默认实现项目` 如有值,必须指向当前产品下的合法项目。 -- `reject`、`cancel`、`close` 等终态动作统一回写主表 `terminal_action_code`、`terminal_reason`、`terminal_time`。 -- V0.5 不启用目标版本字段。 - -## 5. 对象关系与数据设计 - -本章描述数据关系与承接口径,物理表结构以专项 SQL 文档为准。 - -### 5.1 数据结构承接口径 - -- 产品管理实际执行 SQL 统一以 `rdms-project/rdms-project-boot/src/main/resources/sql/product/01_product_schema.sql` 为准;专项 SQL 文档仅承担设计审阅和说明。 -- 系统 RBAC 角色与资源元数据以 `04-设计阶段/数据库/system_menu.sql` 的 DDL 为准。 -- RDMS 对象成员关系统一由 `rdms_user_object_role` 承接。 -- 产品研发令号统一由 `rdms_product_rd_order` 承接,按产品年度维护;同一产品可维护多个年度研发令号。 -- 产品方向统一由系统字典承接;字典类型编码统一为 `rdms_product_direction`,`rdms_product.direction_code` 存字典 `value`,不存 `system_dict_data.id`。 -- 产品状态统一由 `rdms_object_status_model`、`rdms_object_status_transition` 承接;`rdms_product.status_code` 存状态编码。 -- 产品需求统一由 `rdms_product_requirement` 承接;`status_code` 存需求状态编码,状态定义与流转统一由 `rdms_object_status_model`、`rdms_object_status_transition` 承接,其中 `object_type = product_requirement`。 -- 产品需求来源追溯和拆分关系分开建模;`source_type`、`source_biz_type`、`source_biz_id`、`source_biz_code` 承接来源追溯,`parent_requirement_id`、`root_requirement_id` 承接拆分链路。 -- 同一产品下,同一来源工单只允许一条源头需求记录;源头需求和手工新增需求都允许继续拆分为多个子需求,子需求不参与来源唯一约束。 -- 产品需求主表统一承接终态结果字段 `terminal_action_code`、`terminal_reason`、`terminal_time`;完整过程留痕继续由 `rdms_biz_audit_log` 承接。 -- 产品状态留痕必须保留 `from_status`、`to_status` 字段;产品状态日志和通用业务审计日志统一按状态编码记录前后变化。 - -### 5.2 保留的核心关系 - -```text -产品(Product) 1 ---- N 项目(Project) - | - +---- N RDMS对象成员关系(UserObjectRole, object_type=product) - | - +---- N 产品研发令号(ProductRdOrder) - | - +---- N 产品需求(ProductRequirement) - | - +---- N 子需求(ProductRequirement, parent_requirement_id) -``` - -关系约束: - -- 每个项目必须归属一个产品。 -- 产品与项目关系由项目模块的 `product_id` 维护,不建立独立中间表。 -- 产品团队和项目团队不是同一对象;项目团队是产品团队中的执行子集。 -- 产品研发令号不属于产品主数据的一对一字段;按产品维度一对多维护。 -- 同一产品同一年度只允许一个有效研发令号;研发令号本身默认不做全局唯一约束。 -- 产品需求只归属产品,不直接归属项目;需要项目承接时通过 `implement_project_id` 建立默认实现关系。 -- 来源追溯统一挂在源头需求上,父子拆分通过 `parent_requirement_id`、`root_requirement_id` 承接,不混用同一组字段表达两类关系。 -- 同一产品下,同一来源工单只保留 1 条源头需求记录;该源头需求和手工新增需求都可继续拆分子需求。 -- 当前用户在某产品中的对象角色,由 `rdms_user_object_role` 绑定 `system_role` 中 `scope_type = object`、`object_type = product` 的角色定义。 -- 当前产品上下文下的导航和按钮,由 `system_role`、`system_menu`、`system_role_menu` 共同驱动。 - -## 6. 详细业务规则 - -### 6.1 主数据规则 - -| 编号 | 规则 | -|---:|---| -| PDD-01 | 创建产品时必须填写产品名称、产品方向、产品经理;产品编码可手动录入,也可由系统自动生成。 | -| PDD-02 | 产品编码自动生成格式统一为 `CNPD + YYYY + NNN`,按年从 `001` 递增。 | -| PDD-03 | 产品编码、产品名称在未删除范围内必须唯一。 | -| PDD-04 | 产品创建后默认状态编码为 `active`,对应产品状态模型中的“启用”。 | -| PDD-05 | 产品编码创建后不可修改。 | -| PDD-06 | 产品研发令号不作为产品主表字段;应按产品维度一对多维护,统一由 `rdms_product_rd_order` 承接。 | -| PDD-07 | 同一产品同一年度只允许一个有效研发令号;研发令号本身默认不做全局唯一约束。 | -| PDD-08 | 产品方向为必填正式字段,统一通过系统字典承接;字典类型编码统一为 `rdms_product_direction`,`direction_code` 存字典 `value`,初始值为 `embedded`、`power_electronics`、`system_group`。 | -| PDD-09 | 产品经理允许变更;创建人不可编辑修改。 | -| PDD-10 | 产品不引入产品模板、产品类型等其他正式扩展字段;方向字段除外。 | - -### 6.2 产品团队规则 - -| 编号 | 规则 | -|---:|---| -| PDD-10 | V0.5 产品团队默认提供 `产品经理`、`产品成员`、`产品观察者` 三类对象角色。 | -| PDD-11 | 同一产品下,同一用户只允许存在一条有效团队关系。 | -| PDD-12 | 同一产品下,同一时刻只允许一个有效产品经理。 | -| PDD-13 | 产品经理变更时,必须同步产品主表和团队关系。 | -| PDD-14 | 产品团队成员是项目团队的人员来源池,但不自动继承项目权限。 | -| PDD-15 | 已退出产品团队的成员不影响历史项目关系,但不能继续被该产品下的新项目选入。 | - -### 6.3 产品与项目关系规则 - -| 编号 | 规则 | -|---:|---| -| PDD-20 | 项目创建时必须选择所属产品。 | -| PDD-21 | 项目创建后不允许改挂产品。 | -| PDD-22 | 当前产品上下文中的关联项目列表必须按项目权限二次过滤。 | -| PDD-23 | 建项目时的产品选择范围,按“`status_code = active` + 当前用户具备该产品建项目资格”过滤。 | -| PDD-24 | V0.5 默认仅产品经理和授权管理员具备在产品下建项目资格。 | - -### 6.4 产品可见范围规则 - -统一口径: - -`产品可见范围 = 关联项目反推范围 + 产品团队范围 + 创建人范围 + 授权管理员范围 + 特殊授权范围` - -细化规则: - -| 编号 | 规则 | -|---:|---| -| PDD-30 | 已有关联项目的产品,任何可见该产品下至少一个项目的用户,均可查看产品基础信息。 | -| PDD-31 | 尚未关联项目的产品,仅产品团队、创建人、授权管理员和特殊授权用户可见。 | -| PDD-32 | 产品可见不等于可编辑,也不等于可在该产品下建项目。 | -| PDD-33 | 关联项目、任务、工时、周报等执行数据权限仍按项目和任务侧口径控制。 | - -### 6.5 轻量需求规则 - -| 编号 | 规则 | -|---:|---| -| PDD-40 | 产品需求只承接通用、可复用、应进入标准产品能力的需求。 | -| PDD-41 | 单一项目、单一区域、单一客户交付诉求,应进入项目需求,不进入产品需求池。 | -| PDD-42 | 手工新增的产品需求默认进入 `待处理`;工单流转到产品侧的需求默认先进入 `待确认`。 | -| PDD-43 | 产品需求最多只挂一个默认实现项目。 | -| PDD-44 | 默认实现项目如有值,必须属于当前产品。 | -| PDD-45 | 产品需求录入、认领、拒绝、维护、关闭默认由产品经理或授权管理员承接。 | -| PDD-46 | 是否进入 `待评审` 由业务判断决定;需要评审的需求进入 `待评审`,不需要评审的需求可直接从 `待处理` 进入 `待分流`。 | -| PDD-47 | V0.5 不引入正式审批流、工作流引擎和复杂评审编排;`待评审` 仅表达业务评审待处理状态。 | -| PDD-48 | V0.5 不启用目标版本字段。 | -| PDD-49 | 产品需求本身不承接排期状态;排期由项目侧承接。 | -| PDD-50 | 工单流转到产品侧后,不等于自动进入正式产品需求池,需由产品负责人或授权管理员认领或拒绝。 | -| PDD-51 | 产品需求应保留来源类型和来源业务追溯信息;被拒绝的工单流转需求仍需可按来源与状态查询追溯。 | -| PDD-52 | 产品需求主表统一使用 `status_code`。 | -| PDD-53 | 同一产品下,同一来源工单只允许沉淀 1 条源头需求记录;该源头需求和手工新增需求都可继续拆分为 N 条子需求。 | -| PDD-54 | 来源追溯和拆分关系分开建模;来源由 `source_*` 字段承接,拆分由 `parent_requirement_id`、`root_requirement_id` 承接。 | -| PDD-55 | 子需求不参与来源唯一约束。 | -| PDD-56 | `reject`、`cancel`、`close` 等终态动作统一回写 `terminal_action_code`、`terminal_reason`、`terminal_time`,审计日志保留完整过程留痕。 | - -## 7. 状态机与流程设计 - -### 7.1 产品状态定义 - -| 状态 | 说明 | 是否可被新建项目选择 | 是否允许编辑 | 是否允许新增需求 | -|---|---|---|---|---| -| 启用(`active`) | 正常可用 | 是 | 是 | 是 | -| 暂停(`paused`) | 受环境或资源限制临时暂停推进,仅保留交接与存量维护 | 否 | 是,仅限交接与存量维护 | 否 | -| 归档(`archived`) | 历史留存,只读为主 | 否 | 否 | 否 | -| 废弃(`abandoned`) | 经调研或决策确认不再继续推进 | 否 | 否 | 否 | -| 逻辑删除 | 删除结果 | 否 | 否 | 否 | - -说明: - -- `rdms_product` 主表统一存 `status_code`。 -- 产品状态定义统一由 `rdms_object_status_model` 承接;状态动作与流转统一由 `rdms_object_status_transition` 承接。 -- `逻辑删除` 不纳入正常状态模型,通过 `deleted` 表达。 -- `paused` 允许通过 `resume` 动作恢复到 `active`。 -- `paused` 后不自动解绑历史项目和历史需求关系。 -- `paused` 后仅允许产品经理交接、产品团队维护、描述/备注修正和恢复启用,不允许继续新增产品需求。 -- `archived`、`abandoned` 和删除在 V0.5 不支持恢复。 - -### 7.2 产品状态流转 - -允许流转: - -- `active` --`pause`--> `paused` -- `paused` --`resume`--> `active` -- `active` --`archive`--> `archived` -- `paused` --`archive`--> `archived` -- `active` --`abandon`--> `abandoned` -- `paused` --`abandon`--> `abandoned` - -补充说明: - -- 上述流转以 `rdms_object_status_transition` 中 `object_type = product` 的配置为准。 -- 逻辑删除不属于正常状态流转,不写入 `to_status_code` 终态,而是通过 `deleted = 1` 表达。 - -### 7.3 产品状态动作前置条件 - -#### 7.3.1 暂停(`pause`) - -前置条件: - -- 当前状态为 `active`。 -- 产品下不存在状态为 `待开始`、`进行中`、`已暂停` 的项目。 -- 产品下不存在阻塞暂停的执行和任务。 -- 操作人具备暂停权限。 - -动作要求: - -- 必填暂停原因。 -- 暂停后不自动解绑历史项目和历史需求关系。 -- 暂停后仅允许产品经理交接、产品团队维护、描述/备注修正和恢复启用。 -- 暂停后不允许新增产品需求;已有需求可继续按权限查看、认领、分流、关闭等存量维护动作。 -- 写入状态日志。 - -#### 7.3.2 恢复(`resume`) - -前置条件: - -- 当前状态为 `paused`。 -- 操作人具备恢复权限。 - -动作要求: - -- 不强制填写原因,允许补充说明。 -- 写入状态日志。 - -#### 7.3.3 归档(`archive`) - -前置条件: - -- 当前状态为 `active` 或 `paused`。 -- 产品下不存在状态为 `待开始`、`进行中`、`已暂停` 的项目。 -- 产品下不存在阻塞归档的执行和任务。 -- 操作人具备归档权限。 - -动作要求: - -- 必填归档原因。 -- 归档后产品默认只读。 -- 写入状态日志。 - -#### 7.3.4 废弃(`abandon`) - -前置条件: - -- 当前状态为 `active` 或 `paused`。 -- 产品下不存在状态为 `待开始`、`进行中`、`已暂停` 的项目。 -- 产品下不存在阻塞废弃的执行和任务。 -- 操作人具备废弃权限。 - -动作要求: - -- 必填废弃原因。 -- 废弃后产品默认只读。 -- 废弃后不支持恢复。 -- 写入状态日志。 - -#### 7.3.5 逻辑删除 - -前置条件: - -- 产品下关联项目均已满足项目侧删除条件并已逻辑删除。 -- 操作人具备删除权限。 -- 二次身份确认通过。 -- 二次输入产品名称确认通过。 - -动作要求: - -- 必填删除原因。 -- 写入删除人、删除时间、删除原因。 -- 写入状态日志。 - -### 7.4 产品需求状态定义 - -| 状态编码 | 状态名称 | 说明 | -|---|---|---| -| `pending_confirm` | 待确认 | 工单流转到产品侧后的待确认状态,待产品负责人认领或拒绝 | -| `pending_process` | 待处理 | 手工新增后的默认状态,或工单流转需求认领后的正式初始状态 | -| `pending_review` | 待评审 | 仅针对需要评审的需求,待产品侧完成业务评审判断 | -| `pending_dispatch` | 待分流 | 需求成立,等待明确承接方向 | -| `dispatched` | 已分流 | 已明确承接方向 | -| `in_progress` | 实施中 | 承接项目已进入正式执行 | -| `accepted` | 已验收 | 承接结果已完成验收 | -| `closed` | 已关闭 | 生命周期闭环完成 | -| `rejected` | 已拒绝 | 评审后确认不成立 | -| `canceled` | 已取消 | 原已进入推进链路后终止 | - -### 7.5 产品需求状态流转 - -允许流转: - -- 待确认 -> 待处理 -- 待确认 -> 已拒绝 -- 待确认 -> 已取消 -- 待处理 -> 待评审 -- 待处理 -> 待分流 -- 待处理 -> 已拒绝 -- 待处理 -> 已取消 -- 待评审 -> 待分流 -- 待评审 -> 已拒绝 -- 待评审 -> 已取消 -- 待分流 -> 已分流 -- 待分流 -> 已取消 -- 已分流 -> 实施中 -- 已分流 -> 已取消 -- 实施中 -> 已验收 -- 实施中 -> 已取消 -- 已验收 -> 已关闭 - -补充规则: - -- 产品需求主表统一存 `status_code`;状态定义与流转统一以 `rdms_object_status_model`、`rdms_object_status_transition` 中 `object_type = product_requirement` 的配置为准。 -- 手工新增需求首状态为 `待处理`;工单流转需求首状态为 `待确认`。 -- `待确认 -> 待处理` 表示产品负责人已认领,需求正式进入产品需求池。 -- 只有需要评审的需求才进入 `待评审`;不需要评审的需求可直接从 `待处理` 进入 `待分流`。 -- `待评审` 仅表达业务评审待处理状态,V0.5 不展开正式审批流、会签/或签或工作流引擎实现。 -- `已分流 -> 实施中` 以承接项目进入正式执行为触发条件。 -- 到达 `已拒绝`、`已取消`、`已关闭` 时,主表同步回写 `terminal_action_code`、`terminal_reason`、`terminal_time`。 -- 产品需求认领、拒绝、分流、关闭等关键动作统一写入 `rdms_biz_audit_log`。 -- 已关闭、已拒绝、已取消状态默认不再继续流转。 - -### 7.6 关键流程 - -#### 7.6.1 创建产品 - -1. 用户在产品入口态点击“新建产品”,打开产品新增弹窗。 -2. 填写产品名称、产品方向、产品经理、产品描述,可手动录入产品编码。 -3. 系统校验名称唯一、编码唯一、产品方向字典值合法、产品经理有效。 -4. 编码为空时系统自动生成编码。 -5. 保存产品主数据,默认状态编码设为 `active`。 -6. 自动建立产品经理团队关系。 -7. 写入创建审计记录。 - -#### 7.6.2 变更产品经理 - -1. 当前产品经理或授权管理员发起编辑。 -2. 选择新的产品经理。 -3. 系统校验目标用户有效。 -4. 更新 `manager_user_id`。 -5. 同步调整产品团队角色关系,保证仅一个有效 `manager`。 -6. 写入 `rdms_biz_audit_log`,记录变更前后产品经理、操作者和原因。 - -#### 7.6.3 暂停产品 - -1. 用户发起 `pause` 动作。 -2. 系统按 `rdms_object_status_transition` 校验 `active -> paused` 流转存在且可用。 -3. 系统检查项目、执行、任务阻塞项。 -4. 用户填写暂停原因。 -5. 校验通过后写入状态编码为 `paused`。 -6. 写入 `rdms_product_status_log`。 - -#### 7.6.4 恢复产品 - -1. 用户发起 `resume` 动作。 -2. 系统按 `rdms_object_status_transition` 校验 `paused -> active` 流转存在且可用。 -3. 校验通过后写入状态编码为 `active`。 -4. 写入 `rdms_product_status_log`。 - -#### 7.6.5 归档产品 - -1. 用户发起 `archive` 动作。 -2. 系统按 `rdms_object_status_transition` 校验目标流转存在且可用。 -3. 系统检查归档阻塞项。 -4. 用户填写归档原因。 -5. 校验通过后更新为 `archived`。 -6. 写入 `rdms_product_status_log`。 - -#### 7.6.6 废弃产品 - -1. 用户发起 `abandon` 动作。 -2. 系统按 `rdms_object_status_transition` 校验目标流转存在且可用。 -3. 系统检查废弃阻塞项。 -4. 用户填写废弃原因。 -5. 校验通过后更新为 `abandoned`。 -6. 写入 `rdms_product_status_log`。 - -#### 7.6.7 删除产品 - -1. 授权管理员发起删除动作。 -2. 系统复核关联项目是否已全部逻辑删除。 -3. 弹出二次身份确认。 -4. 要求输入产品名称确认。 -5. 用户填写删除原因。 -6. 校验通过后执行逻辑删除,并写入 `rdms_product_status_log`。 - -#### 7.6.8 新增产品需求 - -1. 产品经理或授权管理员在当前产品上下文的“产品需求”功能中点击“新增需求”。 -2. 系统弹出新增需求弹窗,填写需求标题、分类、来源、优先级、描述、提出人、默认实现项目。 -3. 系统校验默认实现项目属于当前产品。 -4. 保存后状态编码默认为 `pending_process`,显示状态为 `待处理`。 -5. 写入 `rdms_biz_audit_log`。 - -#### 7.6.9 认领或拒绝工单流转需求 - -1. 工单归属到某产品后,产品侧生成一条来源为工单流转的源头需求记录,状态编码为 `pending_confirm`。 -2. 产品负责人或授权管理员在概览的待确认需求区块,或在产品需求列表的待确认视图中查看该记录。 -3. 若认领,系统将需求状态从 `pending_confirm` 更新为 `pending_process`,并保留来源工单追溯信息。 -4. 若拒绝,系统要求填写拒绝原因,并将需求状态更新为 `已拒绝`。 -5. 认领或拒绝后,系统写入 `rdms_biz_audit_log`;本设计不承接工单状态回写。 - -#### 7.6.10 产品需求分流 - -1. 产品经理在产品需求中判断该诉求是否成立。 -2. 若不成立,转 `已拒绝`。 -3. 若成立但待确定承接方向,转 `待分流`。 -4. 若已明确由具体项目承接,写入默认实现项目并转 `已分流`。 -5. 本次分流动作写入 `rdms_biz_audit_log`。 -6. 项目侧承接后,按项目需求和执行流转规则继续推进。 - -## 8. 权限与动作矩阵 - -### 8.1 角色定义 - -| 角色 | 说明 | -|---|---| -| 授权管理员 | 全局管理和高风险动作控制者 | -| 产品经理 | 单个产品的核心责任人 | -| 产品成员 | 产品侧协作成员 | -| 产品观察者 | 产品侧只读协作成员 | -| 创建人 | 审计身份,不作为长期业务角色 | - -### 8.2 动作矩阵 - -| 动作 | 授权管理员 | 产品经理 | 产品成员 | 产品观察者 | 创建人(非产品经理) | -|---|---|---|---|---|---| -| 查看产品 | 是 | 是 | 是 | 是 | 是 | -| 创建产品 | 是 | 有条件 | 否 | 否 | 否 | -| 编辑产品 | 是 | 是 | 否 | 否 | 否 | -| 管理产品团队 | 是 | 是 | 否 | 否 | 否 | -| 暂停/恢复/归档/废弃 | 是 | 是 | 否 | 否 | 否 | -| 删除产品 | 是 | 否 | 否 | 否 | 否 | -| 查看轻量需求 | 是 | 是 | 是 | 是 | 是 | -| 新增轻量需求 | 是 | 是 | 否 | 否 | 否 | -| 编辑轻量需求 | 是 | 是 | 否 | 否 | 否 | -| 关闭轻量需求 | 是 | 是 | 否 | 否 | 否 | -| 在该产品下建项目 | 是 | 是 | 否 | 否 | 否 | - -说明: - -- “有条件”表示需要同时具备全局创建产品权限。 -- 创建人若不是产品经理,只保留查看自己创建产品的可见补充,不自动拥有编辑和管理权限。 - -### 8.3 对象上下文权限与导航控制 - -产品管理属于对象上下文型业务域,因此权限判断需要拆成两层: - -- 全局 RBAC:控制用户能否进入 `产品管理` 业务域 -- 对象上下文 RBAC:控制用户在选中某个产品后,能看到哪些功能导航、访问哪些页面、使用哪些按钮 - -对象上下文下的权限控制规则如下: - -- 当前用户在某产品中的对象角色,以 `rdms_user_object_role.role_id` 为准,且 `object_type = product`。 -- 头部功能导航不直接按全局菜单固定展示,而应根据对象角色绑定的对象资源动态返回。 -- 页面按钮不直接复用全局按钮集合,而应根据当前产品上下文内的对象资源权限单独判断。 - -V0.5 产品对象上下文按以下方式控制: - -- 产品经理:可见 `概览`、`产品需求`、`产品团队`、`关联项目`,并具备产品编辑、状态动作、需求维护、团队维护等按钮权限。 -- 产品成员:可见 `概览`、`产品需求`、`关联项目`,默认不具备产品状态动作和团队维护权限。 -- 产品观察者:可见 `概览`、`产品需求`、`关联项目`,默认只读。 - -说明: - -- 是否展示 `产品团队` 导航项,应以对象角色授权结果为准,不要求所有产品角色都能看到。 -- 即使两个用户都能进入产品管理业务域,在不同产品中也可能看到不同的导航与按钮。 -- 导航、页面、按钮三类资源应统一通过对象资源模型配置,不在前端写死角色判断。 - -## 9. 接口承接 - -### 9.1 产品主数据接口 - -- `GET /products` -- `GET /products/{id}` -- `GET /products/{id}/context` -- `POST /products` -- `PUT /products/{id}` -- `POST /products/{id}/change-status` -- `DELETE /products/{id}` - -### 9.2 产品团队接口 - -- `GET /products/{id}/members` -- `POST /products/{id}/members` -- `PUT /products/{id}/members/{memberId}` -- `POST /products/{id}/members/{memberId}/inactive` - -### 9.3 产品需求接口 - -- `GET /products/{id}/requirements` -- `POST /products/{id}/requirements` -- `GET /products/{id}/requirements/{requirementId}` -- `PUT /products/{id}/requirements/{requirementId}` -- `POST /products/{id}/requirements/{requirementId}/change-status` - -### 9.4 产品关联与动态接口 - -- `GET /products/{id}/projects` -- `GET /products/{id}/activities` - -补充说明: - -- 概览中的待确认需求区块可直接复用 `GET /products/{id}/requirements`,并按 `statusCode=pending_confirm`、`sourceType=work_order`、`pageSize` 取前 `N` 条,无需重复建设独立接口。 -- `GET /products/{id}/projects` 只承接关联项目查看,列表结果仍需按项目权限二次过滤。 -- `GET /products/{id}/activities` 用于承接概览中的最近动态与“查看更多操作记录”,数据来源以 `rdms_biz_audit_log`、`rdms_product_status_log` 为主。 - -### 9.5 依赖与选择器接口说明 - -- 产品经理选择器复用用户中心用户查询接口 `GET /users`;产品服务负责校验目标用户有效性。 -- 产品团队成员选择器复用用户中心或组织用户查询接口 `GET /users`;产品服务负责校验成员有效性和产品团队约束。 -- 默认实现项目选择器复用项目模块查询接口 `GET /projects?productId={id}`;只返回当前产品下、当前用户可见的合法项目。 -- 查看来源工单复用工单模块详情接口 `GET /work-orders/{id}`;产品模块只保留来源引用信息。 - -### 9.6 接口共性要求 - -- 列表接口统一支持分页、筛选、排序。 -- 列表和详情接口必须做服务端权限裁剪。 -- 对象上下文型业务域应提供按对象返回上下文能力的接口,至少返回当前对象摘要、当前用户对象角色、对象导航集合、对象按钮权限集合和默认首页。 -- 状态动作不得复用通用编辑接口混写。 -- `POST /products/{id}/change-status` 必须按动作编码传参,不允许直接透传任意目标状态;V0.5 产品状态动作统一支持 `pause`、`resume`、`archive`、`abandon`。 -- `POST /products/{id}/requirements/{requirementId}/change-status` 必须按业务动作传参,不允许直接透传任意目标状态值;统一支持 `claim`、`to_review`、`to_dispatch`、`dispatch`、`reject`、`cancel`、`close` 动作。 -- `claim`、`reject` 动作仅适用于工单流转形成的待确认需求;`reject`、`cancel`、`close` 必须传原因。 -- `dispatch` 若直接明确承接项目,则必须同时传入 `implementProjectId`;服务端需校验该项目属于当前产品。 -- 需求详情和需求列表返回体中应保留 `statusCode`、`sourceType`、`sourceBizType`、`sourceBizId`、`sourceBizCode`、`parentRequirementId`、`rootRequirementId`、`terminalActionCode`、`terminalReason`、`terminalTime` 等字段,便于前端联动来源追溯、拆分链路和结果态展示。 -- 删除、归档、暂停、恢复、废弃、关闭等敏感动作必须记录审计信息。 - -## 10. 异常与边界 - -### 10.1 重复创建 - -- 前端需要防重复提交。 -- 后端以唯一约束作为最终兜底。 - -### 10.2 产品重名 - -- 若新名称与未删除产品重名,则拒绝保存。 - -### 10.3 产品建错 - -- 不支持直接修改项目的 `product_id` 纠偏。 -- 统一按“关闭/作废旧项目 + 新建正确项目”处理。 - -### 10.4 产品经理离岗 - -- 用户停用前应先完成产品经理转交。 -- 系统应提示存在未交接产品,避免静默失效。 - -### 10.5 无项目产品 - -- 产品创建后允许暂时没有项目。 -- 无项目产品不应全员可见。 -- 仅产品团队、创建人、授权管理员和特殊授权用户可见。 - -### 10.6 暂停、归档和废弃后的历史关系 - -- 暂停、归档、废弃不自动解除历史项目归属。 -- 历史统计、历史审计保留对该产品的引用。 -- 暂停、归档、废弃影响的是新增和维护能力,不影响历史追溯。 - -## 11. 测试关注点 - -### 11.1 功能测试 - -- 产品创建、编辑、查看、查询。 -- 产品编码手动录入与自动生成。 -- 产品方向必填、字典值校验、筛选与展示。 -- 产品研发令号按年度维护与同一产品同年度唯一校验。 -- 产品经理变更与团队同步。 -- 暂停、恢复、归档、废弃、删除动作。 -- 轻量需求创建、编辑、来源筛选、需求拆分、状态流转、关闭。 -- 工单流转需求的待确认、认领、拒绝与追溯。 - -### 11.2 权限测试 - -- 不同角色的按钮可见性。 -- 无权限用户越权编辑、删除、暂停、恢复、废弃拦截。 -- 当前产品上下文中的关联项目是否按项目权限二次过滤。 -- 建项目产品选择器是否按资格过滤。 - -### 11.3 数据一致性测试 - -- 产品经理字段与产品团队 `manager` 关系一致。 -- 删除前关联项目删除条件复核正确。 -- 默认实现项目必须属于当前产品。 -- 产品需求分流后与项目侧口径一致。 -- 工单流转需求的来源类型、来源业务 ID、来源业务编号是否保留一致。 -- 同一产品同一来源工单是否只生成 1 条源头需求记录,子需求是否不参与来源唯一约束。 -- 产品需求主表 `status_code`、`terminal_*` 字段与审计日志记录是否一致。 - -### 11.4 回归测试 - -- 产品暂停后是否不能再被新建项目选择。 -- 归档后产品是否只读。 -- 废弃后产品是否只读且不可恢复。 -- 创建人非产品经理时权限是否未被错误放大。 -- 产品成员退出后是否不再进入新项目成员池。 - -## 12. 对下游文档的约束 - -### 12.1 对数据库设计文档 - -- 产品管理相关物理表结构统一由产品管理专项 SQL 文档承接。 -- 必须落正式表:`rdms_product`、`rdms_product_rd_order`、`rdms_user_object_role`、`rdms_product_requirement`、`rdms_product_status_log`、`rdms_biz_audit_log`。 -- 必须落正式表:`rdms_object_status_model`、`rdms_object_status_transition`,并写入产品和产品需求的状态定义与流转配置。 -- 对象上下文 RBAC 的角色与资源元数据统一复用 `system_role`、`system_menu`、`system_role_menu`,并通过 DDL 定义 `scope_type`、`object_type` 字段。 -- `rdms_biz_audit_log` 虽放在产品管理专项 SQL 中维护,但定位为 RDMS 通用业务审计表,产品、项目共用。 -- 必须实现产品编码、产品名称的未删除范围唯一约束,以及产品研发令号按产品年度的未删除范围唯一约束。 -- 产品方向必须统一通过系统字典承接;字典类型编码统一为 `rdms_product_direction`,业务表字段存字典 `value`,不存字典数据主键 ID。 -- `rdms_product_requirement` 主表必须使用 `status_code`。 -- 必须区分来源追溯字段和需求拆分字段,不得再用同一组字段混合承接两类关系。 -- 必须保留 `terminal_action_code`、`terminal_reason`、`terminal_time` 三个终态结果字段。 -- 必须保留创建、更新、删除审计字段。 - -### 12.2 对 API 接口设计文档 - -- 必须按“主数据接口、团队接口、状态动作接口、需求接口”拆分。 -- 状态动作单独建接口,不与通用编辑混用。 -- 删除、归档、暂停、恢复、废弃、关闭动作必须定义审计参数或服务端补全规则。 - -### 12.3 对项目管理业务设计 - -- 项目必须归属产品。 -- 项目创建时的产品选择器范围按本文件规则执行。 -- 项目成员必须来源于所属产品的有效产品团队成员池。 -- 项目创建后不允许改挂产品。 - -### 12.4 对权限设计 - -- 需落实 `product_view`、`product_create`、`product_edit`、`product_status_manage`、`product_delete`、`product_member_manage`、`product_requirement_manage`、`project_create_under_product` 等动作权限。 -- 需落实产品可见范围、建项目资格范围、项目成员来源范围三套口径分离但联动的实现方式。 diff --git a/rdms-project/rdms-project-boot/product/03-工单到任务全链路与工作流方案.md b/rdms-project/rdms-project-boot/product/03-工单到任务全链路与工作流方案.md deleted file mode 100644 index 75469dc..0000000 --- a/rdms-project/rdms-project-boot/product/03-工单到任务全链路与工作流方案.md +++ /dev/null @@ -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 的开发顺序。 diff --git a/rdms-project/rdms-project-boot/product/04-产品管理_编码前必看清单.md b/rdms-project/rdms-project-boot/product/04-产品管理_编码前必看清单.md deleted file mode 100644 index 49ffbf2..0000000 --- a/rdms-project/rdms-project-boot/product/04-产品管理_编码前必看清单.md +++ /dev/null @@ -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 落地代码。 -- 不要在产品模块里扩展未确认的版本、路线图、附件等能力。 diff --git a/rdms-project/rdms-project-boot/product/05-产品管理_前端联调最小闭环清单.md b/rdms-project/rdms-project-boot/product/05-产品管理_前端联调最小闭环清单.md deleted file mode 100644 index 6887acb..0000000 --- a/rdms-project/rdms-project-boot/product/05-产品管理_前端联调最小闭环清单.md +++ /dev/null @@ -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` diff --git a/rdms-project/rdms-project-boot/product/06-产品主数据_API接口文档.md b/rdms-project/rdms-project-boot/product/06-产品主数据_API接口文档.md deleted file mode 100644 index 147635a..0000000 --- a/rdms-project/rdms-project-boot/product/06-产品主数据_API接口文档.md +++ /dev/null @@ -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`: - -```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`。 -- 若产品方向字典值未准备好,创建和更新接口会触发字典校验失败。 diff --git a/rdms-project/rdms-project-boot/product/rdms_biz_audit_log.sql b/rdms-project/rdms-project-boot/product/rdms_biz_audit_log.sql deleted file mode 100644 index 8da9a5d..0000000 --- a/rdms-project/rdms-project-boot/product/rdms_biz_audit_log.sql +++ /dev/null @@ -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_code,object_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_code,object_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; diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductController.java index 83e69a5..1b200a8 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductController.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductController.java @@ -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 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 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 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 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 deleteProduct(@Valid @RequestBody ProductDeleteReqVO reqVO) { productService.deleteProduct(reqVO); return success(true); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductMemberController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductMemberController.java new file mode 100644 index 0000000..ff34dbf --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductMemberController.java @@ -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> 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 createProductMember(@PathVariable("id") Long productId, + @Valid @RequestBody ProductMemberSaveReqVO reqVO) { + return success(productMemberService.createProductMember(productId, reqVO)); + } + + @PutMapping("/{id}/members/{memberId}") + @Operation(summary = "调整产品团队成员角色") + public CommonResult 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 inactiveProductMember(@PathVariable("id") Long productId, + @PathVariable("memberId") Long memberId, + @Valid @RequestBody ProductMemberInactiveReqVO reqVO) { + productMemberService.inactiveProductMember(productId, memberId, reqVO); + return success(true); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductSettingController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductSettingController.java new file mode 100644 index 0000000..8ef29f9 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/ProductSettingController.java @@ -0,0 +1,61 @@ +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.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 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> getProductActivities(@PathVariable("id") Long id, + @Valid ProductActivityPageReqVO reqVO) { + return success(productSettingService.getProductActivities(id, reqVO)); + } + + @PutMapping("/{id}/settings/base-info") + @Operation(summary = "更新产品设置基础信息") + @Parameter(name = "id", description = "产品编号", required = true, example = "1024") + public CommonResult updateProductBaseInfo(@PathVariable("id") Long id, + @Valid @RequestBody ProductSettingBaseInfoUpdateReqVO reqVO) { + productService.updateProductBaseInfo(id, reqVO); + return success(true); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/activity/ProductActivityPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/activity/ProductActivityPageReqVO.java new file mode 100644 index 0000000..83bf07d --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/activity/ProductActivityPageReqVO.java @@ -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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/activity/ProductActivityRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/activity/ProductActivityRespVO.java new file mode 100644 index 0000000..1f085a0 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/activity/ProductActivityRespVO.java @@ -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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberInactiveReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberInactiveReqVO.java new file mode 100644 index 0000000..ba918ea --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberInactiveReqVO.java @@ -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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberRespVO.java new file mode 100644 index 0000000..857727f --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberRespVO.java @@ -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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberSaveReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberSaveReqVO.java new file mode 100644 index 0000000..fc26a0a --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberSaveReqVO.java @@ -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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberUpdateReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberUpdateReqVO.java new file mode 100644 index 0000000..b2b2ad8 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberUpdateReqVO.java @@ -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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextNavRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextNavRespVO.java new file mode 100644 index 0000000..b468aac --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextNavRespVO.java @@ -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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextProductRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextProductRespVO.java new file mode 100644 index 0000000..f857ed4 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextProductRespVO.java @@ -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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextRespVO.java new file mode 100644 index 0000000..7e6582a --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextRespVO.java @@ -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 navs; + + @Schema(description = "当前产品下按钮权限码集合") + private List buttons; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextRoleRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextRoleRespVO.java new file mode 100644 index 0000000..a544378 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextRoleRespVO.java @@ -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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductDeleteReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductDeleteReqVO.java index e93dba4..9bba133 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductDeleteReqVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductDeleteReqVO.java @@ -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; + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductPageReqVO.java index 8da6b28..46524bd 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductPageReqVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductPageReqVO.java @@ -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") diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductRespVO.java index e56a55a..1c58cc1 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductRespVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductRespVO.java @@ -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; diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductSaveReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductSaveReqVO.java index 1066cbe..f0e9d89 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductSaveReqVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductSaveReqVO.java @@ -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; - } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingActionRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingActionRespVO.java new file mode 100644 index 0000000..f7523c1 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingActionRespVO.java @@ -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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingBaseInfoRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingBaseInfoRespVO.java new file mode 100644 index 0000000..7149e5e --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingBaseInfoRespVO.java @@ -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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingBaseInfoUpdateReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingBaseInfoUpdateReqVO.java new file mode 100644 index 0000000..08af5b0 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingBaseInfoUpdateReqVO.java @@ -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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingLifecycleRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingLifecycleRespVO.java new file mode 100644 index 0000000..c02bf5e --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingLifecycleRespVO.java @@ -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 availableActions; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingRespVO.java new file mode 100644 index 0000000..8bdf39a --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/setting/ProductSettingRespVO.java @@ -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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/member/UserObjectRoleDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/member/UserObjectRoleDO.java new file mode 100644 index 0000000..a0ce19c --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/member/UserObjectRoleDO.java @@ -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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/permission/SystemMenuDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/permission/SystemMenuDO.java new file mode 100644 index 0000000..fcd19a8 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/permission/SystemMenuDO.java @@ -0,0 +1,42 @@ +package com.njcn.rdms.module.project.dal.dataobject.permission; + +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("system_menu") +@Data +@EqualsAndHashCode(callSuper = true) +public class SystemMenuDO extends BaseDO { + + @TableId + private Long id; + + private String name; + + private String permission; + + private String scopeType; + + private String objectType; + + private Integer type; + + private Integer sort; + + private Long parentId; + + private String path; + + private String icon; + + private Integer status; + + private Boolean visible; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/permission/SystemRoleDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/permission/SystemRoleDO.java new file mode 100644 index 0000000..85b2f93 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/permission/SystemRoleDO.java @@ -0,0 +1,55 @@ +package com.njcn.rdms.module.project.dal.dataobject.permission; + +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("system_role") +@Data +@EqualsAndHashCode(callSuper = true) +public class SystemRoleDO extends BaseDO { + + /** + * 角色ID + */ + @TableId + private Long id; + /** + * 角色名称 + */ + private String name; + /** + * 角色编码 + */ + private String code; + /** + * 作用域类型 + */ + private String scopeType; + /** + * 对象类型 + */ + private String objectType; + /** + * 显示顺序 + */ + private Integer sort; + /** + * 角色状态 + */ + private Integer status; + /** + * 角色类型 + */ + private Integer type; + /** + * 备注 + */ + private String remark; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/permission/SystemRoleMenuDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/permission/SystemRoleMenuDO.java new file mode 100644 index 0000000..ad99ca3 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/permission/SystemRoleMenuDO.java @@ -0,0 +1,24 @@ +package com.njcn.rdms.module.project.dal.dataobject.permission; + +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("system_role_menu") +@Data +@EqualsAndHashCode(callSuper = true) +public class SystemRoleMenuDO extends BaseDO { + + @TableId + private Long id; + + private Long roleId; + + private Long menuId; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/product/ProductDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/product/ProductDO.java index fe287f6..6fbfa07 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/product/ProductDO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/product/ProductDO.java @@ -47,9 +47,5 @@ public class ProductDO extends BaseDO { * 最近一次状态动作原因 */ private String lastStatusReason; - /** - * 备注 - */ - private String remark; } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/status/ObjectStatusModelDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/status/ObjectStatusModelDO.java index bab6f47..63c5ea1 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/status/ObjectStatusModelDO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/status/ObjectStatusModelDO.java @@ -51,14 +51,6 @@ public class ObjectStatusModelDO extends BaseDO { * 是否允许编辑对象主数据 */ private Boolean allowEdit; - /** - * 是否允许新建项目 - */ - private Boolean allowCreateProject; - /** - * 是否允许新增需求 - */ - private Boolean allowCreateRequirement; /** * 备注 */ diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/audit/BizAuditLogMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/audit/BizAuditLogMapper.java index 71c79e3..d879001 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/audit/BizAuditLogMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/audit/BizAuditLogMapper.java @@ -1,9 +1,34 @@ 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 { + + default List selectListByBiz(String bizType, Long bizId, String actionType, LocalDateTime[] operateTime) { + return selectList(new LambdaQueryWrapperX() + .eq(BizAuditLogDO::getBizType, bizType) + .eq(BizAuditLogDO::getBizId, bizId) + .eqIfPresent(BizAuditLogDO::getActionType, actionType) + .betweenIfPresent(BaseDO::getCreateTime, operateTime) + .orderByDesc(BaseDO::getCreateTime) + .orderByDesc(BizAuditLogDO::getId)); + } + + default List selectListByBizType(String bizType, String actionType, LocalDateTime[] operateTime) { + return selectList(new LambdaQueryWrapperX() + .eq(BizAuditLogDO::getBizType, bizType) + .eqIfPresent(BizAuditLogDO::getActionType, actionType) + .betweenIfPresent(BaseDO::getCreateTime, operateTime) + .orderByDesc(BaseDO::getCreateTime) + .orderByDesc(BizAuditLogDO::getId)); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/member/UserObjectRoleMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/member/UserObjectRoleMapper.java new file mode 100644 index 0000000..a9b3571 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/member/UserObjectRoleMapper.java @@ -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 { + + default List selectListByObject(String objectType, Long objectId) { + return selectList(new LambdaQueryWrapperX() + .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() + .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() + .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() + .eq(UserObjectRoleDO::getObjectType, objectType) + .eq(UserObjectRoleDO::getObjectId, objectId) + .eq(UserObjectRoleDO::getUserId, userId) + .eq(UserObjectRoleDO::getStatus, 0)); + } + + default List selectListByIdsAndObject(List ids, String objectType, Long objectId) { + if (ids == null || ids.isEmpty()) { + return Collections.emptyList(); + } + return selectList(new LambdaQueryWrapperX() + .in(UserObjectRoleDO::getId, ids) + .eq(UserObjectRoleDO::getObjectType, objectType) + .eq(UserObjectRoleDO::getObjectId, objectId)); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/permission/SystemMenuMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/permission/SystemMenuMapper.java new file mode 100644 index 0000000..36d042f --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/permission/SystemMenuMapper.java @@ -0,0 +1,23 @@ +package com.njcn.rdms.module.project.dal.mysql.permission; + +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.permission.SystemMenuDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +@Mapper +public interface SystemMenuMapper extends BaseMapperX { + + default List selectListByIdsAndScopeAndObjectType(Collection ids, + String scopeType, + String objectType) { + return selectList(new LambdaQueryWrapperX() + .inIfPresent(SystemMenuDO::getId, ids) + .eq(SystemMenuDO::getScopeType, scopeType) + .eq(SystemMenuDO::getObjectType, objectType)); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/permission/SystemRoleMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/permission/SystemRoleMapper.java new file mode 100644 index 0000000..ba29e7d --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/permission/SystemRoleMapper.java @@ -0,0 +1,48 @@ +package com.njcn.rdms.module.project.dal.mysql.permission; + +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.permission.SystemRoleDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +@Mapper +public interface SystemRoleMapper extends BaseMapperX { + + default SystemRoleDO selectByIdAndScopeAndObjectType(Long id, String scopeType, String objectType) { + return selectOne(new LambdaQueryWrapperX() + .eq(SystemRoleDO::getId, id) + .eq(SystemRoleDO::getScopeType, scopeType) + .eq(SystemRoleDO::getObjectType, objectType) + .eq(SystemRoleDO::getStatus, 0)); + } + + default List selectListByIdsAndScopeAndObjectType(Collection ids, + String scopeType, + String objectType) { + return selectList(new LambdaQueryWrapperX() + .inIfPresent(SystemRoleDO::getId, ids) + .eq(SystemRoleDO::getScopeType, scopeType) + .eq(SystemRoleDO::getObjectType, objectType) + .eq(SystemRoleDO::getStatus, 0)); + } + + default SystemRoleDO selectByScopeAndObjectTypeAndCode(String scopeType, String objectType, String code) { + return selectOne(new LambdaQueryWrapperX() + .eq(SystemRoleDO::getScopeType, scopeType) + .eq(SystemRoleDO::getObjectType, objectType) + .eq(SystemRoleDO::getCode, code) + .eq(SystemRoleDO::getStatus, 0)); + } + + default SystemRoleDO selectByScopeAndObjectTypeAndName(String scopeType, String objectType, String name) { + return selectOne(new LambdaQueryWrapperX() + .eq(SystemRoleDO::getScopeType, scopeType) + .eq(SystemRoleDO::getObjectType, objectType) + .eq(SystemRoleDO::getName, name) + .eq(SystemRoleDO::getStatus, 0)); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/permission/SystemRoleMenuMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/permission/SystemRoleMenuMapper.java new file mode 100644 index 0000000..8b83cb1 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/permission/SystemRoleMenuMapper.java @@ -0,0 +1,16 @@ +package com.njcn.rdms.module.project.dal.mysql.permission; + +import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX; +import com.njcn.rdms.module.project.dal.dataobject.permission.SystemRoleMenuDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface SystemRoleMenuMapper extends BaseMapperX { + + default List selectListByRoleId(Long roleId) { + return selectList(SystemRoleMenuDO::getRoleId, roleId); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductMapper.java index 7d26e0b..beda994 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductMapper.java @@ -43,4 +43,19 @@ public interface ProductMapper extends BaseMapperX { .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() + .eq(ProductDO::getId, id) + .eq(ProductDO::getStatusCode, fromStatus)); + } + + default int deleteByIdAndStatus(Long id, String statusCode) { + return delete(new LambdaQueryWrapperX() + .eq(ProductDO::getId, id) + .eq(ProductDO::getStatusCode, statusCode)); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductStatusLogMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductStatusLogMapper.java index 699ff00..ffb9fee 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductStatusLogMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductStatusLogMapper.java @@ -1,9 +1,24 @@ 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 { + + default List selectListByProductId(Long productId, String actionType, LocalDateTime[] operateTime) { + return selectList(new LambdaQueryWrapperX() + .eq(ProductStatusLogDO::getProductId, productId) + .eqIfPresent(ProductStatusLogDO::getActionType, actionType) + .betweenIfPresent(BaseDO::getCreateTime, operateTime) + .orderByDesc(BaseDO::getCreateTime) + .orderByDesc(ProductStatusLogDO::getId)); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/status/ObjectStatusModelMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/status/ObjectStatusModelMapper.java index d9d07bd..068473c 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/status/ObjectStatusModelMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/status/ObjectStatusModelMapper.java @@ -16,6 +16,13 @@ public interface ObjectStatusModelMapper extends BaseMapperX() + .eq(ObjectStatusModelDO::getObjectType, objectType) + .eq(ObjectStatusModelDO::getStatusCode, statusCode) + .eq(ObjectStatusModelDO::getStatus, 0)); + } + default List selectListByObjectType(String objectType) { return selectList(new LambdaQueryWrapperX() .eq(ObjectStatusModelDO::getObjectType, objectType) diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/annotation/CheckObjectPermission.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/annotation/CheckObjectPermission.java new file mode 100644 index 0000000..a26925e --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/annotation/CheckObjectPermission.java @@ -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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/aop/ObjectPermissionAspect.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/aop/ObjectPermissionAspect.java new file mode 100644 index 0000000..83911a7 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/aop/ObjectPermissionAspect.java @@ -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 objectPermissionServiceMap; + + public ObjectPermissionAspect(List 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); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ObjectPermissionService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ObjectPermissionService.java new file mode 100644 index 0000000..67a09ed --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ObjectPermissionService.java @@ -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); + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionService.java new file mode 100644 index 0000000..58ab66c --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionService.java @@ -0,0 +1,104 @@ +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.dataobject.permission.SystemMenuDO; +import com.njcn.rdms.module.project.dal.dataobject.permission.SystemRoleMenuDO; +import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper; +import com.njcn.rdms.module.project.dal.mysql.permission.SystemMenuMapper; +import com.njcn.rdms.module.project.dal.mysql.permission.SystemRoleMenuMapper; +import com.njcn.rdms.module.project.enums.ErrorCodeConstants; +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.List; +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 SystemRoleMenuMapper systemRoleMenuMapper; + @Resource + private SystemMenuMapper systemMenuMapper; + + @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 getRolePermissions(Long roleId) { + List roleMenus = systemRoleMenuMapper.selectListByRoleId(roleId); + if (roleMenus == null || roleMenus.isEmpty()) { + return Collections.emptySet(); + } + Set menuIds = roleMenus.stream() + .map(SystemRoleMenuDO::getMenuId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (menuIds.isEmpty()) { + return Collections.emptySet(); + } + List menus = systemMenuMapper.selectListByIdsAndScopeAndObjectType( + menuIds, ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE); + if (menus == null || menus.isEmpty()) { + return Collections.emptySet(); + } + return menus.stream() + .filter(menu -> ROLE_SCOPE_OBJECT.equals(menu.getScopeType())) + .filter(menu -> PRODUCT_OBJECT_TYPE.equals(menu.getObjectType())) + .filter(menu -> Integer.valueOf(0).equals(menu.getStatus())) + .map(SystemMenuDO::getPermission) + .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); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryService.java new file mode 100644 index 0000000..60e45d4 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryService.java @@ -0,0 +1,187 @@ +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.common.util.object.PageUtils; +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 jakarta.annotation.Resource; +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.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class ProductActivityQueryService { + + private static final String PRODUCT_OBJECT_TYPE = "product"; + private static final String MEMBER_BIZ_TYPE = "rdms_user_object_role"; + + private static final String ACTIVITY_TYPE_STATUS = "status"; + private static final String ACTIVITY_TYPE_PRODUCT = "product"; + private static final String ACTIVITY_TYPE_MEMBER = "member"; + + @Resource + private ProductStatusLogMapper productStatusLogMapper; + @Resource + private BizAuditLogMapper bizAuditLogMapper; + @Resource + private UserObjectRoleMapper userObjectRoleMapper; + + public PageResult getProductActivities(Long productId, ProductActivityPageReqVO reqVO) { + List items = new ArrayList<>(); + if (includeType(reqVO.getActivityType(), 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(), 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(), 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 activities = items.stream() + .map(ActivityItem::respVO) + .toList(); + return buildPageResult(activities, reqVO); + } + + private void appendMemberActivities(Long productId, ProductActivityPageReqVO reqVO, List items) { + List memberLogs = bizAuditLogMapper + .selectListByBizType(MEMBER_BIZ_TYPE, reqVO.getActionType(), reqVO.getOperateTime()); + if (memberLogs.isEmpty()) { + return; + } + + List memberIds = memberLogs.stream() + .map(BizAuditLogDO::getBizId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (memberIds.isEmpty()) { + return; + } + + Map 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 buildPageResult(List 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 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(ACTIVITY_TYPE_STATUS); + respVO.setActionType(log.getActionType()); + respVO.setActionName(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 buildStatusDetails(ProductStatusLogDO log) { + Map 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(ACTIVITY_TYPE_PRODUCT); + respVO.setActionType(log.getActionType()); + respVO.setActionName(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(ACTIVITY_TYPE_MEMBER); + return respVO; + } + + private String resolveActionName(String actionType) { + if (!StringUtils.hasText(actionType)) { + return actionType; + } + return switch (actionType.trim()) { + case "create" -> "创建"; + case "update" -> "更新"; + case "delete" -> "删除"; + case "pause" -> "暂停"; + case "resume" -> "恢复"; + case "archive" -> "归档"; + case "abandon" -> "废弃"; + case "change_manager" -> "切换产品经理"; + case "add_member" -> "新增成员"; + case "update_member" -> "调整成员"; + case "remove_member" -> "移出成员"; + default -> actionType.trim(); + }; + } + + 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) { + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberService.java new file mode 100644 index 0000000..37f1f43 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberService.java @@ -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 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); + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImpl.java new file mode 100644 index 0000000..637bf6f --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImpl.java @@ -0,0 +1,414 @@ +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.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.permission.SystemRoleDO; +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.permission.SystemRoleMapper; +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 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"; + + private static final String AUDIT_BIZ_TYPE_MEMBER = "rdms_user_object_role"; + private static final String AUDIT_BIZ_TYPE_PRODUCT = "product"; + private static final String AUDIT_ACTION_ADD_MEMBER = "add_member"; + private static final String AUDIT_ACTION_UPDATE_MEMBER = "update_member"; + private static final String AUDIT_ACTION_REMOVE_MEMBER = "remove_member"; + private static final String AUDIT_ACTION_CHANGE_MANAGER = "change_manager"; + + @Resource + private ProductMapper productMapper; + @Resource + private UserObjectRoleMapper userObjectRoleMapper; + @Resource + private SystemRoleMapper systemRoleMapper; + @Resource + private BizAuditLogMapper bizAuditLogMapper; + @Resource + private AdminUserApi adminUserApi; + + @Override + @CheckObjectPermission(objectType = PRODUCT_OBJECT_TYPE, objectId = "#productId", + permission = PRODUCT_QUERY_PERMISSION) + public List getProductMemberList(Long productId) { + ProductDO product = validateProductExists(productId); + List members = userObjectRoleMapper.selectListByObject(PRODUCT_OBJECT_TYPE, productId); + Map roleMap = getRoleMap(members.stream().map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet())); + Map 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()); + SystemRoleDO 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); + SystemRoleDO 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, AUDIT_ACTION_ADD_MEMBER, 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); + } + + SystemRoleDO 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, AUDIT_ACTION_UPDATE_MEMBER, 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, AUDIT_ACTION_REMOVE_MEMBER, 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 SystemRoleDO validateProductRole(Long roleId) { + SystemRoleDO role = systemRoleMapper.selectByIdAndScopeAndObjectType(roleId, ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE); + 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; + } + + SystemRoleDO 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 SystemRoleDO 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); + } + SystemRoleDO 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 = AUDIT_ACTION_ADD_MEMBER; + } 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 = AUDIT_ACTION_ADD_MEMBER; + } else { + actionType = AUDIT_ACTION_UPDATE_MEMBER; + } + userObjectRoleMapper.updateById(member); + } + writeMemberAuditLog(member, actionType, before, member, reason); + } + + private boolean isManagerRole(SystemRoleDO role) { + return Objects.equals(PRODUCT_MANAGER_ROLE_CODE, role.getCode()); + } + + private Map getRoleMap(Set roleIds) { + if (roleIds.isEmpty()) { + return Collections.emptyMap(); + } + List roles = systemRoleMapper + .selectListByIdsAndScopeAndObjectType(roleIds, ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE); + return roles.stream().collect(Collectors.toMap(SystemRoleDO::getId, Function.identity())); + } + + private Map getUserMap(Set 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(AUDIT_BIZ_TYPE_MEMBER); + 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(AUDIT_BIZ_TYPE_PRODUCT); + auditLog.setBizId(productId); + auditLog.setActionType(AUDIT_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 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 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 valueOf(UserObjectRoleDO member, Function getter) { + return member == null ? null : getter.apply(member); + } + + private void appendFieldChange(Map fieldChanges, String fieldName, Object before, Object after) { + if (Objects.equals(before, after)) { + return; + } + Map 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 : ""; + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductService.java index 9e2be4f..24747f5 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductService.java @@ -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); + /** * 获取产品分页 * diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java index 68fd2c1..a2bf2d0 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java @@ -5,30 +5,54 @@ 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.ProductContextNavRespVO; +import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextProductRespVO; +import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRoleRespVO; +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.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.permission.SystemMenuDO; +import com.njcn.rdms.module.project.dal.dataobject.permission.SystemRoleDO; +import com.njcn.rdms.module.project.dal.dataobject.permission.SystemRoleMenuDO; 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.permission.SystemMenuMapper; +import com.njcn.rdms.module.project.dal.mysql.permission.SystemRoleMapper; +import com.njcn.rdms.module.project.dal.mysql.permission.SystemRoleMenuMapper; 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.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.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashSet; 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; import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; @@ -40,16 +64,29 @@ 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 Integer MEMBER_STATUS_ACTIVE = 0; + 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 String PRODUCT_CHANGE_MANAGER_ACTION = "change_manager"; + private static final String PRODUCT_ADD_MEMBER_ACTION = "add_member"; + private static final String AUDIT_BIZ_TYPE_MEMBER = "rdms_user_object_role"; 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,6 +97,14 @@ public class ProductServiceImpl implements ProductService { @Resource private ObjectStatusTransitionMapper objectStatusTransitionMapper; @Resource + private UserObjectRoleMapper userObjectRoleMapper; + @Resource + private SystemRoleMapper systemRoleMapper; + @Resource + private SystemRoleMenuMapper systemRoleMenuMapper; + @Resource + private SystemMenuMapper systemMenuMapper; + @Resource private AdminUserApi adminUserApi; @Override @@ -75,44 +120,82 @@ 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); + initManagerMemberRelation(product); writeBizAuditLog(product, PRODUCT_CREATE_ACTION, null, PRODUCT_ACTIVE_STATUS, - buildFieldChanges(null, product), null); + 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; + } + + SystemRoleDO currentRole = systemRoleMapper + .selectByIdAndScopeAndObjectType(currentMember.getRoleId(), ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE); + respVO.setCurrentRole(buildCurrentRole(currentMember, currentRole)); + + List roleMenus = systemRoleMenuMapper.selectListByRoleId(currentMember.getRoleId()); + if (roleMenus.isEmpty()) { + respVO.setNavs(Collections.emptyList()); + respVO.setButtons(Collections.emptyList()); + return respVO; + } + + Set menuIds = roleMenus.stream() + .map(SystemRoleMenuDO::getMenuId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + List menus = filterEnableProductObjectMenus( + systemMenuMapper.selectListByIdsAndScopeAndObjectType(menuIds, ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE)); + + respVO.setNavs(buildContextNavs(menus)); + respVO.setButtons(buildContextButtons(menus)); + return respVO; + } + @Override public PageResult getProductPage(ProductPageReqVO pageReqVO) { return productMapper.selectPage(pageReqVO); @@ -120,6 +203,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 +214,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,15 +227,21 @@ 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); @@ -213,8 +307,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 +328,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 +351,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 +385,90 @@ public class ProductServiceImpl implements ProductService { return generatedCode; } + private void initManagerMemberRelation(ProductDO product) { + SystemRoleDO managerRole = systemRoleMapper + .selectByScopeAndObjectTypeAndCode(ROLE_SCOPE_OBJECT, PRODUCT_OBJECT_TYPE, PRODUCT_MANAGER_ROLE_CODE); + 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 List filterEnableProductObjectMenus(List menus) { + if (menus == null || menus.isEmpty()) { + return Collections.emptyList(); + } + return menus.stream() + .filter(menu -> Objects.equals(ROLE_SCOPE_OBJECT, menu.getScopeType())) + .filter(menu -> Objects.equals(PRODUCT_OBJECT_TYPE, menu.getObjectType())) + .filter(menu -> Objects.equals(0, menu.getStatus())) + .collect(Collectors.toList()); + } + + private ProductContextProductRespVO buildCurrentProduct(ProductDO product) { + return BeanUtils.toBean(product, ProductContextProductRespVO.class); + } + + private ProductContextRoleRespVO buildCurrentRole(UserObjectRoleDO currentMember, SystemRoleDO currentRole) { + ProductContextRoleRespVO roleRespVO = new ProductContextRoleRespVO(); + roleRespVO.setRoleId(currentMember.getRoleId()); + if (currentRole != null) { + roleRespVO.setRoleCode(currentRole.getCode()); + roleRespVO.setRoleName(currentRole.getName()); + } + return roleRespVO; + } + + private List buildContextNavs(List menus) { + if (menus.isEmpty()) { + return Collections.emptyList(); + } + + List 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 buildContextButtons(List menus) { + if (menus.isEmpty()) { + return Collections.emptyList(); + } + return menus.stream() + .filter(menu -> MenuTypeEnum.BUTTON.getType().equals(menu.getType())) + .map(SystemMenuDO::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 +499,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(AUDIT_BIZ_TYPE_MEMBER); + auditLog.setBizId(member.getId()); + auditLog.setActionType(PRODUCT_ADD_MEMBER_ACTION); + 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(PRODUCT_CHANGE_MANAGER_ACTION); + 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, PRODUCT_UPDATE_ACTION, before.getStatusCode(), product.getStatusCode(), + buildProductFieldChanges(before, product), null); + } + + private String buildProductFieldChanges(ProductDO before, ProductDO after) { Map fieldChanges = new LinkedHashMap<>(); appendFieldChange(fieldChanges, "code", valueOf(before, ProductDO::getCode), valueOf(after, ProductDO::getCode)); appendFieldChange(fieldChanges, "directionCode", valueOf(before, ProductDO::getDirectionCode), @@ -315,10 +562,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 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 fieldChanges = new LinkedHashMap<>(); + appendFieldChange(fieldChanges, "managerUserId", beforeManagerUserId, afterManagerUserId); + return JsonUtils.toJsonString(fieldChanges); + } + private T valueOf(ProductDO product, Function getter) { return product == null ? null : getter.apply(product); } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingService.java new file mode 100644 index 0000000..56cb538 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingService.java @@ -0,0 +1,30 @@ +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.setting.ProductSettingRespVO; + +/** + * 产品设置 Service 接口 + */ +public interface ProductSettingService { + + /** + * 获取产品设置 + * + * @param productId 产品编号 + * @return 产品设置 + */ + ProductSettingRespVO getProductSettings(Long productId); + + /** + * 获取产品动态 + * + * @param productId 产品编号 + * @param reqVO 查询参数 + * @return 产品动态分页 + */ + PageResult getProductActivities(Long productId, ProductActivityPageReqVO reqVO); + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImpl.java new file mode 100644 index 0000000..86eb372 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImpl.java @@ -0,0 +1,93 @@ +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.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; + + @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 getProductActivities(Long productId, ProductActivityPageReqVO reqVO) { + validateProductExists(productId); + return productActivityQueryService.getProductActivities(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 result = adminUserApi.getUser(managerUserId); + AdminUserRespDTO user = result == null ? null : result.getCheckedData(); + return user == null ? null : user.getNickname(); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductStatusViewService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductStatusViewService.java new file mode 100644 index 0000000..d5bd432 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductStatusViewService.java @@ -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 buildAvailableActions(String statusCode) { + List 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(); + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/aop/ObjectPermissionAspectTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/aop/ObjectPermissionAspectTest.java new file mode 100644 index 0000000..ff5ea0a --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/aop/ObjectPermissionAspectTest.java @@ -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; + } + + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionServiceTest.java new file mode 100644 index 0000000..7d71754 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionServiceTest.java @@ -0,0 +1,128 @@ +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.dataobject.permission.SystemMenuDO; +import com.njcn.rdms.module.project.dal.dataobject.permission.SystemRoleMenuDO; +import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper; +import com.njcn.rdms.module.project.dal.mysql.permission.SystemMenuMapper; +import com.njcn.rdms.module.project.dal.mysql.permission.SystemRoleMenuMapper; +import com.njcn.rdms.module.project.enums.ErrorCodeConstants; +import com.njcn.rdms.module.system.enums.permission.MenuTypeEnum; +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.List; + +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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +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 SystemRoleMenuMapper systemRoleMenuMapper; + @Mock + private SystemMenuMapper systemMenuMapper; + + @Test + void checkPermission_whenMemberOnlyAndCurrentUserIsMember_shouldPass() { + Long productId = 1001L; + Long loginUserId = 2001L; + when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId)) + .thenReturn(createMember(productId, loginUserId, 3001L)); + + try (MockedStatic mockedStatic = mockLoginUser(loginUserId)) { + assertDoesNotThrow(() -> permissionService.checkPermission(productId, null, true)); + } + + verifyNoInteractions(systemRoleMenuMapper, systemMenuMapper); + } + + @Test + void checkPermission_whenQueryPermissionConfiguredOnMenuNode_shouldPass() { + Long productId = 1002L; + Long loginUserId = 2002L; + when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId)) + .thenReturn(createMember(productId, loginUserId, 3002L)); + when(systemRoleMenuMapper.selectListByRoleId(3002L)) + .thenReturn(List.of(createRoleMenu(3002L, 4002L))); + when(systemMenuMapper.selectListByIdsAndScopeAndObjectType(any(), + eq(PermissionScopeTypeEnum.OBJECT.getScopeType()), eq("product"))) + .thenReturn(List.of(createMenu(4002L, MenuTypeEnum.MENU.getType(), "project:product:query"))); + + try (MockedStatic 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(systemRoleMenuMapper.selectListByRoleId(3003L)) + .thenReturn(List.of(createRoleMenu(3003L, 4003L))); + when(systemMenuMapper.selectListByIdsAndScopeAndObjectType(any(), + eq(PermissionScopeTypeEnum.OBJECT.getScopeType()), eq("product"))) + .thenReturn(List.of(createMenu(4003L, MenuTypeEnum.BUTTON.getType(), "project:product:update"))); + + try (MockedStatic mockedStatic = mockLoginUser(loginUserId)) { + ServiceException ex = assertThrows(ServiceException.class, + () -> permissionService.checkPermission(productId, "project:product:delete", false)); + assertEquals(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode()); + } + } + + 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 SystemRoleMenuDO createRoleMenu(Long roleId, Long menuId) { + SystemRoleMenuDO roleMenu = new SystemRoleMenuDO(); + roleMenu.setId(9101L); + roleMenu.setRoleId(roleId); + roleMenu.setMenuId(menuId); + return roleMenu; + } + + private SystemMenuDO createMenu(Long menuId, Integer type, String permission) { + SystemMenuDO menu = new SystemMenuDO(); + menu.setId(menuId); + menu.setType(type); + menu.setPermission(permission); + menu.setScopeType(PermissionScopeTypeEnum.OBJECT.getScopeType()); + menu.setObjectType("product"); + menu.setStatus(0); + return menu; + } + + private MockedStatic mockLoginUser(Long loginUserId) { + MockedStatic mockedStatic = mockStatic(SecurityFrameworkUtils.class); + mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId); + return mockedStatic; + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryServiceTest.java new file mode 100644 index 0000000..5c5f2e3 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductActivityQueryServiceTest.java @@ -0,0 +1,113 @@ +package com.njcn.rdms.module.project.service.product; + +import com.njcn.rdms.framework.common.pojo.PageResult; +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 org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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; + + @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 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 result = productActivityQueryService.getProductActivities(productId, reqVO); + + assertEquals(0L, result.getTotal()); + assertEquals(0, result.getList().size()); + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductServiceImplTest.java new file mode 100644 index 0000000..8b434f1 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductServiceImplTest.java @@ -0,0 +1,448 @@ +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.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.permission.SystemMenuDO; +import com.njcn.rdms.module.project.dal.dataobject.permission.SystemRoleMenuDO; +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.permission.SystemMenuMapper; +import com.njcn.rdms.module.project.dal.mysql.permission.SystemRoleMapper; +import com.njcn.rdms.module.project.dal.mysql.permission.SystemRoleMenuMapper; +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.user.AdminUserApi; +import com.njcn.rdms.module.system.enums.permission.MenuTypeEnum; +import com.njcn.rdms.module.system.enums.permission.PermissionScopeTypeEnum; +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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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.ArgumentMatchers.eq; +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 SystemRoleMapper systemRoleMapper; + @Mock + private SystemRoleMenuMapper systemRoleMenuMapper; + @Mock + private SystemMenuMapper systemMenuMapper; + @Mock + private AdminUserApi adminUserApi; + + @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 mockedStatic = mockLoginUser(loginUserId, "测试人")) { + productService.changeProductStatus(reqVO); + } + + verify(productMapper, times(1)).updateStatusByIdAndStatus(productId, "active", "paused", "环境受限"); + + ArgumentCaptor 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 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 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 mockedStatic = mockLoginUser(loginUserId, "测试人")) { + productService.deleteProduct(reqVO); + } + + verify(productMapper, times(1)).deleteByIdAndStatus(productId, "active"); + + ArgumentCaptor 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()); + } + + 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 void mockObjectPermission(Long productId, Long loginUserId, String permission) { + UserObjectRoleDO currentMember = new UserObjectRoleDO(); + currentMember.setId(9001L); + currentMember.setUserId(loginUserId); + currentMember.setObjectType("product"); + currentMember.setObjectId(productId); + currentMember.setRoleId(9101L); + currentMember.setStatus(0); + + SystemRoleMenuDO roleMenu = new SystemRoleMenuDO(); + roleMenu.setId(9201L); + roleMenu.setRoleId(9101L); + roleMenu.setMenuId(9301L); + + SystemMenuDO menu = new SystemMenuDO(); + menu.setId(9301L); + menu.setPermission(permission); + menu.setScopeType(PermissionScopeTypeEnum.OBJECT.getScopeType()); + menu.setObjectType("product"); + menu.setType(MenuTypeEnum.BUTTON.getType()); + menu.setStatus(0); + menu.setVisible(true); + + when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId)) + .thenReturn(currentMember); + when(systemRoleMenuMapper.selectListByRoleId(9101L)).thenReturn(List.of(roleMenu)); + when(systemMenuMapper.selectListByIdsAndScopeAndObjectType( + any(), eq(PermissionScopeTypeEnum.OBJECT.getScopeType()), eq("product"))) + .thenReturn(List.of(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 mockLoginUser(Long loginUserId, String nickname) { + MockedStatic mockedStatic = mockStatic(SecurityFrameworkUtils.class); + mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId); + mockedStatic.when(SecurityFrameworkUtils::getLoginUserNickname).thenReturn(nickname); + return mockedStatic; + } + + private ProductDO captureUpdatedProduct() { + ArgumentCaptor productCaptor = ArgumentCaptor.forClass(ProductDO.class); + verify(productMapper, times(1)).updateById(productCaptor.capture()); + return productCaptor.getValue(); + } + + private BizAuditLogDO captureAuditLog() { + ArgumentCaptor 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")); + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImplTest.java new file mode 100644 index 0000000..f40b6e6 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductSettingServiceImplTest.java @@ -0,0 +1,172 @@ +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.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; + + @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 pageResult = new PageResult<>(List.of(respVO), 1L); + + when(productMapper.selectById(productId)).thenReturn(product); + when(productActivityQueryService.getProductActivities(productId, reqVO)).thenReturn(pageResult); + + PageResult 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()); + } + +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductStatusViewServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductStatusViewServiceTest.java new file mode 100644 index 0000000..f4fb596 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductStatusViewServiceTest.java @@ -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()); + } + +} diff --git a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/DictTypeConstants.java b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/DictTypeConstants.java index 43328b9..513eab3 100644 --- a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/DictTypeConstants.java +++ b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/DictTypeConstants.java @@ -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"; diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/DictDataController.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/DictDataController.java index ee8aeb2..fdb7d7d 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/DictDataController.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/DictDataController.java @@ -26,7 +26,10 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import static com.njcn.rdms.framework.apilog.core.enums.OperateTypeEnum.EXPORT; import static com.njcn.rdms.framework.common.pojo.CommonResult.success; @@ -79,13 +82,26 @@ public class DictDataController { @GetMapping(value = {"/list-all-simple", "simple-list"}) @Operation(summary = "获得全部字典数据列表", description = "一般用于管理后台缓存字典数据在本地") - // 无需添加权限认证,因为前端全局都需要 + // 不额外校验菜单权限,前端在登录后可直接拉取并缓存 public CommonResult> getSimpleDictDataList() { List list = dictDataService.getDictDataList( CommonStatusEnum.ENABLE.getStatus(), null); return success(BeanUtils.toBean(list, DictDataSimpleRespVO.class)); } + @GetMapping("/frontend-cache") + @Operation(summary = "获得前端运行时字典缓存") + public CommonResult>> getFrontendCache() { + List list = BeanUtils.toBean( + dictDataService.getDictDataList(CommonStatusEnum.ENABLE.getStatus(), null), + DictDataSimpleRespVO.class); + Map> result = new LinkedHashMap<>(); + // 基于 service 已排好序的结果分组,保证每个字典组内仍按 sort 升序返回。 + list.forEach(dictData -> result.computeIfAbsent(dictData.getDictType(), + key -> new ArrayList<>()).add(dictData)); + return success(result); + } + @GetMapping("/page") @Operation(summary = "获得字典类型的分页") @PreAuthorize("@ss.hasPermission('system:dict:query')") diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/vo/data/DictDataSimpleRespVO.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/vo/data/DictDataSimpleRespVO.java index e6bbd4b..d4596cc 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/vo/data/DictDataSimpleRespVO.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/dict/vo/data/DictDataSimpleRespVO.java @@ -16,6 +16,9 @@ public class DictDataSimpleRespVO { @Schema(description = "字典标签", requiredMode = Schema.RequiredMode.REQUIRED, example = "男") private String label; + @Schema(description = "排序值", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer sort; + @Schema(description = "颜色类型,default、primary、success、info、warning、danger", example = "default") private String colorType; diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/framework/cache/PermissionCacheStartupCleaner.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/framework/cache/PermissionCacheStartupCleaner.java new file mode 100644 index 0000000..1d34872 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/framework/cache/PermissionCacheStartupCleaner.java @@ -0,0 +1,45 @@ +package com.njcn.rdms.module.system.framework.cache; + +import com.njcn.rdms.module.system.dal.redis.RedisKeyConstants; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 系统启动后清理权限相关缓存,避免 SQL 直改后继续命中旧权限数据。 + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class PermissionCacheStartupCleaner implements ApplicationRunner { + + private static final List CACHE_NAMES = List.of( + RedisKeyConstants.ROLE, + RedisKeyConstants.USER_ROLE_ID_LIST, + RedisKeyConstants.MENU_ROLE_ID_LIST, + RedisKeyConstants.PERMISSION_MENU_ID_LIST + ); + + private final CacheManager cacheManager; + + @Override + public void run(ApplicationArguments args) { + CACHE_NAMES.forEach(this::clearCacheQuietly); + } + + private void clearCacheQuietly(String cacheName) { + Cache cache = cacheManager.getCache(cacheName); + if (cache == null) { + log.debug("[clearCacheQuietly][cacheName({}) 未注册,跳过清理]", cacheName); + return; + } + cache.clear(); + } + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/permission/PermissionService.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/permission/PermissionService.java index 62efb06..7f291ab 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/permission/PermissionService.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/permission/PermissionService.java @@ -3,8 +3,6 @@ package com.njcn.rdms.module.system.service.permission; import java.util.Collection; import java.util.Set; -import static java.util.Collections.singleton; - /** * 权限 Service 接口 *

@@ -61,9 +59,7 @@ public interface PermissionService { * @param roleId 角色编号 * @return 菜单编号集合 */ - default Set getRoleMenuListByRoleId(Long roleId) { - return getRoleMenuListByRoleId(singleton(roleId)); - } + Set getRoleMenuListByRoleId(Long roleId); /** * 获得角色们拥有的菜单编号集合 diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/permission/PermissionServiceImpl.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/permission/PermissionServiceImpl.java index e0f9f4c..1fc94fc 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/permission/PermissionServiceImpl.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/permission/PermissionServiceImpl.java @@ -28,7 +28,10 @@ import org.springframework.transaction.annotation.Transactional; import jakarta.annotation.Resource; import java.util.*; +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; import static com.njcn.rdms.framework.common.util.collection.CollectionUtils.convertSet; +import static com.njcn.rdms.module.system.enums.ErrorCodeConstants.ROLE_IS_DISABLE; +import static com.njcn.rdms.module.system.enums.ErrorCodeConstants.ROLE_NOT_EXISTS; /** * 权限 Service 实现类 @@ -179,8 +182,7 @@ public class PermissionServiceImpl implements PermissionService { allEntries = true) // allEntries 清空所有缓存,主要一次更新涉及到的 menuIds 较多,反倒批量会更快 }) public void assignRoleMenu(Long roleId, Set menuIds) { - roleService.validateRoleList(Collections.singleton(roleId), GLOBAL_SCOPE_TYPE, GLOBAL_OBJECT_TYPE); - RoleDO role = roleService.getRole(roleId); + RoleDO role = getEnabledRole(roleId); menuService.validateMenuList(menuIds, role.getScopeType(), role.getObjectType()); // 获得角色拥有菜单编号 Set dbMenuIds = convertSet(roleMenuMapper.selectListByRoleId(roleId), RoleMenuDO::getMenuId); @@ -224,6 +226,12 @@ public class PermissionServiceImpl implements PermissionService { roleMenuMapper.deleteListByMenuId(menuId); } + @Override + public Set getRoleMenuListByRoleId(Long roleId) { + RoleDO role = getEnabledRole(roleId); + return getRoleMenuListByRoleId(Collections.singleton(roleId), role.getScopeType(), role.getObjectType()); + } + @Override public Set getRoleMenuListByRoleId(Collection roleIds) { if (CollUtil.isEmpty(roleIds)) { @@ -338,6 +346,17 @@ public class PermissionServiceImpl implements PermissionService { return CollUtil.isEmpty(menus) ? null : menus.get(0); } + private RoleDO getEnabledRole(Long roleId) { + RoleDO role = roleService.getRole(roleId); + if (role == null) { + throw exception(ROLE_NOT_EXISTS); + } + if (!CommonStatusEnum.ENABLE.getStatus().equals(role.getStatus())) { + throw exception(ROLE_IS_DISABLE, role.getName()); + } + return role; + } + private PermissionServiceImpl getSelf() { return SpringUtil.getBean(getClass()); } diff --git a/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/framework/cache/PermissionCacheStartupCleanerTest.java b/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/framework/cache/PermissionCacheStartupCleanerTest.java new file mode 100644 index 0000000..27fc5f7 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/framework/cache/PermissionCacheStartupCleanerTest.java @@ -0,0 +1,57 @@ +package com.njcn.rdms.module.system.framework.cache; + +import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; +import com.njcn.rdms.module.system.dal.redis.RedisKeyConstants; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.boot.ApplicationArguments; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PermissionCacheStartupCleanerTest extends BaseMockitoUnitTest { + + @Mock + private CacheManager cacheManager; + @Mock + private Cache roleCache; + @Mock + private Cache userRoleCache; + @Mock + private Cache menuRoleCache; + @Mock + private Cache permissionMenuCache; + + @InjectMocks + private PermissionCacheStartupCleaner cleaner; + + @Test + void run_shouldClearPermissionRelatedCaches() throws Exception { + when(cacheManager.getCache(RedisKeyConstants.ROLE)).thenReturn(roleCache); + when(cacheManager.getCache(RedisKeyConstants.USER_ROLE_ID_LIST)).thenReturn(userRoleCache); + when(cacheManager.getCache(RedisKeyConstants.MENU_ROLE_ID_LIST)).thenReturn(menuRoleCache); + when(cacheManager.getCache(RedisKeyConstants.PERMISSION_MENU_ID_LIST)).thenReturn(permissionMenuCache); + + cleaner.run(mock(ApplicationArguments.class)); + + verify(roleCache).clear(); + verify(userRoleCache).clear(); + verify(menuRoleCache).clear(); + verify(permissionMenuCache).clear(); + } + + @Test + void run_shouldIgnoreMissingCaches() { + when(cacheManager.getCache(RedisKeyConstants.ROLE)).thenReturn(roleCache); + + assertDoesNotThrow(() -> cleaner.run(mock(ApplicationArguments.class))); + + verify(roleCache).clear(); + } + +}