From f13286aaff8f903ed88221767fe87eb5800ccb2f Mon Sep 17 00:00:00 2001 From: hongawen <83944980@qq.com> Date: Thu, 4 Jun 2026 18:46:41 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=88=A0=E9=99=A4=E5=B7=A5=E5=8D=95?= =?UTF-8?q?=E9=9C=80=E6=B1=82=E8=A7=84=E6=A0=BC=E6=96=87=E6=A1=A3=E5=B9=B6?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=BC=80=E5=8F=91=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除了工单需求规格说明文档 2026-05-22-ticket-design.md - 在安全注解 CheckObjectPermission 中新增 accessible 参数配置 - 更新 CLAUDE.md 开发规范文档,补充 MySQL 客户端使用说明 - 优化错误码常量中的错误消息格式,使用中文状态和操作名称 - 修复权限拒绝提示消息,提供更友好的用户提示 - 更新开发规范关于演示库同步补丁和文档输出格式的要求 --- .claude/settings.local.json | 5 +- CLAUDE.md | 62 ++- .../specs/2026-05-22-ticket-design.md | 355 ------------------ .../project/enums/ErrorCodeConstants.java | 44 +-- .../execution/MyExecutionController.java | 39 ++ .../MyProjectExecutionPageReqVO.java | 19 + .../execution/MyProjectExecutionRespVO.java | 41 ++ .../project/project/MyProjectController.java | 51 +++ .../vo/myproject/MyProjectOwnedRespVO.java | 51 +++ .../vo/myproject/MyProjectPageReqVO.java | 16 + .../MyProjectParticipatedRespVO.java | 32 ++ .../mysql/member/UserObjectRoleMapper.java | 14 + .../execution/ProjectExecutionMapper.java | 23 ++ .../mysql/project/task/ProjectTaskMapper.java | 87 +++++ .../status/ObjectStatusTransitionMapper.java | 12 + .../annotation/CheckObjectPermission.java | 6 + .../security/aop/ObjectPermissionAspect.java | 9 +- .../service/ObjectPermissionService.java | 8 + .../ProductObjectPermissionService.java | 46 ++- .../ProjectObjectPermissionService.java | 46 ++- .../OvertimeApplicationServiceImpl.java | 10 +- .../personal/PersonalItemServiceImpl.java | 9 +- .../product/ProductMemberServiceImpl.java | 1 + .../ProductRequirementServiceImpl.java | 12 +- .../service/product/ProductServiceImpl.java | 10 +- .../product/ProductSettingServiceImpl.java | 5 + .../service/project/MyProjectService.java | 19 + .../service/project/MyProjectServiceImpl.java | 344 +++++++++++++++++ .../project/ProjectMemberServiceImpl.java | 1 + .../ProjectRequirementServiceImpl.java | 13 +- .../service/project/ProjectServiceImpl.java | 27 +- .../ProjectExecutionAssigneeServiceImpl.java | 4 + .../execution/ProjectExecutionService.java | 9 + .../ProjectExecutionServiceImpl.java | 108 +++++- .../project/task/ProjectTaskServiceImpl.java | 21 +- .../assignee/TaskAssigneeServiceImpl.java | 4 + .../task/worklog/TaskWorklogServiceImpl.java | 2 + .../status/StatusActionTextResolver.java | 41 ++ .../AssigneeWorklogReadPermissionTest.java | 128 +++++++ .../aop/ObjectPermissionAspectTest.java | 22 ++ .../ProductObjectPermissionServiceTest.java | 115 ++++++ .../ProjectObjectPermissionServiceTest.java | 119 ++++++ .../OvertimeApplicationServiceImplTest.java | 121 ++++++ .../ProductRequirementServiceImplTest.java | 2 + .../product/ProductServiceImplTest.java | 2 + .../project/MyProjectServiceImplTest.java | 259 +++++++++++++ .../ProjectRequirementServiceImplTest.java | 2 + .../project/ProjectServiceImplTest.java | 2 + .../ProjectExecutionServiceImplTest.java | 91 +++++ .../status/StatusActionTextResolverTest.java | 53 +++ 50 files changed, 2072 insertions(+), 450 deletions(-) delete mode 100644 docs/superpowers/specs/2026-05-22-ticket-design.md create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/MyExecutionController.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/MyProjectExecutionPageReqVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/MyProjectExecutionRespVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/MyProjectController.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/myproject/MyProjectOwnedRespVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/myproject/MyProjectPageReqVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/myproject/MyProjectParticipatedRespVO.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyProjectService.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyProjectServiceImpl.java create mode 100644 rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/status/StatusActionTextResolver.java create mode 100644 rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/aop/AssigneeWorklogReadPermissionTest.java create mode 100644 rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/overtime/OvertimeApplicationServiceImplTest.java create mode 100644 rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/MyProjectServiceImplTest.java create mode 100644 rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/status/StatusActionTextResolverTest.java diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 723aba9..510babb 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -109,7 +109,10 @@ "Bash(grep -E \"\\\\.\\(sql|java|md\\)$\")", "Bash(xargs grep -l \"INSERT INTO.*system_menu\")", "Bash(Get-ChildItem *)", - "Bash(Select-Object FullName)" + "Bash(Select-Object FullName)", + "PowerShell($env:JAVA_HOME='C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -e -pl rdms-project/rdms-project-boot test \"-Dtest=ProjectExecutionServiceImplTest#changeExecutionStatus_whenCompleteTransitionMissing_shouldThrow\" 2>&1 | Select-String -Pattern \"BUILD|Tests run|FAIL|ERROR|passed\" | Select-Object -First 20)", + "PowerShell($env:JAVA_HOME='C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -q -pl rdms-project/rdms-project-boot -am compile 2>&1 | Select-Object -Last 20)", + "PowerShell($env:JAVA_HOME='C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -e -pl rdms-project/rdms-project-boot test -Dtest=ProjectServiceImplTest 2>&1 | Select-Object -Last 40)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index a751836..c04be5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ - JDK:必须使用 `JDK 17`,路径 `C:\Program Files\Java\jdk-17`。不要使用 JDK 8 / 11 / 其他版本。 - Maven:`C:\software\apache-maven-3.8.9`,命令优先用完整路径 `C:\software\apache-maven-3.8.9\bin\mvn.cmd`。不要假设 `mvn` 在 PATH。 - 执行任何 Maven / java 命令前,先确认当前 shell 的 `JAVA_HOME` 指向 JDK 17,且 `java -version` 输出 17;否则在该命令上下文中显式切换。 +- MySQL 客户端:本机已装命令行客户端 `C:\software\mysql-8.4.9-winx64\bin\mysql.exe`,Agent 可直接连库跑 SQL(查数据、核对状态、验证库结构),无需另装工具。连接账密**不写进本文件**,以 `application-local.yaml` 的 `spring.datasource` 段为准(开发库 `192.168.1.22:13306`/`rdms_view`);**演示库是另一套,账密以用户当场给的为准**。**读随便跑;写库(DDL/DML)务必先确认是哪套库**,开发库改动按本文件§数据与 SQL 出演示库补丁,绝不擅自改演示库。调用细节(PowerShell 必须逐参数加单引号否则 host 被拆、字符集、常用查询)见 [`docs/agent/MySQL客户端连库手册.md`](./docs/agent/MySQL客户端连库手册.md)。 ## 仓库结构 @@ -48,7 +49,6 @@ 不要: - 把后续业务长期堆进 `rdms-system`。 - 为新增子域引入一套平行的 `application/domain/infrastructure/adapter` 分层。 -- 让外部模块直接依赖 `*-boot` 的 service 或 mapper(必须走 `*-api`)。 ## 分层职责 @@ -64,7 +64,7 @@ ## 认证与跨模块调用 - 默认沿用 OAuth2 / Token / `LoginUser` / `login-user` 透传主链。**不要**另造 ThreadLocal / Session / 自定义 header。 -- 业务逻辑落 `*-boot`;可复用契约落 `*-api`;可复用框架能力落 `rdms-framework`。跨模块/跨服务必须通过 `*-api` 定义契约,**不要直接依赖别人的 `*-boot`**;改跨模块 API 时,`*-boot` 实现与对应 `*-api` 契约同步更新。 +- 业务逻辑落 `*-boot`;可复用契约落 `*-api`;可复用框架能力落 `rdms-framework`。跨模块/跨服务必须通过 `*-api` 定义契约,**不要直接依赖别人 `*-boot` 的 service 或 mapper(必须走 `*-api`)**;改跨模块 API 时,`*-boot` 实现与对应 `*-api` 契约同步更新。 ### 鉴权:必须按"全域 / 对象域"分通道挂 @@ -80,7 +80,7 @@ - **对象内接口绝不能挂 `@PreAuthorize("@ss.hasPermission(...)")`**。该注解走的链路在 `PermissionServiceImpl` 里强制按 GLOBAL 取角色(line 343-347)+ 强制按 GLOBAL 查菜单(line 92-94),对象域角色与对象域菜单都进不来,即使授权配置完全正确也必然 403。 - **对象域权限校验必须落在 Service 层 `@CheckObjectPermission`**,原因:路径里 `objectId` 通常以 `#projectId`/`#productId` 等 SpEL 解析,Controller 的参数校验前置阶段不便复用;与同模块(`ProjectMemberServiceImpl` / `ProjectExecutionServiceImpl` / `ProjectExecutionAssigneeServiceImpl` / `ProjectTaskServiceImpl`)保持一致。 - **同一接口不要两条通道叠加**。要么全域,要么对象域;叠加只会让对象域用户被全域那条卡死。 -- 对象内**读路径**(列表 / 详情 / 状态看板 / 聚合)已统一在 **Service 层**挂 `@CheckObjectPermission(objectType=PROJECT, permission=...PERMISSION_QUERY)`——查询同样要扫库耗资源,必须按对象域鉴权(原台账 TD-001 所述"读路径未挂"已不成立)。**Controller 方法层一律不挂权限注解**,对象域鉴权全部落 Service;新增读接口照此在 Service 层挂对象域权限,不要只在 Controller 留空、更不要误判"Controller 没注解 = 无鉴权"。 +- 对象内**读路径**(列表 / 详情 / 状态看板 / 聚合)同样要挂 `@CheckObjectPermission(objectType=PROJECT, permission=...PERMISSION_QUERY)`——查询同样扫库耗资源,必须按对象域鉴权。**Controller 方法层一律不挂权限注解**,新增读接口照此在 Service 层挂对象域权限;不要只在 Controller 留空,更不要误判"Controller 没注解 = 无鉴权"。 判定口诀:**URL 里有 `{projectId}` / `{productId}` 等对象 ID → 对象域;没有 → 全域**。 @@ -100,8 +100,32 @@ - 新增 update 接口时,必须在 API 文档对应章节明示"PUT 全字段回传"约定;DO 上对允许 null 的字段补 `FieldStrategy.ALWAYS` 注解,并加注释说明语义来源(指向本节)。 - 历史接口若是稀疏 PATCH 风格(传 null = 不动),保留现状但不要拓展;遇到清空诉求时按 PUT 方向重构。 +## 用户可见错误文案(状态机动作 / 状态校验) + +凡"状态机动作 / 状态校验失败"的业务异常,若 `message` 会被前端直接 toast 给用户:**不要把动作 / 状态的内部 code 塞进 message**(如"不支持动作【complete】")。给用户看中文展示名,技术诊断(原始 code / 入参 / 堆栈)由 `infra_api_access_log` 承载。 + +红线: + +- service 注入 `StatusActionTextResolver`(`rdms-project-boot · service/status`),抛错前用 `actionName / statusName` 把 code 翻成中文名再填错误码占位;中文名权威源是 `rdms_object_status_transition.action_name` / `rdms_object_status_model.status_name`,**不在代码里硬编码 code→中文 映射**。 +- 错误码文案用「{}」占位中文名(正面样板 `PERSONAL_ITEM_STATUS_ACTION_NOT_ALLOWED`),不要把 code 写死进文案。 +- 新对象类型只要在状态机两张表配好 `status_name` / `action_name`,resolver 自动生效,无需改代码。 + +完整规范、落地清单与已知缺口(加班申请 `OvertimeApplicationServiceImpl` 待补)见 [`docs/architecture/用户可见错误文案规范.html`](./docs/architecture/用户可见错误文案规范.html)。来源 TD-012。 + ## 数据与 SQL +### 🔴 演示库同步补丁(上线前最高优先级,每次开发都要做) + +项目已临近上线测试,**开发库与演示库两套数据库并存**:日常开发只改了开发库,演示库要靠 SQL 补丁手工同步。一旦补丁缺失或漏项,演示库就会因数据结构 / 字典 / 权限缺失导致功能异常。因此—— + +- **每次开发引起的任何数据库变动,都必须同步产出一个可直接运行的 SQL 补丁文件**,交给用户拿到演示库执行。覆盖范围:表结构(DDL)、数据字典(`system_dict_type` / `system_dict_data`)、菜单 / 角色 / 权限(`system_menu` 及权限相关表)、新功能初始化数据、索引等——**只要开发库动了,补丁就必须有**。 +- **落地位置与命名**:`docs/sql/patches/`,命名 `YYYY-MM-DD-功能名-NN.sql`(每次变更一个独立文件;当天同一功能多次变更递增 `NN` 序号)。沿用 `docs/sql/**` 不主动提交 git 的惯例。 +- **可直接执行**:纯 SQL、自包含,不依赖 IDE / 迁移工具;用户复制即可在演示库一次跑通。 +- **幂等可重复**:演示库可能已有部分数据,补丁必须可重复执行不报错(`NOT EXISTS` / `IF NOT EXISTS` 守卫;雪花 id 表按下文「种子 SQL」规则显式取 id)。 +- **只含增量**:仅本次变动,不 dump 全库、不夹带无关数据。 +- 补丁写法严格遵守下文「种子 SQL」小节(雪花 id 取值、collation 1267 陷阱)。 +- **收尾必做**:完成任何涉及 DB 的开发后,主动告诉用户「本次演示库补丁:`docs/sql/patches/xxx.sql`」并简述其内容,不要等用户来问。 + - 新表 DO 复用现有 `BaseDO` / 审计字段风格,不要再引一套审计基类(除非该表本身明确不需要逻辑删除)。 - **不要假设运行时自动数据库迁移**:依赖新表/新字段/新索引时,必须同步补 SQL 脚本与文档。 - SQL 放在目标模块 `src/main/resources/sql/...`,可审阅、可单独执行。 @@ -109,13 +133,13 @@ ### 种子 SQL(纯 SQL INSERT 雪花 ID 表) -`system_dict_type` / `system_dict_data` / `system_menu` 等历史表 id 由 MyBatis-Plus 雪花算法在 Java 层生成,DDL 无 `AUTO_INCREMENT`。纯 SQL 路径(字典种子、菜单种子等)写 INSERT 必须显式提供 id,否则 MySQL 报 `1364 - Field 'id' doesn't have a default value`。 +`system_dict_type` / `system_dict_data` / `system_menu` 等历史表 id 由 MyBatis-Plus 雪花算法在 Java 层生成,DDL 无 `AUTO_INCREMENT`。纯 SQL 路径(字典种子、菜单种子、演示库补丁)写 INSERT 必须遵守三条红线,否则报错或撞库: -- **id 取值**:`SET @new_id = (SELECT IFNULL(MAX(id), 0) + 1 FROM xxx_table);` 然后 INSERT `SELECT @new_id, ...`。雪花 ID 单调递增,`MAX+1` 落在已用区间之后,不会与未来 Java 生成的新雪花 ID 冲突。 -- **多条连续 INSERT**:每条 INSERT **前重新取** `MAX+1`——不要用 `base+1 / +2 / +3` 一次性算多个。配合 `NOT EXISTS` 守卫,部分已存在场景(半路重跑)才不会出现两条共用一个 id。 -- **collation 1267 陷阱**:仓库历史表 collation 不统一(如 `system_dict_data` 是 `utf8mb4_unicode_ci`,新表 `rdms_task_worklog` 是 `utf8mb4_0900_ai_ci`)。**不要**用 `SET @t = 'xxx'` 存字符串再 `WHERE col = @t`——用户变量自带连接级 collation,与列 collation 撞会报 `1267 Illegal mix of collations (utf8mb4_unicode_ci,IMPLICIT) and (utf8mb4_0900_ai_ci,IMPLICIT) for operation '='`。**对策**:直接展开成字面值,MySQL 字面值会按列 collation 隐式解析,不冲突。 +- **必须显式给 id**:用 `MAX+1` 取(`SET @new_id = (SELECT IFNULL(MAX(id),0)+1 FROM xxx);`),多条连续 INSERT **每条前重新取**,否则 `1364 - Field 'id' doesn't have a default value`。 +- **必须幂等**:每条 INSERT 加 `NOT EXISTS` 守卫,可重复执行不重插。 +- **防 collation 1267**:**不要**用用户变量存字符串再比较(`SET @t='xxx'` 后 `WHERE col=@t`),仓库历史表 collation 不统一会撞 `1267 Illegal mix of collations`;直接展开成字面值。 -样板参考:`docs/sql/rdms_task_worklog.sql:47-50`(菜单种子)+ `docs/sql/rdms_worklog_difficulty_seed.sql`(字典种子)。 +详细写法、示例与样板(`docs/sql/rdms_project_query_permission.sql`)见 [`docs/agent/种子SQL写法规范.md`](./docs/agent/种子SQL写法规范.md)。 ## 注释与编码 @@ -125,9 +149,29 @@ ## 文档输出格式 - 新写文档默认输出 **HTML 格式**(便于浏览器直接打开、自带样式)。 -- 例外:`docs/superpowers/` 下保持 markdown(工作流约定)。 +- 例外:`docs/superpowers/` 与 `docs/agent/` 下保持 **markdown**——前者是工作流约定,后者是专给 Agent 看的操作手册(Agent 读 md 更直接,不需要浏览器样式)。 - 历史已有的 markdown 文档不强制迁移;只有新写的按 HTML。 +### 产出落点(每次生成前对照,避免散乱) + +**红线:绝不把生成物放在仓库根或 `docs/` 根目录**——任何文档 / SQL / 草稿必须落到 `docs/` 下对应子目录(完整目录见 [`docs/README.md`](./docs/README.md) 场景表)。历史教训:工作台 API 文档被丢到仓库根 + docs 根导致散乱。 + +| 产出类型 | 落点 | +|---|---| +| 业务功能文档(设计 / 前端 API / 实现说明 / 联调) | `docs/domains/<域>/`(域=system/product/project),**平铺**、命名 `YYYY-MM-DD-功能-类型.html`,**不按功能建子目录** | +| superpowers 工作流产物(spec / plan) | `docs/superpowers/specs/` `plans/`(保持 markdown) | +| 跨模块约束 / 架构规范 | `docs/architecture/` | +| starter / 框架 API 说明 | `docs/modules/` | +| 专给 Agent 看的操作手册(从 CLAUDE.md 抽出的长篇 how-to / 命令 / 陷阱) | `docs/agent/`(CLAUDE.md 留红线 + 指针) | +| 调研 / 对标 / 可行性 | `docs/research/` | +| 负债台账 / 排查经验 | `docs/debt/` | +| 演示库同步补丁 | `docs/sql/patches/`(见本文件§数据与 SQL) | +| SQL 草稿 | `docs/sql/`(最终迁回模块 `src/main/resources/sql/`) | +| **过程草稿 / 待确认清单 / "看一眼"稿** | **`docs/temp/`,定稿后及时删** | + +- 正文格式按上文「文档输出格式」(默认 HTML,`superpowers/` 与 `docs/agent/` 保持 md,历史 md 不强迁)。 +- 落点对应的子目录有 `README.md` 时,新增文件**同步登记**进该 README 的文件表。 + ## 工作规则(执行前对照) 1. 优先做有边界的模块内改动,避免跨模块扩散。 diff --git a/docs/superpowers/specs/2026-05-22-ticket-design.md b/docs/superpowers/specs/2026-05-22-ticket-design.md deleted file mode 100644 index 5d19f42..0000000 --- a/docs/superpowers/specs/2026-05-22-ticket-design.md +++ /dev/null @@ -1,355 +0,0 @@ -# 工单需求规格说明 - -日期:2026-05-22 - -## 1. 背景 - -`rdms-project` 当前承载项目、产品、需求、执行、任务等核心交付对象。现有代码中产品需求、项目需求已经具备 `sourceType` / `sourceBizId` 来源字段,可以承接来自工单的需求派生关系;执行和任务也已经形成“项目需求 -> 执行 -> 任务”的后续交付链路。 - -本需求新增内部工单能力。工单作为独立业务对象存在,不复用需求、执行或任务主表。工单用于记录内部用户提交的诉求,经工单负责人受理后,可按归属类型派生产品需求或项目需求,并通过现有需求链路继续流转到执行、任务。 - -## 2. 目标 - -1. 支持内部用户创建普通工单或父工单。 -2. 支持父工单逐步拆分子工单,父工单只汇总,不直接处理。 -3. 支持普通工单、子工单作为最小处理单位,由指定工单负责人受理、拒绝、处理和关闭。 -4. 支持工单单归属到一个产品或一个项目。 -5. 支持产品工单派生产品需求、项目工单派生项目需求。 -6. 支持有派生需求的工单在全部需求完成后自动关闭。 -7. 支持无派生需求的工单由工单负责人手动关闭。 - -## 3. 非目标 - -1. 本期不做外部客户工单,不保留 `sourceChannel`、`externalCustomerName`、`externalContact` 等外部来源字段。 -2. 本期不做工单编号 `ticketNo`。 -3. 工单不能直接派生执行或任务。 -4. 父工单不能受理、拒绝、派生需求或手动关闭。 -5. 本期不引入流程引擎,不做可配置审批流。 -6. 本期不自动判断工单是否涉及多个产品/项目,也不自动判断归属产品或项目;这些由录入人员人工选择。 - -## 4. 核心概念 - -### 4.1 工单形态 - -使用 `ticketMode` 表达工单形态: - -| 值 | 含义 | 是否可处理 | 说明 | -|---|---|---|---| -| `single` | 普通工单 | 是 | 单归属工单,不挂父工单 | -| `parent` | 父工单 | 否 | 原始诉求汇总单,可持续拆分子工单 | -| `child` | 子工单 | 是 | 挂在父工单下的最小处理单位 | - -不使用 `isParent`。`ticketMode` 比布尔字段更准确,可以区分普通工单、父工单和子工单,避免在父工单尚未创建子工单时无法识别其形态。 - -### 4.2 归属类型 - -普通工单和子工单必须单归属: - -| `belongType` | 归属对象 | 可派生对象 | 后续链路 | -|---|---|---|---| -| `product` | 一个产品 | 产品需求 | 产品需求 -> 指派项目 -> 项目需求 -> 执行 -> 任务 | -| `project` | 一个项目 | 项目需求 | 项目需求 -> 执行 -> 任务 | - -父工单不填写 `belongType`、`productId`、`projectId`。 - -## 5. 业务流程 - -### 5.1 总流程图 - -```mermaid -flowchart TD - A([开始]) --> B[录入人员创建工单] - B --> C{ticketMode} - - C -->|parent| P1[父工单: splitting] - P1 --> P2[列表操作列拆分子工单] - P2 --> C1[创建子工单: pending_accept] - P1 --> P3{是否存在子工单且全部为终态} - P3 -->|否| P1 - P3 -->|是| P4[父工单: closed] - P4 --> Z([结束]) - - C -->|single| S1[普通工单: pending_accept] - C1 --> S2[工单负责人待处理] - S1 --> S2 - S2 --> D{是否受理} - D -->|否| R[工单: rejected] - R --> Z - D -->|是| E[工单: processing] - - E --> F{是否派生需求} - F -->|否| G[负责人填写处理结论并手动关闭] - G --> H[工单: closed] - H --> I[触发父工单汇总检查] - I --> Z - - F -->|是| J{归属类型} - J -->|product| K[派生一个或多个产品需求] - J -->|project| L[派生一个或多个项目需求] - K --> M[等待派生需求全部完成] - L --> M - M --> N{全部派生需求完成} - N -->|否| E - N -->|是| O[系统自动关闭工单: closed] - O --> I -``` - -### 5.2 父工单流程 - -1. 录入人员创建父工单,状态为 `splitting`。 -2. 父工单只记录原始诉求和附件,不进入处理队列。 -3. 工单列表查询的操作列为父工单提供“拆分子工单”入口。 -4. 录入人员可以持续新增子工单。 -5. 父工单至少存在一个子工单,且所有子工单均进入终态后,系统自动关闭父工单。 -6. 父工单关闭后不再作为处理对象,但可继续作为历史汇总查看。 - -### 5.3 普通工单 / 子工单流程 - -1. 创建后进入 `pending_accept`。 -2. 工单负责人判断是否受理。 -3. 不受理则进入 `rejected`,需要填写拒绝原因。 -4. 受理后进入 `processing`。 -5. 处理中可以派生需求,也可以在无派生需求时填写处理结论并手动关闭。 -6. 一旦存在派生需求,工单不能手动关闭,必须等待全部派生需求完成后自动关闭。 - -## 6. 状态模型 - -### 6.1 父工单状态 - -| 状态 | 含义 | 进入方式 | 退出方式 | -|---|---|---|---| -| `splitting` | 拆分中 / 汇总中 | 创建父工单 | 所有子工单终态后自动关闭 | -| `closed` | 已关闭 | 系统自动关闭 | 终态 | - -父工单不允许进入 `pending_accept`、`rejected`、`processing`。 - -### 6.2 普通工单 / 子工单状态 - -| 状态 | 含义 | 进入方式 | 退出方式 | -|---|---|---|---| -| `pending_accept` | 待受理 | 创建普通工单或子工单 | 受理或拒绝 | -| `rejected` | 已拒绝 | 工单负责人拒绝 | 终态 | -| `processing` | 处理中 | 工单负责人受理 | 手动关闭或自动关闭 | -| `closed` | 已关闭 | 手动关闭或派生需求全部完成后自动关闭 | 终态 | - -终态包括 `rejected`、`closed`。 - -### 6.3 自动关闭规则 - -1. 普通工单 / 子工单存在派生需求时,只有全部派生需求完成后才自动关闭。 -2. 普通工单 / 子工单不存在派生需求时,允许工单负责人手动关闭,必须填写 `closeResult`。 -3. 父工单至少存在一个子工单,且所有子工单均为 `rejected` 或 `closed` 后,自动关闭。 -4. 父工单不直接检查需求完成情况,只汇总子工单终态。 - -## 7. 数据模型 - -### 7.1 工单主表 - -建议表名:`rdms_ticket` - -| 字段 | 类型建议 | 必填规则 | 说明 | -|---|---|---|---| -| `id` | `bigint` | 是 | 主键 | -| `parent_id` | `bigint` | 否 | 父工单 ID;`child` 必填 | -| `ticket_mode` | `varchar(32)` | 是 | `single` / `parent` / `child` | -| `title` | `varchar(255)` | 是 | 工单标题 | -| `description` | `text` | 否 | 工单描述,支持富文本 | -| `ticket_type` | `varchar(32)` | 是 | 工单类型,字典 | -| `priority` | `varchar(32)` | 否 | 优先级,建议复用需求优先级字典 | -| `status_code` | `varchar(32)` | 是 | 工单状态 | -| `submitter_id` | `bigint` | 是 | 提交人用户 ID | -| `submitter_nickname` | `varchar(64)` | 否 | 提交人昵称快照 | -| `owner_id` | `bigint` | `single` / `child` 必填 | 工单负责人用户 ID | -| `owner_nickname` | `varchar(64)` | 否 | 工单负责人昵称快照 | -| `belong_type` | `varchar(32)` | `single` / `child` 必填 | `product` / `project` | -| `product_id` | `bigint` | 产品工单必填 | 归属产品 ID | -| `project_id` | `bigint` | 项目工单必填 | 归属项目 ID | -| `accept_time` | `datetime` | 否 | 受理时间 | -| `reject_reason` | `varchar(500)` | 拒绝时必填 | 拒绝原因 | -| `close_time` | `datetime` | 否 | 关闭时间 | -| `close_result` | `varchar(1000)` | 手动关闭时必填 | 处理结论 | -| `attachments` | `json` | 否 | 附件列表,沿用 `AttachmentItem` | - -审计字段、逻辑删除字段复用现有 `BaseDO` 风格。 - -### 7.2 工单需求关联表 - -建议表名:`rdms_ticket_requirement_link` - -| 字段 | 类型建议 | 必填规则 | 说明 | -|---|---|---|---| -| `id` | `bigint` | 是 | 主键 | -| `ticket_id` | `bigint` | 是 | 工单 ID | -| `target_type` | `varchar(32)` | 是 | `product_requirement` / `project_requirement` | -| `target_id` | `bigint` | 是 | 需求 ID | - -不设置 `relationType`。本期关联表只表达“该工单派生了哪些需求”。 - -建议唯一约束:`ticket_id + target_type + target_id`。 - -## 8. 需求派生规则 - -### 8.1 产品工单派生产品需求 - -适用条件: - -1. `ticketMode` 为 `single` 或 `child`。 -2. `belongType = product`。 -3. 工单状态为 `processing`。 - -派生结果: - -1. 创建 `ProductRequirementDO`。 -2. `productId` 使用工单 `productId`。 -3. `sourceType = "work_order"`。 -4. `sourceBizId = ticketId`。 -5. 标题、描述、优先级、附件、提出人等字段可从工单带入,并允许派生表单二次编辑。 -6. 写入 `rdms_ticket_requirement_link`,`targetType = product_requirement`。 - -后续链路沿用现有产品需求分发到项目的能力。 - -### 8.2 项目工单派生项目需求 - -适用条件: - -1. `ticketMode` 为 `single` 或 `child`。 -2. `belongType = project`。 -3. 工单状态为 `processing`。 - -派生结果: - -1. 创建 `ProjectRequirementDO`。 -2. `projectId` 使用工单 `projectId`。 -3. `sourceType = "work_order"`。 -4. `sourceBizId = ticketId`。 -5. 标题、描述、优先级、附件、提出人等字段可从工单带入,并允许派生表单二次编辑。 -6. 写入 `rdms_ticket_requirement_link`,`targetType = project_requirement`。 - -后续执行和任务沿用现有项目需求、执行、任务链路。 - -### 8.3 完成判定 - -1. 产品工单只检查由该工单派生的产品需求。 -2. 项目工单只检查由该工单派生的项目需求。 -3. 需求完成态应复用现有需求状态模型的终态配置,不在工单逻辑中写死具体状态码。 -4. 派生需求数量大于 0 且全部完成时,系统自动关闭对应工单。 - -## 9. 页面与待办 - -### 9.1 我的提交 - -展示当前用户提交的工单,包括: - -1. 父工单。 -2. 普通工单。 -3. 子工单。 - -建议支持按状态、工单类型、归属类型、归属对象、创建时间筛选。 - -### 9.2 我的待处理 - -展示当前用户负责的普通工单和子工单,不展示父工单。 - -筛选条件: - -1. `ownerId = 当前用户`。 -2. `ticketMode in (single, child)`。 -3. `statusCode in (pending_accept, processing)`。 - -### 9.3 父工单列表操作 - -工单列表查询中,父工单行需要在操作列展示“拆分子工单”入口。该入口只对父工单提交人或具备工单管理权限的用户可见。 - -点击后进入新增子工单表单,表单需要携带父工单上下文,并要求录入人员填写子工单的归属类型、归属产品/项目、工单负责人、工单类型等处理字段。 - -### 9.4 父工单汇总展示 - -父工单汇总展示需要包含: - -1. 原始诉求信息。 -2. 子工单列表。 -3. 子工单归属产品/项目。 -4. 子工单负责人。 -5. 子工单状态。 -6. 子工单派生需求数量与完成数量。 -7. 父工单自动关闭结果。 - -## 10. 权限规则 - -1. 创建工单走全域权限。 -2. 父工单新增子工单:父工单提交人或具备工单管理权限的用户可操作。 -3. 普通工单 / 子工单受理、拒绝、手动关闭:工单负责人或具备工单管理权限的用户可操作。 -4. 派生产品需求时,需要满足产品对象权限。 -5. 派生项目需求时,需要满足项目对象权限。 -6. 父工单不能执行受理、拒绝、派生需求、手动关闭动作。 - -## 11. 接口建议 - -接口路径建议落在 `rdms-project` 模块: - -| 方法 | 路径 | 说明 | -|---|---|---| -| `POST` | `/project/tickets` | 创建普通工单或父工单 | -| `POST` | `/project/tickets/{parentId}/children` | 父工单新增子工单 | -| `GET` | `/project/tickets/my-submitted/page` | 我的提交 | -| `GET` | `/project/tickets/my-pending/page` | 我的待处理 | -| `GET` | `/project/tickets/{id}` | 工单详情 | -| `POST` | `/project/tickets/{id}/accept` | 受理 | -| `POST` | `/project/tickets/{id}/reject` | 拒绝 | -| `POST` | `/project/tickets/{id}/close` | 无派生需求时手动关闭 | -| `POST` | `/project/tickets/{id}/derive-product-requirement` | 派生产品需求 | -| `POST` | `/project/tickets/{id}/derive-project-requirement` | 派生项目需求 | - -创建、更新类接口需要继续遵守仓库 HTTP 动词语义约定。部分动作使用语义化 `POST` 子动作接口,不引入 `PATCH`。 - -## 12. 校验规则 - -1. `ticketMode = parent` 时,`ownerId`、`belongType`、`productId`、`projectId` 必须为空。 -2. `ticketMode = single` 时,`ownerId`、`belongType` 必填,且 `productId` / `projectId` 按归属类型二选一。 -3. `ticketMode = child` 时,`parentId`、`ownerId`、`belongType` 必填,且父工单必须存在且 `ticketMode = parent`。 -4. 产品工单必须填写 `productId`,不能填写 `projectId`。 -5. 项目工单必须填写 `projectId`,不能填写 `productId`。 -6. 只有 `pending_accept` 状态的普通工单 / 子工单可以受理或拒绝。 -7. 只有 `processing` 状态的普通工单 / 子工单可以派生需求。 -8. 有派生需求的工单不能手动关闭。 -9. 无派生需求的 `processing` 工单可以手动关闭,必须填写 `closeResult`。 -10. 父工单不能手动关闭。 -11. 父工单至少有一个子工单,且全部子工单终态后才能自动关闭。 - -## 13. 错误处理 - -建议新增明确错误码覆盖以下场景: - -1. 工单不存在。 -2. 工单形态非法。 -3. 父工单不能处理。 -4. 子工单父级非法。 -5. 工单归属类型非法。 -6. 产品工单不能派生项目需求。 -7. 项目工单不能派生产品需求。 -8. 工单状态不允许当前动作。 -9. 有派生需求的工单不能手动关闭。 -10. 派生需求未全部完成,工单不能自动关闭。 - -## 14. 测试重点 - -1. 创建父工单后状态为 `splitting`,且不能受理、拒绝、派生需求、手动关闭。 -2. 父工单可以持续新增多个子工单。 -3. 子工单必须单归属产品或项目。 -4. 普通工单 / 子工单创建后进入 `pending_accept`。 -5. 工单负责人拒绝后进入 `rejected`。 -6. 工单负责人受理后进入 `processing`。 -7. 无派生需求的 `processing` 工单可手动关闭。 -8. 有派生需求的工单不能手动关闭。 -9. 产品工单只能派生产品需求。 -10. 项目工单只能派生项目需求。 -11. 派生需求全部完成后自动关闭工单。 -12. 所有子工单终态后自动关闭父工单。 - -## 15. 风险与约束 - -1. 自动关闭依赖需求完成态判定,实施时必须和现有需求状态模型对齐。 -2. 产品需求分发到项目后的项目需求、执行、任务链路不属于工单直接职责,工单只追踪自己直接派生的需求。 -3. 父工单允许持续补子工单,会带来“父工单已关闭后是否允许继续补子单”的边界。本规格默认父工单关闭后不再补子单;如需重开父工单,需要单独设计重开动作。 -4. 现有前端已有 `/ticket/my-submitted`、`/ticket/my-pending` 资源入口,后端接口落地时需要与前端路由和菜单权限同步。 - 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 90e9396..7da2be3 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 @@ -14,8 +14,8 @@ public interface ErrorCodeConstants { ErrorCode PRODUCT_CODE_DUPLICATE = new ErrorCode(1_008_001_001, "已经存在编码为【{}】的产品"); ErrorCode PRODUCT_NAME_DUPLICATE = new ErrorCode(1_008_001_002, "已经存在名称为【{}】的产品"); ErrorCode PRODUCT_CODE_NOT_MODIFIABLE = new ErrorCode(1_008_001_003, "产品编码创建后不允许修改"); - ErrorCode PRODUCT_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_001_004, "当前产品状态不支持动作【{}】"); - ErrorCode PRODUCT_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_001_005, "动作【{}】必须填写原因"); + ErrorCode PRODUCT_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_001_004, "当前产品为「{}」状态,不支持「{}」操作"); + 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, "产品暂停后仅允许修正描述,产品经理请通过产品团队维护"); @@ -29,7 +29,7 @@ public interface ErrorCodeConstants { 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_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, "产品状态定义不存在或已停用"); @@ -38,7 +38,7 @@ public interface ErrorCodeConstants { ErrorCode PRODUCT_INITIAL_TEAM_MEMBER_DUPLICATE = new ErrorCode(1_008_001_025, "初始团队成员存在重复"); ErrorCode PRODUCT_INITIAL_TEAM_ROLE_INVALID = new ErrorCode(1_008_001_026, "初始团队中存在非法角色"); ErrorCode PRODUCT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE = new ErrorCode(1_008_001_027, "原产品经理在该产品已持有目标角色【{}】(含历史失效行),不能直接转交,请先清理后重试"); - ErrorCode PRODUCT_INTERNAL_ROLE_NOT_CONFIGURED = new ErrorCode(1_008_001_028, "内置产品角色【{}】未在 system_role 找到,请联系管理员"); + ErrorCode PRODUCT_INTERNAL_ROLE_NOT_CONFIGURED = new ErrorCode(1_008_001_028, "内置产品角色【{}】未配置,请联系管理员"); ErrorCode PRODUCT_MEMBER_USER_INVALID = new ErrorCode(1_008_001_029, "产品成员不是有效系统用户"); // 批量新增(POST /project/product/{id}/members/batch)专用:同一请求内 userId 重复 / 经理拦截 ErrorCode PRODUCT_MEMBER_BATCH_USER_DUPLICATE = new ErrorCode(1_008_001_030, "请勿在批量列表中重复添加同一成员"); @@ -48,8 +48,8 @@ public interface ErrorCodeConstants { // ========== 产品需求 1-008-002-000 ========== ErrorCode REQUIREMENT_NOT_EXISTS = new ErrorCode(1_008_002_000, "产品需求不存在"); - ErrorCode REQUIREMENT_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_002_001, "当前需求状态不支持动作【{}】"); - ErrorCode REQUIREMENT_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_002_002, "动作【{}】必须填写原因"); + ErrorCode REQUIREMENT_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_002_001, "当前需求为「{}」状态,不支持「{}」操作"); + ErrorCode REQUIREMENT_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_002_002, "「{}」操作必须填写原因"); ErrorCode REQUIREMENT_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_002_003, "需求状态已发生变化,请刷新后重试"); ErrorCode REQUIREMENT_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_002_004, "当前需求状态不允许编辑"); ErrorCode REQUIREMENT_STATUS_NOT_ALLOW_CLOSE = new ErrorCode(1_008_002_005, "只有已验收的需求才能关闭"); @@ -85,8 +85,8 @@ public interface ErrorCodeConstants { ErrorCode PROJECT_TYPE_INVALID = new ErrorCode(1_008_002_006, "项目类型不是有效字典值"); ErrorCode PROJECT_MANAGER_USER_INVALID = new ErrorCode(1_008_002_007, "项目负责人不是有效系统用户"); ErrorCode PROJECT_MEMBER_USER_INVALID = new ErrorCode(1_008_002_008, "项目成员不是有效系统用户"); - ErrorCode PROJECT_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_002_009, "当前项目状态不支持动作【{}】"); - ErrorCode PROJECT_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_002_010, "动作【{}】必须填写原因"); + ErrorCode PROJECT_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_002_009, "当前项目为「{}」状态,不支持「{}」操作"); + ErrorCode PROJECT_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_002_010, "「{}」操作必须填写原因"); ErrorCode PROJECT_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_002_011, "项目状态已发生变化,请刷新后重试"); ErrorCode PROJECT_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_002_012, "当前项目状态不允许编辑"); ErrorCode PROJECT_MEMBER_NOT_EXISTS = new ErrorCode(1_008_002_013, "项目成员不存在"); @@ -97,7 +97,7 @@ public interface ErrorCodeConstants { ErrorCode PROJECT_DELETE_CONFIRM_TEXT_INVALID = new ErrorCode(1_008_002_018, "删除确认口令不正确"); ErrorCode PROJECT_DELETE_NAME_MISMATCH = new ErrorCode(1_008_002_019, "删除确认名称与当前项目名称不一致"); ErrorCode PROJECT_NOT_ALLOW_DELETE = new ErrorCode(1_008_002_020, "当前项目不允许删除"); - ErrorCode PROJECT_OBJECT_PERMISSION_DENIED = new ErrorCode(1_008_002_021, "当前用户不具备该项目的操作权限【{}】"); + ErrorCode PROJECT_OBJECT_PERMISSION_DENIED = new ErrorCode(1_008_002_021, "您没有该项目的此项操作权限,请联系管理员"); ErrorCode PROJECT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED = new ErrorCode(1_008_002_022, "项目状态定义不存在或已停用"); ErrorCode PROJECT_DIRECTION_INVALID = new ErrorCode(1_008_002_023, "项目方向不是有效字典值"); ErrorCode PROJECT_MANAGER_TRANSFER_INFO_REQUIRED = new ErrorCode(1_008_002_024, "切换项目经理时必须同时传入原项目经理用户和交接后角色"); @@ -111,7 +111,7 @@ public interface ErrorCodeConstants { ErrorCode PROJECT_INITIAL_TEAM_ROLE_INVALID = new ErrorCode(1_008_002_031, "初始团队中存在非法角色"); ErrorCode PROJECT_DIRECTION_NOT_MATCH_PRODUCT = new ErrorCode(1_008_002_032, "项目方向与所属产品方向不一致"); ErrorCode PROJECT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE = new ErrorCode(1_008_002_033, "原项目经理在该项目已持有目标角色【{}】(含历史失效行),不能直接转交,请先清理后重试"); - ErrorCode PROJECT_INTERNAL_ROLE_NOT_CONFIGURED = new ErrorCode(1_008_002_034, "内置项目角色【{}】未在 system_role 找到,请联系管理员"); + ErrorCode PROJECT_INTERNAL_ROLE_NOT_CONFIGURED = new ErrorCode(1_008_002_034, "内置项目角色【{}】未配置,请联系管理员"); // 批量新增(POST /project/project/{id}/members/batch)专用:同一请求内 userId 重复 / 经理拦截 ErrorCode PROJECT_MEMBER_BATCH_USER_DUPLICATE = new ErrorCode(1_008_002_035, "请勿在批量列表中重复添加同一成员"); ErrorCode PROJECT_MEMBER_BATCH_MANAGER_NOT_ALLOWED = new ErrorCode(1_008_002_036, "批量新增不允许指定为经理,请通过编辑成员调整"); @@ -131,13 +131,13 @@ public interface ErrorCodeConstants { ErrorCode PROJECT_EXECUTION_NOT_ALLOW_EDIT = new ErrorCode(1_008_003_008, "当前项目状态不允许维护执行"); ErrorCode PROJECT_EXECUTION_OWNER_HANDOFF_REQUIRED = new ErrorCode(1_008_003_009, "该项目成员仍担任未终态执行负责人,请先完成执行负责人交接"); ErrorCode PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED = new ErrorCode(1_008_003_010, "执行状态定义不存在或已停用"); - ErrorCode PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_003_011, "当前执行状态不支持动作【{}】"); - ErrorCode PROJECT_EXECUTION_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_003_012, "动作【{}】必须填写原因"); + ErrorCode PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_003_011, "当前执行为「{}」状态,不支持「{}」操作"); + ErrorCode PROJECT_EXECUTION_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_003_012, "「{}」操作必须填写原因"); ErrorCode PROJECT_EXECUTION_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_003_013, "执行状态已发生变化,请刷新后重试"); ErrorCode PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_003_014, "当前执行状态不允许维护执行"); ErrorCode PROJECT_EXECUTION_TYPE_INVALID = new ErrorCode(1_008_003_015, "执行类型不是有效字典值"); ErrorCode PROJECT_EXECUTION_ASSIGNEE_REQUIRED = new ErrorCode(1_008_003_016, "创建执行时必须至少选择一名执行协办人"); - ErrorCode PROJECT_EXECUTION_STATUS_OWNER_ONLY = new ErrorCode(1_008_003_017, "只有执行负责人才能执行【{}】动作"); + ErrorCode PROJECT_EXECUTION_STATUS_OWNER_ONLY = new ErrorCode(1_008_003_017, "只有执行负责人才能执行「{}」操作"); ErrorCode PROJECT_EXECUTION_COMPLETE_TASKS_REQUIRED = new ErrorCode(1_008_003_018, "完成执行前,执行下所有任务必须全部完成或取消"); ErrorCode PROJECT_EXECUTION_NOT_ALLOW_DELETE = new ErrorCode(1_008_003_019, "已完成的执行不允许删除"); ErrorCode PROJECT_EXECUTION_DELETE_NAME_MISMATCH = new ErrorCode(1_008_003_020, "确认执行名称与实际不一致"); @@ -153,12 +153,12 @@ public interface ErrorCodeConstants { ErrorCode PROJECT_TASK_PARENT_INVALID = new ErrorCode(1_008_004_002, "父任务必须属于当前项目和执行"); ErrorCode PROJECT_TASK_NOT_ALLOW_EDIT = new ErrorCode(1_008_004_003, "当前项目或执行状态不允许维护任务"); ErrorCode PROJECT_TASK_STATUS_MODEL_NOT_EXISTS_OR_DISABLED = new ErrorCode(1_008_004_004, "任务状态定义不存在或已停用"); - ErrorCode PROJECT_TASK_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_004_005, "当前任务状态不支持动作【{}】"); - ErrorCode PROJECT_TASK_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_004_006, "动作【{}】必须填写原因"); + ErrorCode PROJECT_TASK_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_004_005, "当前任务为「{}」状态,不支持「{}」操作"); + ErrorCode PROJECT_TASK_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_004_006, "「{}」操作必须填写原因"); ErrorCode PROJECT_TASK_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_004_007, "任务状态已发生变化,请刷新后重试"); ErrorCode PROJECT_TASK_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_004_008, "当前任务状态不允许维护任务"); ErrorCode PROJECT_TASK_COMPLETE_CHILDREN_REQUIRED = new ErrorCode(1_008_004_010, "父任务完成前,子任务必须全部完成或取消"); - ErrorCode PROJECT_TASK_STATUS_OWNER_ONLY = new ErrorCode(1_008_004_011, "只有任务负责人才能执行【{}】动作"); + ErrorCode PROJECT_TASK_STATUS_OWNER_ONLY = new ErrorCode(1_008_004_011, "只有任务负责人才能执行「{}」操作"); ErrorCode PROJECT_TASK_NOT_ALLOW_DELETE = new ErrorCode(1_008_004_012, "已完成的任务不允许删除"); ErrorCode PROJECT_TASK_DELETE_NAME_MISMATCH = new ErrorCode(1_008_004_013, "确认任务名称与实际不一致"); ErrorCode PROJECT_TASK_DELETE_CONFIRM_TEXT_INVALID = new ErrorCode(1_008_004_014, "删除确认口令必须为 DELETE 或 删除"); @@ -195,8 +195,8 @@ public interface ErrorCodeConstants { // ========== 项目需求 1_008_007_xxx ========== ErrorCode PROJECT_REQUIREMENT_NOT_EXISTS = new ErrorCode(1_008_007_000, "项目需求不存在"); - ErrorCode PROJECT_REQUIREMENT_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_007_001, "当前项目需求状态不支持动作【{}】"); - ErrorCode PROJECT_REQUIREMENT_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_007_002, "动作【{}】必须填写原因"); + ErrorCode PROJECT_REQUIREMENT_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_007_001, "当前项目需求为「{}」状态,不支持「{}」操作"); + ErrorCode PROJECT_REQUIREMENT_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_007_002, "「{}」操作必须填写原因"); ErrorCode PROJECT_REQUIREMENT_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_007_003, "项目需求状态已发生变化,请刷新后重试"); ErrorCode PROJECT_REQUIREMENT_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_007_004, "当前项目需求状态不允许编辑"); ErrorCode PROJECT_REQUIREMENT_STATUS_NOT_ALLOW_CLOSE = new ErrorCode(1_008_007_005, "只有已验收的项目需求才能关闭"); @@ -221,8 +221,8 @@ public interface ErrorCodeConstants { ErrorCode PERSONAL_ITEM_NOT_EXISTS = new ErrorCode(1_008_008_001, "个人事项不存在"); ErrorCode PERSONAL_ITEM_OWNER_NOT_IN_EXECUTION = new ErrorCode(1_008_008_002, "个人事项负责人必须属于当前有效执行团队成员"); ErrorCode PERSONAL_ITEM_STATUS_MODEL_NOT_EXISTS_OR_DISABLED = new ErrorCode(1_008_008_003, "个人事项状态定义不存在或已停用"); - ErrorCode PERSONAL_ITEM_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_008_004, "当前个人事项状态不支持动作【{}】"); - ErrorCode PERSONAL_ITEM_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_008_005, "动作【{}】必须填写原因"); + ErrorCode PERSONAL_ITEM_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_008_004, "当前个人事项为「{}」状态,不支持「{}」操作"); + ErrorCode PERSONAL_ITEM_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_008_005, "「{}」操作必须填写原因"); ErrorCode PERSONAL_ITEM_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_008_006, "个人事项状态已发生变化,请刷新后重试"); ErrorCode PERSONAL_ITEM_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_008_007, "当前个人事项状态不允许编辑"); ErrorCode PERSONAL_ITEM_NOT_ALLOW_DELETE = new ErrorCode(1_008_008_008, "仅初始态(待开始)的个人事项允许删除"); @@ -231,8 +231,8 @@ public interface ErrorCodeConstants { // ========== 加班申请 1_008_009_xxx ========== ErrorCode OVERTIME_APPLICATION_NOT_EXISTS = new ErrorCode(1_008_009_001, "加班申请不存在"); ErrorCode OVERTIME_APPLICATION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED = new ErrorCode(1_008_009_002, "加班申请状态定义不存在或已停用"); - ErrorCode OVERTIME_APPLICATION_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_009_003, "当前加班申请状态不支持动作【{}】"); - ErrorCode OVERTIME_APPLICATION_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_009_004, "动作【{}】必须填写原因"); + ErrorCode OVERTIME_APPLICATION_STATUS_ACTION_NOT_ALLOWED = new ErrorCode(1_008_009_003, "当前加班申请为「{}」状态,不支持「{}」操作"); + ErrorCode OVERTIME_APPLICATION_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_009_004, "「{}」操作必须填写原因"); ErrorCode OVERTIME_APPLICATION_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_009_005, "加班申请状态已发生变化,请刷新后重试"); ErrorCode OVERTIME_APPLICATION_APPLICANT_ONLY = new ErrorCode(1_008_009_006, "仅申请人可执行该操作"); ErrorCode OVERTIME_APPLICATION_APPROVER_ONLY = new ErrorCode(1_008_009_007, "仅当前审核人可执行该操作"); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/MyExecutionController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/MyExecutionController.java new file mode 100644 index 0000000..f3ef65d --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/MyExecutionController.java @@ -0,0 +1,39 @@ +package com.njcn.rdms.module.project.controller.admin.project.execution; + +import com.njcn.rdms.framework.common.pojo.CommonResult; +import com.njcn.rdms.framework.common.pojo.PageParam; +import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.MyProjectExecutionPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.MyProjectExecutionRespVO; +import com.njcn.rdms.module.project.service.project.execution.ProjectExecutionService; +import io.swagger.v3.oas.annotations.Operation; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static com.njcn.rdms.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - 我负责的执行(跨项目)") +@RestController +@RequestMapping("/project/project/me/executions") +@Validated +public class MyExecutionController { + + @Resource + private ProjectExecutionService projectExecutionService; + + @GetMapping("/page") + @Operation(summary = "分页获取当前登录用户负责的执行(跨项目,默认排除终态与进度满)") + public CommonResult> getMyExecutionPage(@Valid MyProjectExecutionPageReqVO reqVO) { + // 前端固定传 pageSize=-1 拉全部;负数统一归一为 PAGE_SIZE_NONE(-1),与现有执行分页接口一致 + if (reqVO.getPageSize() != null && reqVO.getPageSize() < 0) { + reqVO.setPageSize(PageParam.PAGE_SIZE_NONE); + } + return success(projectExecutionService.getMyExecutionPage(reqVO)); + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/MyProjectExecutionPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/MyProjectExecutionPageReqVO.java new file mode 100644 index 0000000..f488f29 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/MyProjectExecutionPageReqVO.java @@ -0,0 +1,19 @@ +package com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution; + +import com.njcn.rdms.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Schema(description = "管理后台 - 我负责的执行(跨项目)分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class MyProjectExecutionPageReqVO extends PageParam { + + @Schema(description = "执行状态编码(预留,单状态精确过滤)", example = "active") + private String statusCode; + + @Schema(description = "执行名称模糊匹配关键字(预留)", example = "联调") + private String keyword; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/MyProjectExecutionRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/MyProjectExecutionRespVO.java new file mode 100644 index 0000000..d5c2006 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/execution/vo/execution/MyProjectExecutionRespVO.java @@ -0,0 +1,41 @@ +package com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDate; + +@Schema(description = "管理后台 - 我负责的执行(跨项目)Response VO") +@Data +public class MyProjectExecutionRespVO { + + @Schema(description = "执行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001") + private Long id; + @Schema(description = "执行名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "后端接口联调") + private String executionName; + @Schema(description = "所属项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2001") + private Long projectId; + @Schema(description = "所属项目名称", example = "商城 V2 升级") + private String projectName; + @Schema(description = "执行状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "active") + private String statusCode; + @Schema(description = "执行状态名称", example = "进行中") + private String statusName; + @Schema(description = "优先级编码(字典 rdms_req_priority),0=P0(最高) ~ 3=P3(最低)", example = "0") + private String priority; + @Schema(description = "计划开始日期") + private LocalDate plannedStartDate; + @Schema(description = "计划结束日期") + private LocalDate plannedEndDate; + @Schema(description = "实际开始日期") + private LocalDate actualStartDate; + @Schema(description = "实际结束日期") + private LocalDate actualEndDate; + @Schema(description = "执行进度百分比 0-100", example = "68") + private Integer progressRate; + @Schema(description = "关联项目需求编号") + private Long projectRequirementId; + @Schema(description = "关联项目需求名称", example = "订单履约后端拆分(一期)") + private String projectRequirementName; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/MyProjectController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/MyProjectController.java new file mode 100644 index 0000000..facd206 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/MyProjectController.java @@ -0,0 +1,51 @@ +package com.njcn.rdms.module.project.controller.admin.project.project; + +import com.njcn.rdms.framework.common.pojo.CommonResult; +import com.njcn.rdms.framework.common.pojo.PageParam; +import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectOwnedRespVO; +import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectParticipatedRespVO; +import com.njcn.rdms.module.project.service.project.MyProjectService; +import io.swagger.v3.oas.annotations.Operation; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static com.njcn.rdms.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - 工作台「我的项目」") +@RestController +@RequestMapping("/project/project/me") +@Validated +public class MyProjectController { + + @Resource + private MyProjectService myProjectService; + + @GetMapping("/participated/page") + @Operation(summary = "分页获取当前登录用户参与的项目(作为成员)") + public CommonResult> getMyParticipatedPage(@Valid MyProjectPageReqVO reqVO) { + normalizePageSize(reqVO); + return success(myProjectService.getMyParticipatedPage(reqVO)); + } + + @GetMapping("/owned/page") + @Operation(summary = "分页获取当前登录用户负责的项目(managerUserId=当前用户)") + public CommonResult> getMyOwnedPage(@Valid MyProjectPageReqVO reqVO) { + normalizePageSize(reqVO); + return success(myProjectService.getMyOwnedPage(reqVO)); + } + + /** 前端固定传 pageSize=-1 拉全部;负数统一归一为 PAGE_SIZE_NONE,与 MyExecutionController 一致。 */ + private void normalizePageSize(MyProjectPageReqVO reqVO) { + if (reqVO.getPageSize() != null && reqVO.getPageSize() < 0) { + reqVO.setPageSize(PageParam.PAGE_SIZE_NONE); + } + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/myproject/MyProjectOwnedRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/myproject/MyProjectOwnedRespVO.java new file mode 100644 index 0000000..a92883f --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/myproject/MyProjectOwnedRespVO.java @@ -0,0 +1,51 @@ +package com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; + +@Schema(description = "管理后台 - 我负责的项目 Response VO") +@Data +public class MyProjectOwnedRespVO { + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2001") + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + @Schema(description = "项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "商城 V2 升级") + private String name; + @Schema(description = "项目编码", example = "MALL-V2") + private String code; + @Schema(description = "项目整体进度百分比 0-100", requiredMode = Schema.RequiredMode.REQUIRED, example = "70") + private Integer progress; + @Schema(description = "当前用户在该项目中的角色名(恒含负责人语义)", example = "项目负责人") + private String myRole; + @Schema(description = "项目计划结束日期 YYYY-MM-DD;未设为 null") + private LocalDate plannedEndDate; + @Schema(description = "项目下进行中执行数", requiredMode = Schema.RequiredMode.REQUIRED, example = "6") + private Integer executionCount; + @Schema(description = "项目下进行中任务数", requiredMode = Schema.RequiredMode.REQUIRED, example = "24") + private Integer taskCount; + @Schema(description = "项目当前有效成员数", requiredMode = Schema.RequiredMode.REQUIRED, example = "5") + private Integer memberCount; + @Schema(description = "项目下逾期任务数", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer overdueCount; + @Schema(description = "成员负载原始数据;无成员为 []", requiredMode = Schema.RequiredMode.REQUIRED) + private List members; + + @Schema(description = "成员负载原始数据") + @Data + public static class MemberLoadVO { + @Schema(description = "成员用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "101") + @JsonSerialize(using = ToStringSerializer.class) + private Long userId; + @Schema(description = "成员姓名/昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三") + private String userName; + @Schema(description = "该成员在本项目下的进行中任务数", requiredMode = Schema.RequiredMode.REQUIRED, example = "6") + private Integer activeTaskCount; + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/myproject/MyProjectPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/myproject/MyProjectPageReqVO.java new file mode 100644 index 0000000..6b2ce72 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/myproject/MyProjectPageReqVO.java @@ -0,0 +1,16 @@ +package com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject; + +import com.njcn.rdms.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Schema(description = "管理后台 - 工作台「我的项目」分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class MyProjectPageReqVO extends PageParam { + + @Schema(description = "项目名称/编码模糊匹配关键字(预留,本期不过滤)", example = "商城") + private String keyword; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/myproject/MyProjectParticipatedRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/myproject/MyProjectParticipatedRespVO.java new file mode 100644 index 0000000..d52a69b --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/project/vo/myproject/MyProjectParticipatedRespVO.java @@ -0,0 +1,32 @@ +package com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - 我参与的项目 Response VO") +@Data +public class MyProjectParticipatedRespVO { + + @Schema(description = "项目编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2001") + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + @Schema(description = "项目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "商城 V2 升级") + private String name; + @Schema(description = "项目编码", example = "MALL-V2") + private String code; + @Schema(description = "项目状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "active") + private String statusCode; + @Schema(description = "项目状态名称", example = "进行中") + private String statusName; + @Schema(description = "项目整体进度百分比 0-100", requiredMode = Schema.RequiredMode.REQUIRED, example = "70") + private Integer progress; + @Schema(description = "当前用户在该项目中的角色名(主角色 / 附加角色拼接)", example = "前端负责人") + private String myRole; + @Schema(description = "我负责的任务总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "8") + private Integer myTaskCount; + @Schema(description = "我负责的未完成任务数", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer myPendingTaskCount; + +} 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 index f16975a..3fdca3c 100644 --- 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 @@ -111,4 +111,18 @@ public interface UserObjectRoleMapper extends BaseMapperX { .eq(UserObjectRoleDO::getStatus, 0)); } + /** + * 工作台「我负责的项目」:批量查一批对象下的活跃成员角色行(status=0)。 + * 一次拿全,内存按 objectId 分组,避免逐项目 N+1。 + */ + default List selectActiveListByObjectTypeAndObjectIds(String objectType, Collection objectIds) { + if (objectIds == null || objectIds.isEmpty()) { + return Collections.emptyList(); + } + return selectList(new LambdaQueryWrapperX() + .eq(UserObjectRoleDO::getObjectType, objectType) + .in(UserObjectRoleDO::getObjectId, objectIds) + .eq(UserObjectRoleDO::getStatus, 0)); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java index 962a8f1..42a248c 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/execution/ProjectExecutionMapper.java @@ -277,4 +277,27 @@ public interface ProjectExecutionMapper extends BaseMapperX .eq(ProjectExecutionDO::getStatusCode, fromStatus)); } + /** + * 接口二:一批项目下的进行中执行数(按 project_id 分组,排除终态)。 + * 返回 Map:projectId(Long) / executionCount(Long)。 + */ + @Select(""" + + """) + List> selectExecutionCountGroupByProjectIds( + @Param("projectIds") Collection projectIds, + @Param("terminalStatusCodes") Collection terminalStatusCodes); + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java index 9647e04..5fd2a3a 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java @@ -692,4 +692,91 @@ public interface ProjectTaskMapper extends BaseMapperX { private Long count; } + // ======================== 工作台「我的项目」聚合计数 ======================== + + /** + * 接口一:当前用户(owner_id)在一批项目下的任务总数与未完成数(按 project_id 分组)。 + * totalCount=全部我负责任务;pendingCount=状态非终态(终态集为空则等于 totalCount)。 + * 返回 Map:projectId(Long) / totalCount(Long) / pendingCount(Long)。 + */ + @Select(""" + + """) + List> selectMyTaskCountGroupByProjectIds( + @Param("ownerId") Long ownerId, + @Param("projectIds") Collection projectIds, + @Param("terminalStatusCodes") Collection terminalStatusCodes); + + /** + * 接口二:一批项目下的进行中任务数与逾期任务数(按 project_id 分组,一次扫表出两数)。 + * taskCount=状态非终态;overdueCount=planned_end_date < today 且状态非终态。 + * 返回 Map:projectId(Long) / taskCount(Long) / overdueCount(Long)。 + */ + @Select(""" + + """) + List> selectTaskAndOverdueCountGroupByProjectIds( + @Param("projectIds") Collection projectIds, + @Param("terminalStatusCodes") Collection terminalStatusCodes, + @Param("today") LocalDate today); + + /** + * 接口二 members:一批项目下每个负责人(owner_id)的进行中任务数(按 project_id, owner_id 分组)。 + * 排除 owner_id 为空的任务。返回 Map:projectId(Long) / ownerId(Long) / activeTaskCount(Long)。 + */ + @Select(""" + + """) + List> selectActiveTaskCountGroupByProjectIdAndOwner( + @Param("projectIds") Collection projectIds, + @Param("terminalStatusCodes") Collection terminalStatusCodes); + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/status/ObjectStatusTransitionMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/status/ObjectStatusTransitionMapper.java index c9da435..4374e6b 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/status/ObjectStatusTransitionMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/status/ObjectStatusTransitionMapper.java @@ -79,6 +79,18 @@ public interface ObjectStatusTransitionMapper extends BaseMapperX list = selectList(new LambdaQueryWrapperX() + .eq(ObjectStatusTransitionDO::getObjectType, objectType) + .eq(ObjectStatusTransitionDO::getActionCode, actionCode) + .last("LIMIT 1")); + return list.isEmpty() ? null : list.get(0).getActionName(); + } + /** * 物理删除 */ 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 index a26925e..9b95de6 100644 --- 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 @@ -32,4 +32,10 @@ public @interface CheckObjectPermission { */ boolean memberOnly() default false; + /** + * 是否走「可访问性门禁」:显式成员 OR 数据范围 scope 兜底(与 getXxxContext 入口口径一致)。 + * 为 true 时切面调用 checkAccessible,忽略 permission / memberOnly(优先级 accessible > memberOnly > permission)。 + */ + boolean accessible() 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 index 83911a7..07923cc 100644 --- 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 @@ -41,8 +41,13 @@ public class ObjectPermissionAspect { throw invalidParamException("暂不支持对象类型:{}", checkObjectPermission.objectType()); } Long objectId = resolveObjectId(joinPoint, checkObjectPermission.objectId()); - permissionService.checkPermission(objectId, checkObjectPermission.permission(), - checkObjectPermission.memberOnly()); + // 分发优先级:accessible(可访问性门禁)> memberOnly / permission(权限码) + if (checkObjectPermission.accessible()) { + permissionService.checkAccessible(objectId); + } else { + permissionService.checkPermission(objectId, checkObjectPermission.permission(), + checkObjectPermission.memberOnly()); + } return joinPoint.proceed(); } 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 index 6721d43..190988d 100644 --- 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 @@ -31,4 +31,12 @@ public interface ObjectPermissionService { */ boolean hasPermission(Long objectId, String permission); + /** + * 可访问性门禁:当前登录用户是否「能进入」该对象(显式成员 OR 数据范围 scope 兜底)。 + * 不可访问(含对象不存在)一律抛 ..._OBJECT_PERMISSION_DENIED,不暴露对象是否存在(见 spec §3.3)。 + * + * @param objectId 对象编号 + */ + void checkAccessible(Long objectId); + } 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 index 074fef1..90e4859 100644 --- 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 @@ -4,8 +4,12 @@ import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.module.project.constant.ObjectRoleConstants; import com.njcn.rdms.module.project.constant.ProductObjectConstants; import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO; +import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO; import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper; +import com.njcn.rdms.module.project.dal.mysql.product.ProductMapper; import com.njcn.rdms.module.project.enums.ErrorCodeConstants; +import com.njcn.rdms.module.project.service.datascope.ObjectDataScope; +import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService; import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; @@ -32,6 +36,10 @@ public class ProductObjectPermissionService implements ObjectPermissionService { private UserObjectRoleMapper userObjectRoleMapper; @Resource private ObjectPermissionApi objectPermissionApi; + @Resource + private ProductMapper productMapper; + @Resource + private ObjectDataScopeService objectDataScopeService; @Override public String getObjectType() { @@ -57,8 +65,9 @@ public class ProductObjectPermissionService implements ObjectPermissionService { List userRoles = userObjectRoleMapper .selectActiveListByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, objectId, loginUserId); if (userRoles.isEmpty()) { - throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED, - buildDeniedPermission(permission, memberOnly)); + // 权限码/成员标记仅作技术诊断,落日志不外泄给用户(见 用户可见错误文案规范) + log.warn("[checkPermission] 用户无对象角色,objectId={}, permission={}, memberOnly={}", objectId, permission, memberOnly); + throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED); } if (memberOnly) { return; @@ -70,7 +79,34 @@ public class ProductObjectPermissionService implements ObjectPermissionService { .distinct() .anyMatch(roleId -> getRolePermissions(roleId).contains(normalizedPermission)); if (!allowed) { - throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED, normalizedPermission); + log.warn("[checkPermission] 缺少对象权限码,objectId={}, permission={}", objectId, normalizedPermission); + throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED); + } + } + + @Override + public void checkAccessible(Long objectId) { + if (objectId == null) { + throw invalidParamException("对象编号不能为空"); + } + Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); + // 显式成员:拥有任一 ACTIVE 对象角色即可访问 + List userRoles = userObjectRoleMapper + .selectActiveListByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, objectId, loginUserId); + if (!userRoles.isEmpty()) { + return; + } + // 无显式角色:查对象拿 directionCode,按数据范围 scope 兜底(隐式 observer / 超管 ALL) + ProductDO product = productMapper.selectById(objectId); + if (product == null) { + // spec §3.3 定稿:对象不存在一律 DENIED,不暴露存在性(技术诊断落 log.warn) + log.warn("[checkAccessible] 对象不存在或无访问权,objectId={}", objectId); + throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED); + } + ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE); + if (!scope.contains(objectId, product.getDirectionCode())) { + log.warn("[checkAccessible] 无对象访问权,objectId={}", objectId); + throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED); } } @@ -95,8 +131,4 @@ public class ProductObjectPermissionService implements ObjectPermissionService { 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/framework/security/service/ProjectObjectPermissionService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionService.java index 56f9845..193e5f6 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionService.java @@ -5,8 +5,12 @@ import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.module.project.constant.ObjectRoleConstants; import com.njcn.rdms.module.project.constant.ProjectObjectConstants; import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper; import com.njcn.rdms.module.project.enums.ErrorCodeConstants; +import com.njcn.rdms.module.project.service.datascope.ObjectDataScope; +import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService; import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; @@ -33,6 +37,10 @@ public class ProjectObjectPermissionService implements ObjectPermissionService { private UserObjectRoleMapper userObjectRoleMapper; @Resource private ObjectPermissionApi objectPermissionApi; + @Resource + private ProjectMapper projectMapper; + @Resource + private ObjectDataScopeService objectDataScopeService; @Override public String getObjectType() { @@ -49,8 +57,9 @@ public class ProjectObjectPermissionService implements ObjectPermissionService { List userRoles = userObjectRoleMapper .selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, objectId, loginUserId); if (userRoles.isEmpty()) { - throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED, - buildDeniedPermission(permission, memberOnly)); + // 权限码/成员标记仅作技术诊断,落日志不外泄给用户(见 用户可见错误文案规范) + log.warn("[checkPermission] 用户无对象角色,objectId={}, permission={}, memberOnly={}", objectId, permission, memberOnly); + throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED); } if (memberOnly) { return; @@ -62,7 +71,34 @@ public class ProjectObjectPermissionService implements ObjectPermissionService { .distinct() .anyMatch(roleId -> getRolePermissions(roleId).contains(normalizedPermission)); if (!allowed) { - throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED, normalizedPermission); + log.warn("[checkPermission] 缺少对象权限码,objectId={}, permission={}", objectId, normalizedPermission); + throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED); + } + } + + @Override + public void checkAccessible(Long objectId) { + if (objectId == null) { + throw invalidParamException("对象编号不能为空"); + } + Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); + // 显式成员:拥有任一 ACTIVE 对象角色即可访问 + List userRoles = userObjectRoleMapper + .selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, objectId, loginUserId); + if (!userRoles.isEmpty()) { + return; + } + // 无显式角色:查对象拿 directionCode,按数据范围 scope 兜底(隐式 observer / 超管 ALL) + ProjectDO project = projectMapper.selectById(objectId); + if (project == null) { + // spec §3.3 定稿:对象不存在一律 DENIED,不暴露存在性(技术诊断落 log.warn) + log.warn("[checkAccessible] 对象不存在或无访问权,objectId={}", objectId); + throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED); + } + ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE); + if (!scope.contains(objectId, project.getDirectionCode())) { + log.warn("[checkAccessible] 无对象访问权,objectId={}", objectId); + throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED); } } @@ -113,8 +149,4 @@ public class ProjectObjectPermissionService implements ObjectPermissionService { 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/overtime/OvertimeApplicationServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/overtime/OvertimeApplicationServiceImpl.java index 65f2fc4..7d6d479 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/overtime/OvertimeApplicationServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/overtime/OvertimeApplicationServiceImpl.java @@ -25,6 +25,7 @@ import com.njcn.rdms.module.project.dal.mysql.overtime.OvertimeApplicationStatus import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper; import com.njcn.rdms.module.project.enums.ErrorCodeConstants; +import com.njcn.rdms.module.project.service.status.StatusActionTextResolver; import com.njcn.rdms.module.system.api.user.AdminUserApi; import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; import jakarta.annotation.Resource; @@ -58,6 +59,8 @@ public class OvertimeApplicationServiceImpl implements OvertimeApplicationServic @Resource private ObjectStatusTransitionMapper objectStatusTransitionMapper; @Resource + private StatusActionTextResolver statusActionTextResolver; + @Resource private AdminUserApi adminUserApi; @Override @@ -266,10 +269,13 @@ public class OvertimeApplicationServiceImpl implements OvertimeApplicationServic .selectByObjectTypeAndFromStatusAndAction(OvertimeApplicationConstants.STATUS_OBJECT_TYPE, fromStatus, actionCode); if (transition == null) { - throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_STATUS_ACTION_NOT_ALLOWED, actionCode); + throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_STATUS_ACTION_NOT_ALLOWED, + statusActionTextResolver.statusName(OvertimeApplicationConstants.STATUS_OBJECT_TYPE, fromStatus), + statusActionTextResolver.actionName(OvertimeApplicationConstants.STATUS_OBJECT_TYPE, actionCode)); } if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) { - throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_STATUS_ACTION_REASON_REQUIRED, actionCode); + throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_STATUS_ACTION_REASON_REQUIRED, + statusActionTextResolver.actionName(OvertimeApplicationConstants.STATUS_OBJECT_TYPE, actionCode)); } ObjectStatusModelDO toModel = objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled( OvertimeApplicationConstants.STATUS_OBJECT_TYPE, transition.getToStatusCode()); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemServiceImpl.java index 4f23d80..571f3ef 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/personal/PersonalItemServiceImpl.java @@ -30,6 +30,7 @@ import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMappe import com.njcn.rdms.module.project.enums.ErrorCodeConstants; import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver; import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator; +import com.njcn.rdms.module.project.service.status.StatusActionTextResolver; import com.njcn.rdms.module.system.api.user.AdminUserApi; import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; import jakarta.annotation.Resource; @@ -83,6 +84,8 @@ public class PersonalItemServiceImpl implements PersonalItemService { private AttachmentFileIdResolver attachmentFileIdResolver; @Resource private AdminUserApi adminUserApi; + @Resource + private StatusActionTextResolver statusActionTextResolver; @Override @Transactional(rollbackFor = Exception.class) @@ -170,11 +173,13 @@ public class PersonalItemServiceImpl implements PersonalItemService { ObjectStatusTransitionDO transition = objectStatusTransitionMapper .selectByObjectTypeAndFromStatusAndAction(PersonalItemConstants.STATUS_OBJECT_TYPE, fromStatus, actionCode); if (transition == null) { - throw exception(ErrorCodeConstants.PERSONAL_ITEM_STATUS_ACTION_NOT_ALLOWED, actionCode); + throw exception(ErrorCodeConstants.PERSONAL_ITEM_STATUS_ACTION_NOT_ALLOWED, + statusActionTextResolver.statusName(PersonalItemConstants.STATUS_OBJECT_TYPE, fromStatus), + statusActionTextResolver.actionName(PersonalItemConstants.STATUS_OBJECT_TYPE, actionCode)); } String reason = normalizeNullableText(reqVO.getReason()); if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) { - throw exception(ErrorCodeConstants.PERSONAL_ITEM_STATUS_ACTION_REASON_REQUIRED, actionCode); + throw exception(ErrorCodeConstants.PERSONAL_ITEM_STATUS_ACTION_REASON_REQUIRED, transition.getActionName()); } String toStatus = transition.getToStatusCode(); int updateCount = personalItemMapper.updateStatusByIdAndStatus(item.getId(), fromStatus, toStatus, reason); 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 index c4b9b42..e449cec 100644 --- 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 @@ -65,6 +65,7 @@ public class ProductMemberServiceImpl implements ProductMemberService { private AdminUserApi adminUserApi; @Override + @CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId", accessible = true) public List getProductMemberList(Long productId) { ProductDO product = validateProductExists(productId); List members = userObjectRoleMapper.selectListByObject(ProductObjectConstants.OBJECT_TYPE, productId); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductRequirementServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductRequirementServiceImpl.java index 0e80939..22c04d5 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductRequirementServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductRequirementServiceImpl.java @@ -125,6 +125,8 @@ public class ProductRequirementServiceImpl implements ProductRequirementService private UserObjectRoleMapper userObjectRoleMapper; @Resource private AttachmentFileIdResolver attachmentFileIdResolver; + @Resource + private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver; // ========== 需求增删改查 ========== @@ -1229,7 +1231,9 @@ public class ProductRequirementServiceImpl implements ProductRequirementService */ private void validateReviewRejectedActionAllowed(ProductRequirementDO requirement, String actionCode) { if (!isReviewRejectedActionAllowed(requirement, actionCode)) { - throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_NOT_ALLOWED, actionCode); + throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_NOT_ALLOWED, + statusActionTextResolver.statusName(REQUIREMENT_OBJECT_TYPE, requirement.getStatusCode()), + statusActionTextResolver.actionName(REQUIREMENT_OBJECT_TYPE, actionCode)); } } @@ -1533,7 +1537,9 @@ public class ProductRequirementServiceImpl implements ProductRequirementService ObjectStatusTransitionDO transition = statusTransitionMapper .selectByObjectTypeAndFromStatusAndAction(REQUIREMENT_OBJECT_TYPE, fromStatusCode, actionCode); if (transition == null) { - throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_NOT_ALLOWED, actionCode); + throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_NOT_ALLOWED, + statusActionTextResolver.statusName(REQUIREMENT_OBJECT_TYPE, fromStatusCode), + statusActionTextResolver.actionName(REQUIREMENT_OBJECT_TYPE, actionCode)); } return transition; } @@ -1544,7 +1550,7 @@ public class ProductRequirementServiceImpl implements ProductRequirementService @VisibleForTesting void validateTransitionReason(ObjectStatusTransitionDO transition, String reason) { if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) { - throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_REASON_REQUIRED, transition.getActionCode()); + throw exception(ErrorCodeConstants.REQUIREMENT_STATUS_ACTION_REASON_REQUIRED, transition.getActionName()); } } 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 4addaf8..54dca5f 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 @@ -89,6 +89,8 @@ public class ProductServiceImpl implements ProductService { private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService; @Resource private ObjectDataScopeService objectDataScopeService; + @Resource + private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver; @Override @Transactional(rollbackFor = Exception.class) @@ -274,7 +276,7 @@ public class ProductServiceImpl implements ProductService { // 显式角色为空:走 scope.contains 判定隐式 observer 兜底(设计文档 2.1 节末段) ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE); if (!scope.contains(id, product.getDirectionCode())) { - throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED, "查看"); + throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED); } return buildImplicitObserverContext(product); } @@ -568,7 +570,9 @@ public class ProductServiceImpl implements ProductService { ObjectStatusTransitionDO transition = objectStatusTransitionMapper .selectByObjectTypeAndFromStatusAndAction(ProductObjectConstants.OBJECT_TYPE, fromStatusCode, actionCode); if (transition == null) { - throw exception(ErrorCodeConstants.PRODUCT_STATUS_ACTION_NOT_ALLOWED, actionCode); + throw exception(ErrorCodeConstants.PRODUCT_STATUS_ACTION_NOT_ALLOWED, + statusActionTextResolver.statusName(ProductObjectConstants.OBJECT_TYPE, fromStatusCode), + statusActionTextResolver.actionName(ProductObjectConstants.OBJECT_TYPE, actionCode)); } return transition; } @@ -576,7 +580,7 @@ public class ProductServiceImpl implements ProductService { @VisibleForTesting void validateTransitionReason(ObjectStatusTransitionDO transition, String reason) { if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) { - throw exception(ErrorCodeConstants.PRODUCT_STATUS_ACTION_REASON_REQUIRED, transition.getActionCode()); + throw exception(ErrorCodeConstants.PRODUCT_STATUS_ACTION_REASON_REQUIRED, transition.getActionName()); } } 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 index 6162acd..48dd263 100644 --- 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 @@ -11,7 +11,9 @@ import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductS 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.constant.ProductObjectConstants; import com.njcn.rdms.module.project.enums.ErrorCodeConstants; +import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission; import com.njcn.rdms.module.system.api.user.AdminUserApi; import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; import jakarta.annotation.Resource; @@ -34,6 +36,7 @@ public class ProductSettingServiceImpl implements ProductSettingService { private ProductActivityTimelineQueryService productActivityTimelineQueryService; @Override + @CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId", accessible = true) public ProductSettingRespVO getProductSettings(Long productId) { ProductDO product = validateProductExists(productId); ProductSettingRespVO respVO = new ProductSettingRespVO(); @@ -43,12 +46,14 @@ public class ProductSettingServiceImpl implements ProductSettingService { } @Override + @CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId", accessible = true) public PageResult getProductActivities(Long productId, ProductActivityPageReqVO reqVO) { validateProductExists(productId); return productActivityQueryService.getProductActivities(productId, reqVO); } @Override + @CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId", accessible = true) public PageResult getProductActivityTimelinePage( Long productId, ProductActivityTimelinePageReqVO reqVO) { validateProductExists(productId); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyProjectService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyProjectService.java new file mode 100644 index 0000000..22ca49f --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyProjectService.java @@ -0,0 +1,19 @@ +package com.njcn.rdms.module.project.service.project; + +import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectOwnedRespVO; +import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectParticipatedRespVO; + +/** + * 工作台「我的项目」Service:按登录用户隐式聚合,无权限注解。 + */ +public interface MyProjectService { + + /** 我参与的项目(作为成员) */ + PageResult getMyParticipatedPage(MyProjectPageReqVO reqVO); + + /** 我负责的项目(managerUserId = 登录用户) */ + PageResult getMyOwnedPage(MyProjectPageReqVO reqVO); + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyProjectServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyProjectServiceImpl.java new file mode 100644 index 0000000..65b5cad --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/MyProjectServiceImpl.java @@ -0,0 +1,344 @@ +package com.njcn.rdms.module.project.service.project; + +import com.njcn.rdms.framework.common.pojo.PageParam; +import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX; +import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; +import com.njcn.rdms.module.project.constant.ObjectRoleConstants; +import com.njcn.rdms.module.project.constant.ProjectExecutionConstants; +import com.njcn.rdms.module.project.constant.ProjectObjectConstants; +import com.njcn.rdms.module.project.constant.ProjectTaskConstants; +import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectOwnedRespVO; +import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectParticipatedRespVO; +import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; +import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO; +import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper; +import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper; +import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper; +import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; +import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; +import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO; +import com.njcn.rdms.module.system.api.user.AdminUserApi; +import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class MyProjectServiceImpl implements MyProjectService { + + @Resource + private ProjectMapper projectMapper; + @Resource + private ProjectTaskMapper projectTaskMapper; + @Resource + private ProjectExecutionMapper projectExecutionMapper; + @Resource + private UserObjectRoleMapper userObjectRoleMapper; + @Resource + private ObjectStatusModelMapper objectStatusModelMapper; + @Resource + private ObjectPermissionApi objectPermissionApi; + @Resource + private AdminUserApi adminUserApi; + + /** 工作台「我的项目」列表统一排序:按项目创建时间升序(先创建的在前),id 兜底保证稳定。 */ + private static final Comparator PROJECT_CREATE_TIME_ASC = + Comparator.comparing(ProjectDO::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(ProjectDO::getId); + + @Override + public PageResult getMyParticipatedPage(MyProjectPageReqVO reqVO) { + Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); + // 1. 我参与的所有 active 角色行(含 manager/dev 等多角色,objectId=项目id) + List myRoles = userObjectRoleMapper + .selectActiveListByObjectTypeAndUserId(ProjectObjectConstants.OBJECT_TYPE, loginUserId); + if (myRoles.isEmpty()) { + return new PageResult<>(Collections.emptyList(), 0L); + } + // 2. 按项目分组我的角色行 + Map> rolesByProject = myRoles.stream() + .filter(r -> r.getObjectId() != null) + .collect(Collectors.groupingBy(UserObjectRoleDO::getObjectId, LinkedHashMap::new, Collectors.toList())); + Set projectIds = new LinkedHashSet<>(rolesByProject.keySet()); + // 3. 项目基本信息 + List projects = projectMapper.selectBatchIds(projectIds); + if (projects.isEmpty()) { + return new PageResult<>(Collections.emptyList(), 0L); + } + // 3.1 仅保留非终态项目(终态项目不在工作台「我的项目」体现),并按创建时间升序(先创建的在前) + List projectTerminal = objectStatusModelMapper + .selectTerminalStatusCodesByObjectTypeEnabled(ProjectObjectConstants.OBJECT_TYPE); + projects = projects.stream() + .filter(p -> !projectTerminal.contains(p.getStatusCode())) + .sorted(PROJECT_CREATE_TIME_ASC) + .collect(Collectors.toList()); + if (projects.isEmpty()) { + return new PageResult<>(Collections.emptyList(), 0L); + } + // 4. statusName 批量回填 + Map statusNameMap = loadStatusNameMap(ProjectObjectConstants.OBJECT_TYPE); + // 5. 角色名 map(一次性拉全部涉及 roleId) + Map roleMap = loadRoleMap(myRoles.stream() + .map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet())); + // 5.1 每个项目下"我的可见角色行":剔除 visible=0 的隐式角色(创建者 / 隐式观察者等业务自动赋予角色)。 + // 若某项目下我没有任何可见角色,则不算"我参与的项目",整项剔除——与 ProjectMemberServiceImpl 团队列表口径一致。 + Map> visibleRolesByProject = new LinkedHashMap<>(); + rolesByProject.forEach((pid, rows) -> { + List visible = filterVisibleRoleRows(rows, roleMap); + if (!visible.isEmpty()) { + visibleRolesByProject.put(pid, visible); + } + }); + // 6. 我负责的任务计数(owner=me,按项目分组 total + pending) + List taskTerminal = objectStatusModelMapper + .selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE); + Map taskCountMap = new LinkedHashMap<>(); + for (Map row : projectTaskMapper + .selectMyTaskCountGroupByProjectIds(loginUserId, projectIds, taskTerminal)) { + taskCountMap.put(asLong(row.get("projectId")), + new long[]{asLong(row.get("totalCount")), asLong(row.get("pendingCount"))}); + } + // 7. 组装(仅保留我有可见角色的项目) + List all = projects.stream() + .filter(p -> visibleRolesByProject.containsKey(p.getId())) + .map(p -> { + MyProjectParticipatedRespVO vo = new MyProjectParticipatedRespVO(); + vo.setId(p.getId()); + vo.setName(p.getProjectName()); + vo.setCode(p.getProjectCode()); + vo.setStatusCode(p.getStatusCode()); + vo.setStatusName(statusNameMap.get(p.getStatusCode())); + vo.setProgress(toProgressInt(p.getProgressRate())); + vo.setMyRole(buildMyRole(visibleRolesByProject.get(p.getId()), roleMap)); + long[] c = taskCountMap.getOrDefault(p.getId(), new long[]{0L, 0L}); + vo.setMyTaskCount((int) c[0]); + vo.setMyPendingTaskCount((int) c[1]); + return vo; + }).collect(Collectors.toList()); + return paginate(all, reqVO); + } + + // ======================== 共用私有方法 ======================== + + private Map loadStatusNameMap(String objectType) { + return objectStatusModelMapper.selectListByObjectTypeEnabled(objectType).stream() + .collect(Collectors.toMap(ObjectStatusModelDO::getStatusCode, + ObjectStatusModelDO::getStatusName, (a, b) -> a)); + } + + private Map loadRoleMap(Set roleIds) { + if (roleIds.isEmpty()) { + return Collections.emptyMap(); + } + List roles = objectPermissionApi + .getObjectRoleList(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProjectObjectConstants.OBJECT_TYPE) + .getCheckedData(); + if (roles == null || roles.isEmpty()) { + return Collections.emptyMap(); + } + return roles.stream().collect(Collectors.toMap(ObjectRoleRespDTO::getId, Function.identity(), (a, b) -> a)); + } + + /** 可见角色行:剔除 visible=0 的隐式角色(创建者 / 隐式观察者等业务自动赋予角色);visible=null 或 roleMap 缺失视同可见。 */ + private List filterVisibleRoleRows(List rows, Map roleMap) { + if (rows == null || rows.isEmpty()) { + return Collections.emptyList(); + } + return rows.stream() + .filter(r -> { + ObjectRoleRespDTO role = roleMap.get(r.getRoleId()); + return role == null || !Integer.valueOf(0).equals(role.getVisible()); + }) + .collect(Collectors.toList()); + } + + /** + * 主角色 + 附加角色名拼接。入参为已过滤的可见角色行(visible=0 隐式角色已在上游剔除)。 + * 主角色挑选与 ProjectMemberServiceImpl 一致:MANAGER 优先,否则 roleId 最小。 + */ + private String buildMyRole(List rowsVisible, Map roleMap) { + if (rowsVisible == null || rowsVisible.isEmpty()) { + return null; + } + UserObjectRoleDO primary = rowsVisible.stream() + .filter(r -> { + ObjectRoleRespDTO role = roleMap.get(r.getRoleId()); + return role != null && Objects.equals(ProjectObjectConstants.MANAGER_ROLE_CODE, role.getCode()); + }) + .findFirst() + .orElseGet(() -> rowsVisible.stream() + .min(Comparator.comparing(UserObjectRoleDO::getRoleId)) + .orElse(rowsVisible.get(0))); + String primaryName = roleName(roleMap, primary.getRoleId()); + List additional = rowsVisible.stream() + .filter(r -> !Objects.equals(r.getId(), primary.getId())) + .map(r -> roleName(roleMap, r.getRoleId())) + .filter(Objects::nonNull) + .sorted() + .collect(Collectors.toList()); + StringBuilder sb = new StringBuilder(primaryName == null ? "" : primaryName); + for (String n : additional) { + if (sb.length() > 0) { + sb.append(" / "); + } + sb.append(n); + } + return sb.length() == 0 ? null : sb.toString(); + } + + private String roleName(Map roleMap, Long roleId) { + ObjectRoleRespDTO role = roleMap.get(roleId); + return role == null ? null : role.getName(); + } + + private Integer toProgressInt(BigDecimal v) { + return v == null ? 0 : v.setScale(0, RoundingMode.HALF_UP).intValue(); + } + + private long asLong(Object v) { + return v == null ? 0L : ((Number) v).longValue(); + } + + private PageResult paginate(List all, PageParam reqVO) { + long total = all.size(); + Integer pageSize = reqVO.getPageSize(); + if (pageSize == null || pageSize < 0) { + return new PageResult<>(all, total); + } + int pageNo = reqVO.getPageNo() == null || reqVO.getPageNo() < 1 ? 1 : reqVO.getPageNo(); + int fromIndex = Math.min((pageNo - 1) * pageSize, all.size()); + int toIndex = Math.min(fromIndex + pageSize, all.size()); + return new PageResult<>(all.subList(fromIndex, toIndex), total); + } + + @Override + public PageResult getMyOwnedPage(MyProjectPageReqVO reqVO) { + Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); + // 1. 我负责的项目(managerUserId = 登录用户) + List projects = projectMapper.selectList(new LambdaQueryWrapperX() + .eq(ProjectDO::getManagerUserId, loginUserId)); + if (projects.isEmpty()) { + return new PageResult<>(Collections.emptyList(), 0L); + } + // 1.1 仅保留非终态项目(终态项目不在工作台「我的项目」体现),并按创建时间升序(先创建的在前) + List projectTerminal = objectStatusModelMapper + .selectTerminalStatusCodesByObjectTypeEnabled(ProjectObjectConstants.OBJECT_TYPE); + projects = projects.stream() + .filter(p -> !projectTerminal.contains(p.getStatusCode())) + .sorted(PROJECT_CREATE_TIME_ASC) + .collect(Collectors.toList()); + if (projects.isEmpty()) { + return new PageResult<>(Collections.emptyList(), 0L); + } + Set projectIds = projects.stream() + .map(ProjectDO::getId).collect(Collectors.toCollection(LinkedHashSet::new)); + // 2. 终态集 + List taskTerminal = objectStatusModelMapper + .selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE); + List execTerminal = objectStatusModelMapper + .selectTerminalStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE); + LocalDate today = LocalDate.now(); + // 3. 任务数 + 逾期数(一次扫表) + Map taskMap = new LinkedHashMap<>(); + for (Map row : projectTaskMapper + .selectTaskAndOverdueCountGroupByProjectIds(projectIds, taskTerminal, today)) { + taskMap.put(asLong(row.get("projectId")), + new long[]{asLong(row.get("taskCount")), asLong(row.get("overdueCount"))}); + } + // 4. 执行数 + Map execMap = new LinkedHashMap<>(); + for (Map row : projectExecutionMapper + .selectExecutionCountGroupByProjectIds(projectIds, execTerminal)) { + execMap.put(asLong(row.get("projectId")), asLong(row.get("executionCount"))); + } + // 5. 每个负责人(owner)的进行中任务数:projectId -> (ownerId -> count) + Map> activeTaskMap = new LinkedHashMap<>(); + for (Map row : projectTaskMapper + .selectActiveTaskCountGroupByProjectIdAndOwner(projectIds, taskTerminal)) { + Long pid = asLong(row.get("projectId")); + Long ownerId = asLong(row.get("ownerId")); + activeTaskMap.computeIfAbsent(pid, k -> new LinkedHashMap<>()) + .put(ownerId, asLong(row.get("activeTaskCount"))); + } + // 6. 成员清单(批量一次拿全,内存按项目分组;同 user 多角色去重为一个成员) + List memberRows = userObjectRoleMapper + .selectActiveListByObjectTypeAndObjectIds(ProjectObjectConstants.OBJECT_TYPE, projectIds); + Map> memberUserIdsByProject = new LinkedHashMap<>(); + for (UserObjectRoleDO m : memberRows) { + if (m.getObjectId() == null || m.getUserId() == null) { + continue; + } + List users = memberUserIdsByProject.computeIfAbsent(m.getObjectId(), k -> new ArrayList<>()); + if (!users.contains(m.getUserId())) { + users.add(m.getUserId()); + } + } + // 7. 成员昵称批量回填 + Set allUserIds = memberUserIdsByProject.values().stream() + .flatMap(List::stream).collect(Collectors.toSet()); + Map userMap = allUserIds.isEmpty() + ? Collections.emptyMap() : adminUserApi.getUserMap(allUserIds); + // 8. myRole 恒为负责人角色名(一次性解析) + String managerRoleName = resolveManagerRoleName(); + // 9. 组装 + List all = projects.stream().map(p -> { + MyProjectOwnedRespVO vo = new MyProjectOwnedRespVO(); + vo.setId(p.getId()); + vo.setName(p.getProjectName()); + vo.setCode(p.getProjectCode()); + vo.setProgress(toProgressInt(p.getProgressRate())); + vo.setMyRole(managerRoleName); + vo.setPlannedEndDate(p.getPlannedEndDate()); + long[] tc = taskMap.getOrDefault(p.getId(), new long[]{0L, 0L}); + vo.setTaskCount((int) tc[0]); + vo.setOverdueCount((int) tc[1]); + vo.setExecutionCount(execMap.getOrDefault(p.getId(), 0L).intValue()); + List memberUserIds = memberUserIdsByProject.getOrDefault(p.getId(), Collections.emptyList()); + vo.setMemberCount(memberUserIds.size()); + Map ownerCounts = activeTaskMap.getOrDefault(p.getId(), Collections.emptyMap()); + List members = memberUserIds.stream().map(uid -> { + MyProjectOwnedRespVO.MemberLoadVO mv = new MyProjectOwnedRespVO.MemberLoadVO(); + mv.setUserId(uid); + AdminUserRespDTO user = userMap.get(uid); + mv.setUserName(user == null ? null : user.getNickname()); + mv.setActiveTaskCount(ownerCounts.getOrDefault(uid, 0L).intValue()); + return mv; + }).collect(Collectors.toList()); + vo.setMembers(members); + return vo; + }).collect(Collectors.toList()); + return paginate(all, reqVO); + } + + /** 项目负责人角色名(对象域 MANAGER_ROLE_CODE);解析失败返回 null,不阻断列表。 */ + private String resolveManagerRoleName() { + try { + ObjectRoleRespDTO role = objectPermissionApi + .getObjectRoleByCode(ProjectObjectConstants.MANAGER_ROLE_CODE, + ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProjectObjectConstants.OBJECT_TYPE) + .getCheckedData(); + return role == null ? null : role.getName(); + } catch (RuntimeException ex) { + return null; + } + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectMemberServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectMemberServiceImpl.java index ee11cd9..b36de4d 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectMemberServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectMemberServiceImpl.java @@ -69,6 +69,7 @@ public class ProjectMemberServiceImpl implements ProjectMemberService { private ProjectExecutionMapper projectExecutionMapper; @Override + @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true) public List getProjectMemberList(Long projectId) { ProjectDO project = validateProjectExists(projectId); List members = userObjectRoleMapper.selectListByObject(ProjectObjectConstants.OBJECT_TYPE, projectId); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectRequirementServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectRequirementServiceImpl.java index 330bb02..33c34e9 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectRequirementServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectRequirementServiceImpl.java @@ -41,6 +41,7 @@ import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMappe import com.njcn.rdms.module.project.enums.ErrorCodeConstants; import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver; import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator; +import com.njcn.rdms.module.project.service.status.StatusActionTextResolver; import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; @@ -131,6 +132,8 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService private AttachmentFileIdResolver attachmentFileIdResolver; @Resource private ProjectExecutionMapper projectExecutionMapper; + @Resource + private StatusActionTextResolver statusActionTextResolver; @Override @Transactional(rollbackFor = Exception.class) @@ -882,7 +885,9 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService */ private void validateReviewRejectedActionAllowed(ProjectRequirementDO requirement, String actionCode) { if (!isReviewRejectedActionAllowed(requirement, actionCode)) { - throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_ACTION_NOT_ALLOWED, actionCode); + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_ACTION_NOT_ALLOWED, + statusActionTextResolver.statusName(REQUIREMENT_OBJECT_TYPE, requirement.getStatusCode()), + statusActionTextResolver.actionName(REQUIREMENT_OBJECT_TYPE, actionCode)); } } @@ -1297,7 +1302,9 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService ObjectStatusTransitionDO transition = statusTransitionMapper .selectByObjectTypeAndFromStatusAndAction(REQUIREMENT_OBJECT_TYPE, fromStatusCode, actionCode); if (transition == null) { - throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_ACTION_NOT_ALLOWED, actionCode); + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_ACTION_NOT_ALLOWED, + statusActionTextResolver.statusName(REQUIREMENT_OBJECT_TYPE, fromStatusCode), + statusActionTextResolver.actionName(REQUIREMENT_OBJECT_TYPE, actionCode)); } return transition; } @@ -1305,7 +1312,7 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService @VisibleForTesting void validateTransitionReason(ObjectStatusTransitionDO transition, String reason) { if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) { - throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_ACTION_REASON_REQUIRED, transition.getActionCode()); + throw exception(ErrorCodeConstants.PROJECT_REQUIREMENT_STATUS_ACTION_REASON_REQUIRED, transition.getActionName()); } } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java index afa9c89..b1a7e34 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java @@ -45,6 +45,7 @@ import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX; import com.njcn.rdms.module.project.service.datascope.ObjectDataScope; import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService; +import com.njcn.rdms.module.project.service.status.StatusActionTextResolver; import com.njcn.rdms.module.system.api.dict.DictDataApi; import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; import com.njcn.rdms.module.system.api.permission.dto.ObjectMenuRespDTO; @@ -116,6 +117,8 @@ class ProjectServiceImpl implements ProjectService { private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService; @Resource private ObjectDataScopeService objectDataScopeService; + @Resource + private StatusActionTextResolver statusActionTextResolver; @Override @Transactional(rollbackFor = Exception.class) @@ -393,7 +396,7 @@ class ProjectServiceImpl implements ProjectService { // 显式角色为空:走 scope.contains 判定隐式 observer 兜底(设计文档 2.1 节末段) ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE); if (!scope.contains(id, project.getDirectionCode())) { - throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED, "查看"); + throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED); } return buildImplicitObserverContext(project); } @@ -580,7 +583,9 @@ class ProjectServiceImpl implements ProjectService { ProjectDO project = validateProjectExists(reqVO.getId()); String actionCode = reqVO.getActionCode().trim(); if (ObjectActivityConstants.PROJECT_ACTION_AUTO_START.equals(actionCode)) { - throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED, actionCode); + throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED, + statusActionTextResolver.statusName(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode()), + statusActionTextResolver.actionName(ProjectObjectConstants.OBJECT_TYPE, actionCode)); } changeStatus(project, actionCode, normalizeNullableText(reqVO.getReason())); } @@ -613,10 +618,12 @@ class ProjectServiceImpl implements ProjectService { @Transactional(rollbackFor = Exception.class) public void autoStartProjectIfPending(Long projectId, String triggerAction) { // auto_start 只允许由后端业务动作内部触发,前端不应直接透传该动作。 - if (!ProjectObjectConstants.AUTO_START_TRIGGERS.contains(triggerAction)) { - throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED, triggerAction); - } ProjectDO project = validateProjectExists(projectId); + if (!ProjectObjectConstants.AUTO_START_TRIGGERS.contains(triggerAction)) { + throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED, + statusActionTextResolver.statusName(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode()), + statusActionTextResolver.actionName(ProjectObjectConstants.OBJECT_TYPE, triggerAction)); + } ObjectStatusTransitionDO transition = objectStatusTransitionMapper .selectByObjectTypeAndFromStatusAndAction(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode(), ObjectActivityConstants.PROJECT_ACTION_AUTO_START); @@ -625,7 +632,9 @@ class ProjectServiceImpl implements ProjectService { ObjectStatusModelDO statusModel = validateEnabledStatusModel(project.getStatusCode()); if (Boolean.TRUE.equals(statusModel.getInitialFlag())) { throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED, - ObjectActivityConstants.PROJECT_ACTION_AUTO_START); + statusActionTextResolver.statusName(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode()), + statusActionTextResolver.actionName(ProjectObjectConstants.OBJECT_TYPE, + ObjectActivityConstants.PROJECT_ACTION_AUTO_START)); } if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) { throw exception(ErrorCodeConstants.PROJECT_STATUS_NOT_ALLOW_EDIT); @@ -772,10 +781,12 @@ class ProjectServiceImpl implements ProjectService { ObjectStatusTransitionDO transition = objectStatusTransitionMapper .selectByObjectTypeAndFromStatusAndAction(ProjectObjectConstants.OBJECT_TYPE, fromStatusCode, actionCode); if (transition == null) { - throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED, actionCode); + throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED, + statusActionTextResolver.statusName(ProjectObjectConstants.OBJECT_TYPE, fromStatusCode), + statusActionTextResolver.actionName(ProjectObjectConstants.OBJECT_TYPE, actionCode)); } if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) { - throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_REASON_REQUIRED, transition.getActionCode()); + throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_REASON_REQUIRED, transition.getActionName()); } return transition; } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionAssigneeServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionAssigneeServiceImpl.java index 3cf7f4a..b40a85f 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionAssigneeServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionAssigneeServiceImpl.java @@ -78,6 +78,8 @@ public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssi private AdminUserApi adminUserApi; @Override + @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", + permission = ProjectExecutionConstants.PERMISSION_QUERY) public List getExecutionAssigneeList(Long projectId, Long executionId) { validateProjectExists(projectId); validateExecutionExists(projectId, executionId); @@ -150,6 +152,8 @@ public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssi } @Override + @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", + permission = ProjectExecutionConstants.PERMISSION_QUERY) public PageResult getExecutionAssigneeLogPage(Long projectId, Long executionId, ExecutionAssigneeLogPageReqVO reqVO) { validateProjectExists(projectId); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionService.java index 9777319..7ec118a 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionService.java @@ -1,6 +1,8 @@ package com.njcn.rdms.module.project.service.project.execution; import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.MyProjectExecutionPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.MyProjectExecutionRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionOwnerChangeReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO; @@ -38,6 +40,13 @@ public interface ProjectExecutionService { List getCurrentUserExecutionList(); + /** + * 分页查询当前登录用户作为负责人(owner)的执行,跨所有项目聚合。 + * 默认口径:排除终态状态(completed/cancelled)且排除进度已满(progressRate >= 100)的执行。 + * pageSize 传 -1(PageParam.PAGE_SIZE_NONE)= 返回全部、不切片。 + */ + PageResult getMyExecutionPage(MyProjectExecutionPageReqVO reqVO); + void changeOwner(Long projectId, Long executionId, ProjectExecutionOwnerChangeReqVO reqVO); /** diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java index 98ef254..1838773 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java @@ -9,6 +9,8 @@ import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.module.project.constant.ObjectActivityConstants; import com.njcn.rdms.module.project.constant.ProjectExecutionConstants; import com.njcn.rdms.module.project.constant.ProjectObjectConstants; +import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.MyProjectExecutionPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.MyProjectExecutionRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionOwnerChangeReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO; @@ -46,6 +48,7 @@ import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPer import com.njcn.rdms.module.project.service.project.ProjectRequirementService; import com.njcn.rdms.module.project.service.project.ProjectService; import com.njcn.rdms.module.project.service.project.task.ProjectTaskService; +import com.njcn.rdms.module.project.service.status.StatusActionTextResolver; import com.njcn.rdms.module.system.api.dict.DictDataApi; import com.njcn.rdms.module.system.api.user.AdminUserApi; import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; @@ -118,6 +121,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { private ProjectRequirementService projectRequirementService; @Resource private ProjectRequirementMapper projectRequirementMapper; + @Resource + private StatusActionTextResolver statusActionTextResolver; /** * 任务服务:执行 cancel / pause / resume 时级联调任务侧 internal 入口。 * 与 ProjectTaskService 互相依赖(任务侧已注入 ProjectExecutionService 用于通知执行),用 @Lazy 打破循环。 @@ -302,6 +307,91 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { return list; } + @Override + public PageResult getMyExecutionPage(MyProjectExecutionPageReqVO reqVO) { + Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); + List executions = projectExecutionMapper.selectListByOwnerId(loginUserId); + if (executions == null || executions.isEmpty()) { + return new PageResult<>(Collections.emptyList(), 0L); + } + // 1. 排除终态状态(completed/cancelled,DB 权威,不硬编码) + List terminalStatusCodes = + objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE); + List nonTerminal = executions.stream() + .filter(e -> terminalStatusCodes == null || !terminalStatusCodes.contains(e.getStatusCode())) + .collect(Collectors.toList()); + if (nonTerminal.isEmpty()) { + return new PageResult<>(Collections.emptyList(), 0L); + } + // 2. 按 projectId 分组,逐组聚合一级任务真实进度(复用 loadExecutionProgressMap) + List excludedTaskStatusCodes = loadProgressExcludedTaskStatusCodes(); + Map progressMap = new HashMap<>(); + nonTerminal.stream() + .filter(e -> e.getProjectId() != null) + .collect(Collectors.groupingBy(ProjectExecutionDO::getProjectId, LinkedHashMap::new, Collectors.toList())) + .forEach((groupProjectId, groupList) -> { + Set executionIds = groupList.stream() + .map(ProjectExecutionDO::getId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + progressMap.putAll(loadExecutionProgressMap(groupProjectId, executionIds, excludedTaskStatusCodes)); + }); + // 3. 排除进度已满(progressRate >= 100);缺失进度按 0 处理 + BigDecimal full = BigDecimal.valueOf(100); + List filtered = nonTerminal.stream() + .filter(e -> progressMap.getOrDefault(e.getId(), normalizeProgress(null)).compareTo(full) < 0) + .collect(Collectors.toList()); + if (filtered.isEmpty()) { + return new PageResult<>(Collections.emptyList(), 0L); + } + // 4. 批量回填 projectName / statusName / projectRequirementName + Set projectIds = filtered.stream() + .map(ProjectExecutionDO::getProjectId).filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Map projectNameMap = projectIds.isEmpty() ? Collections.emptyMap() + : projectMapper.selectBatchIds(projectIds).stream() + .collect(Collectors.toMap(ProjectDO::getId, ProjectDO::getProjectName, (a, b) -> a)); + Map statusNameMap = objectStatusModelMapper + .selectListByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE).stream() + .collect(Collectors.toMap(ObjectStatusModelDO::getStatusCode, ObjectStatusModelDO::getStatusName, (a, b) -> a)); + Set requirementIds = filtered.stream() + .map(ProjectExecutionDO::getProjectRequirementId).filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Map requirementNameMap = requirementIds.isEmpty() ? Collections.emptyMap() + : projectRequirementMapper.selectBatchIds(requirementIds).stream() + .collect(Collectors.toMap(ProjectRequirementDO::getId, ProjectRequirementDO::getTitle, (a, b) -> a)); + // 5. 组装精简 VO(progressRate BigDecimal → Integer 四舍五入) + List all = filtered.stream().map(e -> { + MyProjectExecutionRespVO vo = new MyProjectExecutionRespVO(); + vo.setId(e.getId()); + vo.setExecutionName(e.getExecutionName()); + vo.setProjectId(e.getProjectId()); + vo.setProjectName(projectNameMap.get(e.getProjectId())); + vo.setStatusCode(e.getStatusCode()); + vo.setStatusName(statusNameMap.get(e.getStatusCode())); + vo.setPriority(e.getPriority()); + vo.setPlannedStartDate(e.getPlannedStartDate()); + vo.setPlannedEndDate(e.getPlannedEndDate()); + vo.setActualStartDate(e.getActualStartDate()); + vo.setActualEndDate(e.getActualEndDate()); + BigDecimal progress = progressMap.getOrDefault(e.getId(), normalizeProgress(null)); + vo.setProgressRate(progress.setScale(0, RoundingMode.HALF_UP).intValue()); + vo.setProjectRequirementId(e.getProjectRequirementId()); + vo.setProjectRequirementName(requirementNameMap.get(e.getProjectRequirementId())); + return vo; + }).collect(Collectors.toList()); + // 6. 分页:pageSize<0(PAGE_SIZE_NONE)= 全部;否则内存切片(兼容,前端本期不使用) + long total = all.size(); + Integer pageSize = reqVO.getPageSize(); + if (pageSize == null || pageSize < 0) { + return new PageResult<>(all, total); + } + int pageNo = reqVO.getPageNo() == null || reqVO.getPageNo() < 1 ? 1 : reqVO.getPageNo(); + int fromIndex = Math.min((pageNo - 1) * pageSize, all.size()); + int toIndex = Math.min(fromIndex + pageSize, all.size()); + return new PageResult<>(all.subList(fromIndex, toIndex), total); + } + @Override @Transactional(rollbackFor = Exception.class) @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @@ -489,10 +579,12 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { ObjectStatusTransitionDO transition = objectStatusTransitionMapper .selectByObjectTypeAndFromStatusAndAction(ProjectExecutionConstants.OBJECT_TYPE, fromStatusCode, actionCode); if (transition == null) { - throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED, actionCode); + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED, + statusActionTextResolver.statusName(ProjectExecutionConstants.OBJECT_TYPE, fromStatusCode), + statusActionTextResolver.actionName(ProjectExecutionConstants.OBJECT_TYPE, actionCode)); } if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) { - throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_REASON_REQUIRED, transition.getActionCode()); + throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_REASON_REQUIRED, transition.getActionName()); } return transition; } @@ -1017,7 +1109,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); if (!Objects.equals(loginUserId, execution.getOwnerId())) { throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_OWNER_ONLY, - resolveActionDisplayName(actionCode)); + statusActionTextResolver.actionName(ProjectExecutionConstants.OBJECT_TYPE, actionCode)); } } @@ -1028,16 +1120,6 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { || "resume".equals(actionCode); } - private String resolveActionDisplayName(String actionCode) { - return switch (actionCode) { - case "complete" -> "完成"; - case "cancel" -> "取消"; - case "pause" -> "暂停"; - case "resume" -> "恢复"; - default -> actionCode; - }; - } - /** * 完成执行前置校验:执行下所有任务必须已经进入终态(completed / cancelled)。 */ diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java index 51b1d3c..56855ee 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java @@ -47,6 +47,7 @@ import com.njcn.rdms.module.project.service.project.ProjectService; import com.njcn.rdms.module.project.service.project.execution.ProjectExecutionService; import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeService; import com.njcn.rdms.module.project.service.project.task.worklog.TaskWorklogService; +import com.njcn.rdms.module.project.service.status.StatusActionTextResolver; import com.google.common.annotations.VisibleForTesting; import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants; import com.njcn.rdms.module.system.api.dict.DictDataApi; @@ -127,6 +128,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { private ProjectObjectAuthorizationService projectObjectAuthorizationService; @Resource private DictDataApi dictDataApi; + @Resource + private StatusActionTextResolver statusActionTextResolver; @Override @Transactional(rollbackFor = Exception.class) @@ -673,7 +676,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); if (!Objects.equals(loginUserId, task.getOwnerId())) { throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_OWNER_ONLY, - resolveActionDisplayName(actionCode)); + statusActionTextResolver.actionName(ProjectTaskConstants.OBJECT_TYPE, actionCode)); } } @@ -684,16 +687,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { || "resume".equals(actionCode); } - private String resolveActionDisplayName(String actionCode) { - return switch (actionCode) { - case "complete" -> "完成"; - case "cancel" -> "取消"; - case "pause" -> "暂停"; - case "resume" -> "恢复"; - default -> actionCode; - }; - } - /** * 根据通用状态语义位推导实际开始/结束日期: * - 首次离开初始态(fromStatus.initialFlag=true)且未填写时,写入 actualStartDate @@ -828,10 +821,12 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { ObjectStatusTransitionDO transition = objectStatusTransitionMapper .selectByObjectTypeAndFromStatusAndAction(ProjectTaskConstants.OBJECT_TYPE, fromStatusCode, actionCode); if (transition == null) { - throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_ACTION_NOT_ALLOWED, actionCode); + throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_ACTION_NOT_ALLOWED, + statusActionTextResolver.statusName(ProjectTaskConstants.OBJECT_TYPE, fromStatusCode), + statusActionTextResolver.actionName(ProjectTaskConstants.OBJECT_TYPE, actionCode)); } if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) { - throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_ACTION_REASON_REQUIRED, transition.getActionCode()); + throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_ACTION_REASON_REQUIRED, transition.getActionName()); } return transition; } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/assignee/TaskAssigneeServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/assignee/TaskAssigneeServiceImpl.java index 2be5e67..6782da5 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/assignee/TaskAssigneeServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/assignee/TaskAssigneeServiceImpl.java @@ -69,6 +69,8 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService { private AdminUserApi adminUserApi; @Override + @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", + permission = ProjectTaskConstants.PERMISSION_QUERY) public List getAssigneeList(Long projectId, Long executionId, Long taskId) { validateExecutionAndTaskExists(projectId, executionId, taskId); List activeList = taskAssigneeMapper.selectActiveListByTaskId(taskId); @@ -121,6 +123,8 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService { } @Override + @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", + permission = ProjectTaskConstants.PERMISSION_QUERY) public PageResult getAssigneeLogPage(Long projectId, Long executionId, Long taskId, TaskAssigneeLogPageReqVO reqVO) { validateExecutionAndTaskExists(projectId, executionId, taskId); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImpl.java index f1e6859..e74b1e1 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/worklog/TaskWorklogServiceImpl.java @@ -79,6 +79,8 @@ public class TaskWorklogServiceImpl implements TaskWorklogService { private ProjectTaskService projectTaskService; @Override + @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", + permission = ProjectTaskConstants.PERMISSION_QUERY) public PageResult getWorklogPage(Long projectId, Long executionId, Long taskId, TaskWorklogPageReqVO reqVO) { validateExecutionAndTaskExists(projectId, executionId, taskId); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/status/StatusActionTextResolver.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/status/StatusActionTextResolver.java new file mode 100644 index 0000000..553cf65 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/status/StatusActionTextResolver.java @@ -0,0 +1,41 @@ +package com.njcn.rdms.module.project.service.status; + +import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO; +import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; +import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** + * 状态机文案解析器:把动作 code / 状态 code 翻成 DB 状态机里的中文展示名, + * 供错误提示等用户可见文案使用。查不到时回退原 code(不抛错)。 + * 权威源:rdms_object_status_transition.action_name / rdms_object_status_model.status_name。 + * 背景:TD-012(给用户看的 message 不外泄技术 token;技术诊断由 infra_api_access_log 承载)。 + */ +@Component +public class StatusActionTextResolver { + + @Resource + private ObjectStatusTransitionMapper transitionMapper; + @Resource + private ObjectStatusModelMapper statusModelMapper; + + /** 动作中文名;空入参或查不到时回退原 actionCode。 */ + public String actionName(String objectType, String actionCode) { + if (!StringUtils.hasText(actionCode)) { + return actionCode; + } + String name = transitionMapper.selectActionNameByObjectTypeAndAction(objectType, actionCode); + return StringUtils.hasText(name) ? name : actionCode; + } + + /** 状态中文名;空入参或查不到时回退原 statusCode。 */ + public String statusName(String objectType, String statusCode) { + if (!StringUtils.hasText(statusCode)) { + return statusCode; + } + ObjectStatusModelDO model = statusModelMapper.selectByObjectTypeAndStatusCode(objectType, statusCode); + return model != null && StringUtils.hasText(model.getStatusName()) ? model.getStatusName() : statusCode; + } +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/aop/AssigneeWorklogReadPermissionTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/aop/AssigneeWorklogReadPermissionTest.java new file mode 100644 index 0000000..674f0c7 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/aop/AssigneeWorklogReadPermissionTest.java @@ -0,0 +1,128 @@ +package com.njcn.rdms.module.project.framework.security.aop; + +import com.njcn.rdms.framework.common.exception.ServiceException; +import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; +import com.njcn.rdms.module.project.constant.ProjectExecutionConstants; +import com.njcn.rdms.module.project.constant.ProjectObjectConstants; +import com.njcn.rdms.module.project.constant.ProjectTaskConstants; +import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeLogPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogPageReqVO; +import com.njcn.rdms.module.project.enums.ErrorCodeConstants; +import com.njcn.rdms.module.project.framework.security.service.ObjectPermissionService; +import com.njcn.rdms.module.project.service.project.execution.ProjectExecutionAssigneeService; +import com.njcn.rdms.module.project.service.project.execution.ProjectExecutionAssigneeServiceImpl; +import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeService; +import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeServiceImpl; +import com.njcn.rdms.module.project.service.project.task.worklog.TaskWorklogService; +import com.njcn.rdms.module.project.service.project.task.worklog.TaskWorklogServiceImpl; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; + +import java.util.List; + +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 对象域内「协办人 / 工时」读路径的对象级鉴权回归测试(TD-001)。 + *

+ * 验证执行协办人、任务协办人、任务工时这 5 个读接口都已挂 + * {@link com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission},且: + *

    + *
  • {@code objectId="#projectId"} 解析为 projectId(而非 executionId/taskId);
  • + *
  • permission 为同层 QUERY 码(执行域 project:execution:query、任务域 project:task:query);
  • + *
  • 无权(checkPermission 抛异常)时方法体被拦、不执行。
  • + *
+ * 沿用 {@link ObjectPermissionAspectTest} 的 {@link AspectJProxyFactory} 手动织入方式,纯单测、不连库、不起容器。 + * 真实的「有无权限」判定逻辑由 ProjectObjectPermissionServiceTest 覆盖,本类不重复。 + */ +class AssigneeWorklogReadPermissionTest extends BaseMockitoUnitTest { + + private static final Long PROJECT_ID = 1001L; + private static final Long EXECUTION_ID = 2001L; + private static final Long TASK_ID = 3001L; + + @Mock + private ObjectPermissionService objectPermissionService; + + @Test + void getExecutionAssigneeList_shouldGuardByExecutionQueryOnProjectId() { + ProjectExecutionAssigneeService proxy = + (ProjectExecutionAssigneeService) proxyOf(new ProjectExecutionAssigneeServiceImpl()); + denyPermission(ProjectExecutionConstants.PERMISSION_QUERY); + + assertThrows(ServiceException.class, () -> proxy.getExecutionAssigneeList(PROJECT_ID, EXECUTION_ID)); + + verify(objectPermissionService).checkPermission(PROJECT_ID, ProjectExecutionConstants.PERMISSION_QUERY, false); + } + + @Test + void getExecutionAssigneeLogPage_shouldGuardByExecutionQueryOnProjectId() { + ProjectExecutionAssigneeService proxy = + (ProjectExecutionAssigneeService) proxyOf(new ProjectExecutionAssigneeServiceImpl()); + denyPermission(ProjectExecutionConstants.PERMISSION_QUERY); + + assertThrows(ServiceException.class, + () -> proxy.getExecutionAssigneeLogPage(PROJECT_ID, EXECUTION_ID, new ExecutionAssigneeLogPageReqVO())); + + verify(objectPermissionService).checkPermission(PROJECT_ID, ProjectExecutionConstants.PERMISSION_QUERY, false); + } + + @Test + void getAssigneeList_shouldGuardByTaskQueryOnProjectId() { + TaskAssigneeService proxy = (TaskAssigneeService) proxyOf(new TaskAssigneeServiceImpl()); + denyPermission(ProjectTaskConstants.PERMISSION_QUERY); + + assertThrows(ServiceException.class, () -> proxy.getAssigneeList(PROJECT_ID, EXECUTION_ID, TASK_ID)); + + verify(objectPermissionService).checkPermission(PROJECT_ID, ProjectTaskConstants.PERMISSION_QUERY, false); + } + + @Test + void getAssigneeLogPage_shouldGuardByTaskQueryOnProjectId() { + TaskAssigneeService proxy = (TaskAssigneeService) proxyOf(new TaskAssigneeServiceImpl()); + denyPermission(ProjectTaskConstants.PERMISSION_QUERY); + + assertThrows(ServiceException.class, + () -> proxy.getAssigneeLogPage(PROJECT_ID, EXECUTION_ID, TASK_ID, new TaskAssigneeLogPageReqVO())); + + verify(objectPermissionService).checkPermission(PROJECT_ID, ProjectTaskConstants.PERMISSION_QUERY, false); + } + + @Test + void getWorklogPage_shouldGuardByTaskQueryOnProjectId() { + TaskWorklogService proxy = (TaskWorklogService) proxyOf(new TaskWorklogServiceImpl()); + denyPermission(ProjectTaskConstants.PERMISSION_QUERY); + + assertThrows(ServiceException.class, + () -> proxy.getWorklogPage(PROJECT_ID, EXECUTION_ID, TASK_ID, new TaskWorklogPageReqVO())); + + verify(objectPermissionService).checkPermission(PROJECT_ID, ProjectTaskConstants.PERMISSION_QUERY, false); + } + + /** + * 用 AspectJProxyFactory 把对象权限切面织到真实 Service 上(JDK 代理,返回接口)。 + * target 为无依赖 new 出的实例:无权时切面在 proceed() 之前拦截,方法体不执行,故依赖为 null 无碍。 + */ + private Object proxyOf(Object target) { + when(objectPermissionService.getObjectType()).thenReturn(ProjectObjectConstants.OBJECT_TYPE); + AspectJProxyFactory proxyFactory = new AspectJProxyFactory(target); + proxyFactory.addAspect(new ObjectPermissionAspect(List.of(objectPermissionService))); + return proxyFactory.getProxy(); + } + + /** + * 模拟「无权」:让对象权限校验抛出权限不足异常(与 ProjectObjectPermissionService 无权时一致)。 + */ + private void denyPermission(String permission) { + doThrow(exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED)) + .when(objectPermissionService).checkPermission(eq(PROJECT_ID), eq(permission), eq(false)); + } + +} 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 index ff5ea0a..bea5284 100644 --- 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 @@ -8,6 +8,10 @@ import org.mockito.Mock; import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -43,6 +47,19 @@ class ObjectPermissionAspectTest extends BaseMockitoUnitTest { .checkPermission(1002L, "", true); } + @Test + void around_whenAccessible_shouldDispatchToCheckAccessible() { + when(objectPermissionService.getObjectType()).thenReturn("product"); + DemoService proxy = createProxy(); + + String result = proxy.getProductAccessible(1003L); + + assertEquals("accessible", result); + verify(objectPermissionService, times(1)).checkAccessible(1003L); + // accessible 模式不应再走权限码校验 + verify(objectPermissionService, never()).checkPermission(anyLong(), anyString(), anyBoolean()); + } + private DemoService createProxy() { AspectJProxyFactory proxyFactory = new AspectJProxyFactory(new DemoService()); proxyFactory.addAspect(new ObjectPermissionAspect(java.util.List.of(objectPermissionService))); @@ -61,6 +78,11 @@ class ObjectPermissionAspectTest extends BaseMockitoUnitTest { return "context"; } + @CheckObjectPermission(objectType = "product", objectId = "#productId", accessible = true) + public String getProductAccessible(Long productId) { + return "accessible"; + } + } static class DemoReqVO { 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 index 53005ff..52450b0 100644 --- 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 @@ -4,8 +4,12 @@ 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.product.ProductDO; import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper; +import com.njcn.rdms.module.project.dal.mysql.product.ProductMapper; import com.njcn.rdms.module.project.enums.ErrorCodeConstants; +import com.njcn.rdms.module.project.service.datascope.ObjectDataScope; +import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService; import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; import com.njcn.rdms.module.system.enums.permission.PermissionScopeTypeEnum; import org.junit.jupiter.api.Test; @@ -20,6 +24,7 @@ import java.util.Set; import static com.njcn.rdms.framework.common.pojo.CommonResult.success; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verifyNoInteractions; @@ -33,6 +38,10 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest { private UserObjectRoleMapper userObjectRoleMapper; @Mock private ObjectPermissionApi objectPermissionApi; + @Mock + private ProductMapper productMapper; + @Mock + private ObjectDataScopeService objectDataScopeService; @Test void checkPermission_whenMemberOnlyAndCurrentUserIsMember_shouldPass() { @@ -77,6 +86,8 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest { ServiceException ex = assertThrows(ServiceException.class, () -> permissionService.checkPermission(productId, "project:product:delete", false)); assertEquals(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode()); + // 用户可见 message 不得外泄权限码(技术细节走 log.warn) + assertFalse(ex.getMessage().contains(":"), "message 不应外泄权限码"); } } @@ -91,11 +102,115 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest { ServiceException ex = assertThrows(ServiceException.class, () -> permissionService.checkPermission(productId, "project:product:query", false)); assertEquals(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode()); + assertFalse(ex.getMessage().contains(":"), "message 不应外泄权限码"); } verifyNoInteractions(objectPermissionApi); } + @Test + void checkPermission_whenMemberOnlyAndNotMember_shouldThrowWithoutLeak() { + Long productId = 1005L; + Long loginUserId = 2005L; + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId)) + .thenReturn(Collections.emptyList()); + + try (MockedStatic mockedStatic = mockLoginUser(loginUserId)) { + ServiceException ex = assertThrows(ServiceException.class, + () -> permissionService.checkPermission(productId, "project:product:query", true)); + assertEquals(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode()); + // memberOnly 分支原先会外泄字面 member 标记,现统一为友好文案 + assertFalse(ex.getMessage().contains("member"), "message 不应外泄 member 标记"); + assertFalse(ex.getMessage().contains(":"), "message 不应外泄权限码"); + } + } + + @Test + void checkAccessible_whenActiveMember_shouldPass() { + Long productId = 1101L; + Long loginUserId = 2101L; + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId)) + .thenReturn(List.of(createMember(productId, loginUserId, 3101L))); + + try (MockedStatic mockedStatic = mockLoginUser(loginUserId)) { + assertDoesNotThrow(() -> permissionService.checkAccessible(productId)); + } + verifyNoInteractions(productMapper, objectDataScopeService); + } + + @Test + void checkAccessible_whenImplicitObserverInScope_shouldPass() { + Long productId = 1102L; + Long loginUserId = 2102L; + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId)) + .thenReturn(Collections.emptyList()); + ProductDO product = new ProductDO(); + product.setId(productId); + product.setDirectionCode("D-NET"); + when(productMapper.selectById(productId)).thenReturn(product); + when(objectDataScopeService.compute(loginUserId, "product")) + .thenReturn(ObjectDataScope.idList(Collections.emptySet(), Set.of("D-NET"))); + + try (MockedStatic mockedStatic = mockLoginUser(loginUserId)) { + assertDoesNotThrow(() -> permissionService.checkAccessible(productId)); + } + } + + @Test + void checkAccessible_whenSuperAdminScopeAll_shouldPass() { + Long productId = 1103L; + Long loginUserId = 2103L; + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId)) + .thenReturn(Collections.emptyList()); + ProductDO product = new ProductDO(); + product.setId(productId); + product.setDirectionCode("D-NET"); + when(productMapper.selectById(productId)).thenReturn(product); + when(objectDataScopeService.compute(loginUserId, "product")) + .thenReturn(ObjectDataScope.all()); + + try (MockedStatic mockedStatic = mockLoginUser(loginUserId)) { + assertDoesNotThrow(() -> permissionService.checkAccessible(productId)); + } + } + + @Test + void checkAccessible_whenNeitherMemberNorScope_shouldThrowDenied() { + Long productId = 1104L; + Long loginUserId = 2104L; + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId)) + .thenReturn(Collections.emptyList()); + ProductDO product = new ProductDO(); + product.setId(productId); + product.setDirectionCode("D-NET"); + when(productMapper.selectById(productId)).thenReturn(product); + when(objectDataScopeService.compute(loginUserId, "product")) + .thenReturn(ObjectDataScope.empty()); + + try (MockedStatic mockedStatic = mockLoginUser(loginUserId)) { + ServiceException ex = assertThrows(ServiceException.class, + () -> permissionService.checkAccessible(productId)); + assertEquals(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode()); + assertFalse(ex.getMessage().contains(":"), "message 不应外泄权限码"); + } + } + + @Test + void checkAccessible_whenObjectNotExists_shouldThrowDenied() { + Long productId = 1105L; + Long loginUserId = 2105L; + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId)) + .thenReturn(Collections.emptyList()); + when(productMapper.selectById(productId)).thenReturn(null); + + try (MockedStatic mockedStatic = mockLoginUser(loginUserId)) { + ServiceException ex = assertThrows(ServiceException.class, + () -> permissionService.checkAccessible(productId)); + assertEquals(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode()); + } + verifyNoInteractions(objectDataScopeService); + } + private UserObjectRoleDO createMember(Long productId, Long loginUserId, Long roleId) { UserObjectRoleDO member = new UserObjectRoleDO(); member.setId(9001L); diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionServiceTest.java index fe8d419..189105b 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionServiceTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionServiceTest.java @@ -4,8 +4,12 @@ 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.project.ProjectDO; import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper; import com.njcn.rdms.module.project.enums.ErrorCodeConstants; +import com.njcn.rdms.module.project.service.datascope.ObjectDataScope; +import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService; import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; import com.njcn.rdms.module.system.enums.permission.PermissionScopeTypeEnum; import org.junit.jupiter.api.Test; @@ -20,6 +24,7 @@ import java.util.Set; import static com.njcn.rdms.framework.common.pojo.CommonResult.success; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verifyNoInteractions; @@ -33,6 +38,10 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest { private UserObjectRoleMapper userObjectRoleMapper; @Mock private ObjectPermissionApi objectPermissionApi; + @Mock + private ProjectMapper projectMapper; + @Mock + private ObjectDataScopeService objectDataScopeService; @Test void checkPermission_whenMemberOnlyAndActiveMember_shouldPass() { @@ -59,6 +68,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest { ServiceException ex = assertThrows(ServiceException.class, () -> permissionService.checkPermission(projectId, "project:project:update", false)); assertEquals(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode()); + // 用户可见 message 不得外泄权限码(技术细节走 log.warn) + assertFalse(ex.getMessage().contains(":"), "message 不应外泄权限码"); } } @@ -91,9 +102,117 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest { ServiceException ex = assertThrows(ServiceException.class, () -> permissionService.checkPermission(projectId, "project:project:delete", false)); assertEquals(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode()); + assertFalse(ex.getMessage().contains(":"), "message 不应外泄权限码"); } } + @Test + void checkPermission_whenMemberOnlyAndNoActiveMember_shouldThrowWithoutLeak() { + Long projectId = 1005L; + Long loginUserId = 2005L; + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId)) + .thenReturn(Collections.emptyList()); + + try (MockedStatic mockedStatic = mockLoginUser(loginUserId)) { + ServiceException ex = assertThrows(ServiceException.class, + () -> permissionService.checkPermission(projectId, "project:project:query", true)); + assertEquals(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode()); + // memberOnly 分支原先会外泄字面 member 标记,现统一为友好文案 + assertFalse(ex.getMessage().contains("member"), "message 不应外泄 member 标记"); + assertFalse(ex.getMessage().contains(":"), "message 不应外泄权限码"); + } + } + + @Test + void checkAccessible_whenActiveMember_shouldPass() { + Long projectId = 1101L; + Long loginUserId = 2101L; + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId)) + .thenReturn(List.of(createMember(projectId, loginUserId, 3101L))); + + try (MockedStatic mockedStatic = mockLoginUser(loginUserId)) { + assertDoesNotThrow(() -> permissionService.checkAccessible(projectId)); + } + // 显式成员提前放行,不触达对象查询与数据范围计算 + verifyNoInteractions(projectMapper, objectDataScopeService); + } + + @Test + void checkAccessible_whenImplicitObserverInScope_shouldPass() { + Long projectId = 1102L; + Long loginUserId = 2102L; + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId)) + .thenReturn(Collections.emptyList()); + ProjectDO project = new ProjectDO(); + project.setId(projectId); + project.setDirectionCode("D-NET"); + when(projectMapper.selectById(projectId)).thenReturn(project); + // 无显式角色,但方向 scope 命中 → 隐式 observer 放行 + when(objectDataScopeService.compute(loginUserId, "project")) + .thenReturn(ObjectDataScope.idList(Collections.emptySet(), Set.of("D-NET"))); + + try (MockedStatic mockedStatic = mockLoginUser(loginUserId)) { + assertDoesNotThrow(() -> permissionService.checkAccessible(projectId)); + } + } + + @Test + void checkAccessible_whenSuperAdminScopeAll_shouldPass() { + Long projectId = 1103L; + Long loginUserId = 2103L; + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId)) + .thenReturn(Collections.emptyList()); + ProjectDO project = new ProjectDO(); + project.setId(projectId); + project.setDirectionCode("D-NET"); + when(projectMapper.selectById(projectId)).thenReturn(project); + when(objectDataScopeService.compute(loginUserId, "project")) + .thenReturn(ObjectDataScope.all()); + + try (MockedStatic mockedStatic = mockLoginUser(loginUserId)) { + assertDoesNotThrow(() -> permissionService.checkAccessible(projectId)); + } + } + + @Test + void checkAccessible_whenNeitherMemberNorScope_shouldThrowDenied() { + Long projectId = 1104L; + Long loginUserId = 2104L; + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId)) + .thenReturn(Collections.emptyList()); + ProjectDO project = new ProjectDO(); + project.setId(projectId); + project.setDirectionCode("D-NET"); + when(projectMapper.selectById(projectId)).thenReturn(project); + when(objectDataScopeService.compute(loginUserId, "project")) + .thenReturn(ObjectDataScope.empty()); + + try (MockedStatic mockedStatic = mockLoginUser(loginUserId)) { + ServiceException ex = assertThrows(ServiceException.class, + () -> permissionService.checkAccessible(projectId)); + assertEquals(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode()); + assertFalse(ex.getMessage().contains(":"), "message 不应外泄权限码"); + } + } + + @Test + void checkAccessible_whenObjectNotExists_shouldThrowDenied() { + Long projectId = 1105L; + Long loginUserId = 2105L; + when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId)) + .thenReturn(Collections.emptyList()); + when(projectMapper.selectById(projectId)).thenReturn(null); + + try (MockedStatic mockedStatic = mockLoginUser(loginUserId)) { + ServiceException ex = assertThrows(ServiceException.class, + () -> permissionService.checkAccessible(projectId)); + // spec §3.3 定稿:对象不存在也返回 DENIED,不暴露存在性 + assertEquals(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode()); + } + // 对象不存在即终止,不再计算数据范围 + verifyNoInteractions(objectDataScopeService); + } + private UserObjectRoleDO createMember(Long projectId, Long loginUserId, Long roleId) { UserObjectRoleDO member = new UserObjectRoleDO(); member.setId(9001L); diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/overtime/OvertimeApplicationServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/overtime/OvertimeApplicationServiceImplTest.java new file mode 100644 index 0000000..7e8dd22 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/overtime/OvertimeApplicationServiceImplTest.java @@ -0,0 +1,121 @@ +package com.njcn.rdms.module.project.service.overtime; + +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.constant.OvertimeApplicationConstants; +import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationStatusActionReqVO; +import com.njcn.rdms.module.project.dal.dataobject.overtime.OvertimeApplicationDO; +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.overtime.OvertimeApplicationMapper; +import com.njcn.rdms.module.project.dal.mysql.overtime.OvertimeApplicationStatusLogMapper; +import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; +import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper; +import com.njcn.rdms.module.project.enums.ErrorCodeConstants; +import com.njcn.rdms.module.project.service.status.StatusActionTextResolver; +import com.njcn.rdms.module.system.api.user.AdminUserApi; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +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.mockStatic; +import static org.mockito.Mockito.when; + +/** + * 加班申请状态机错误文案单测(TD-012)。 + * 验证:状态机「动作不允许 / 缺原因」抛错时,message 填的是 {@link StatusActionTextResolver} 翻出的中文名, + * 不外泄英文动作 / 状态 code(技术 token 只进 log,不进用户可见 message)。 + * resolver 自身的 DB 翻译由 StatusActionTextResolverTest 覆盖,这里只验证 service 填进 message 的是中文名。 + */ +class OvertimeApplicationServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private OvertimeApplicationServiceImpl overtimeApplicationService; + + @Mock + private OvertimeApplicationMapper overtimeApplicationMapper; + @Mock + private OvertimeApplicationStatusLogMapper overtimeApplicationStatusLogMapper; + @Mock + private BizAuditLogMapper bizAuditLogMapper; + @Mock + private ObjectStatusModelMapper objectStatusModelMapper; + @Mock + private ObjectStatusTransitionMapper objectStatusTransitionMapper; + @Mock + private StatusActionTextResolver statusActionTextResolver; + @Mock + private AdminUserApi adminUserApi; + + private static final String OBJECT_TYPE = OvertimeApplicationConstants.STATUS_OBJECT_TYPE; + + @Test + void approve_whenTransitionNotAllowed_shouldThrowWithChineseNameNoCodeLeak() { + Long id = 1001L; + Long loginUserId = 2001L; + OvertimeApplicationDO application = new OvertimeApplicationDO(); + application.setId(id); + application.setApproverId(loginUserId); + application.setStatusCode(OvertimeApplicationConstants.STATUS_APPROVED); + when(overtimeApplicationMapper.selectById(id)).thenReturn(application); + // 当前状态 + 动作在状态机表查不到流转 → 不允许 + when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction( + OBJECT_TYPE, OvertimeApplicationConstants.STATUS_APPROVED, OvertimeApplicationConstants.ACTION_APPROVE)) + .thenReturn(null); + when(statusActionTextResolver.statusName(OBJECT_TYPE, OvertimeApplicationConstants.STATUS_APPROVED)) + .thenReturn("已通过"); + when(statusActionTextResolver.actionName(OBJECT_TYPE, OvertimeApplicationConstants.ACTION_APPROVE)) + .thenReturn("通过"); + + try (MockedStatic mocked = mockLoginUser(loginUserId)) { + ServiceException ex = assertThrows(ServiceException.class, + () -> overtimeApplicationService.approve(id, new OvertimeApplicationStatusActionReqVO())); + assertEquals(ErrorCodeConstants.OVERTIME_APPLICATION_STATUS_ACTION_NOT_ALLOWED.getCode(), ex.getCode()); + // message 用中文名 + assertTrue(ex.getMessage().contains("已通过"), "message 应含状态中文名"); + assertTrue(ex.getMessage().contains("通过"), "message 应含动作中文名"); + // 不外泄英文动作 / 状态 code + assertFalse(ex.getMessage().contains(OvertimeApplicationConstants.ACTION_APPROVE), "message 不应外泄英文动作 code"); + assertFalse(ex.getMessage().contains(OvertimeApplicationConstants.STATUS_APPROVED), "message 不应外泄英文状态 code"); + } + } + + @Test + void approve_whenReasonRequiredButBlank_shouldThrowWithChineseActionName() { + Long id = 1002L; + Long loginUserId = 2002L; + OvertimeApplicationDO application = new OvertimeApplicationDO(); + application.setId(id); + application.setApproverId(loginUserId); + application.setStatusCode(OvertimeApplicationConstants.STATUS_PENDING); + when(overtimeApplicationMapper.selectById(id)).thenReturn(application); + // 流转存在但要求填原因,而入参未带原因 → REASON_REQUIRED + ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO(); + transition.setNeedReason(Boolean.TRUE); + when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction( + OBJECT_TYPE, OvertimeApplicationConstants.STATUS_PENDING, OvertimeApplicationConstants.ACTION_APPROVE)) + .thenReturn(transition); + when(statusActionTextResolver.actionName(OBJECT_TYPE, OvertimeApplicationConstants.ACTION_APPROVE)) + .thenReturn("通过"); + + try (MockedStatic mocked = mockLoginUser(loginUserId)) { + ServiceException ex = assertThrows(ServiceException.class, + () -> overtimeApplicationService.approve(id, new OvertimeApplicationStatusActionReqVO())); + assertEquals(ErrorCodeConstants.OVERTIME_APPLICATION_STATUS_ACTION_REASON_REQUIRED.getCode(), ex.getCode()); + assertTrue(ex.getMessage().contains("通过"), "message 应含动作中文名"); + assertFalse(ex.getMessage().contains(OvertimeApplicationConstants.ACTION_APPROVE), "message 不应外泄英文动作 code"); + } + } + + private MockedStatic mockLoginUser(Long loginUserId) { + MockedStatic mocked = mockStatic(SecurityFrameworkUtils.class); + mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId); + return mocked; + } +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductRequirementServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductRequirementServiceImplTest.java index 418eede..e03dd70 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductRequirementServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductRequirementServiceImplTest.java @@ -51,6 +51,8 @@ class ProductRequirementServiceImplTest extends BaseMockitoUnitTest { private ObjectStatusTransitionMapper statusTransitionMapper; @Mock private ObjectStatusModelMapper statusModelMapper; + @Mock + private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver; // ========== 创建需求测试 ========== 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 index 6d49d15..5b081b1 100644 --- 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 @@ -84,6 +84,8 @@ class ProductServiceImplTest extends BaseMockitoUnitTest { private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService; @Mock private ObjectDataScopeService objectDataScopeService; + @Mock + private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver; @Test void createProduct_shouldCreateDefaultRequirementModule() { diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/MyProjectServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/MyProjectServiceImplTest.java new file mode 100644 index 0000000..ed88a8d --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/MyProjectServiceImplTest.java @@ -0,0 +1,259 @@ +package com.njcn.rdms.module.project.service.project; + +import com.njcn.rdms.framework.common.pojo.PageParam; +import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; +import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; +import com.njcn.rdms.module.project.constant.ProjectObjectConstants; +import com.njcn.rdms.module.project.constant.ProjectTaskConstants; +import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectParticipatedRespVO; +import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO; +import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; +import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO; +import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper; +import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper; +import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper; +import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper; +import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; +import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; +import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO; +import com.njcn.rdms.module.system.api.user.AdminUserApi; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static com.njcn.rdms.framework.common.pojo.CommonResult.success; +import static java.util.Collections.emptyList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +class MyProjectServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private MyProjectServiceImpl myProjectService; + @Mock + private ProjectMapper projectMapper; + @Mock + private ProjectTaskMapper projectTaskMapper; + @Mock + private ProjectExecutionMapper projectExecutionMapper; + @Mock + private UserObjectRoleMapper userObjectRoleMapper; + @Mock + private ObjectStatusModelMapper objectStatusModelMapper; + @Mock + private ObjectPermissionApi objectPermissionApi; + @Mock + private AdminUserApi adminUserApi; + + private MyProjectPageReqVO allPageReq() { + MyProjectPageReqVO reqVO = new MyProjectPageReqVO(); + reqVO.setPageNo(1); + reqVO.setPageSize(PageParam.PAGE_SIZE_NONE); + return reqVO; + } + + private UserObjectRoleDO role(Long id, Long objectId, Long roleId) { + UserObjectRoleDO r = new UserObjectRoleDO(); + r.setId(id); + r.setObjectType(ProjectObjectConstants.OBJECT_TYPE); + r.setObjectId(objectId); + r.setRoleId(roleId); + r.setStatus(0); + return r; + } + + @Test + void testParticipated_emptyWhenNoRoles() { + try (MockedStatic mock = mockStatic(SecurityFrameworkUtils.class)) { + mock.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(100L); + when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId( + eq(ProjectObjectConstants.OBJECT_TYPE), eq(100L))).thenReturn(emptyList()); + + PageResult result = myProjectService.getMyParticipatedPage(allPageReq()); + + assertEquals(0L, result.getTotal()); + assertEquals(0, result.getList().size()); + } + } + + @Test + void testParticipated_assemblesRoleProgressAndTaskCount() { + try (MockedStatic mock = mockStatic(SecurityFrameworkUtils.class)) { + mock.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(100L); + // 我在项目 2001 有两个角色行:roleId=10(manager) + roleId=20(dev) + when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId( + eq(ProjectObjectConstants.OBJECT_TYPE), eq(100L))) + .thenReturn(List.of(role(1L, 2001L, 10L), role(2L, 2001L, 20L))); + ProjectDO project = new ProjectDO(); + project.setId(2001L); + project.setProjectName("商城 V2 升级"); + project.setProjectCode("MALL-V2"); + project.setStatusCode("active"); + project.setProgressRate(new BigDecimal("69.6")); + when(projectMapper.selectBatchIds(anyCollection())).thenReturn(List.of(project)); + // statusName 回填 + ObjectStatusModelDO sm = new ObjectStatusModelDO(); + sm.setStatusCode("active"); + sm.setStatusName("进行中"); + when(objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectObjectConstants.OBJECT_TYPE)) + .thenReturn(List.of(sm)); + // 角色名:10=项目负责人(manager),20=开发 + ObjectRoleRespDTO manager = new ObjectRoleRespDTO(); + manager.setId(10L); + manager.setCode(ProjectObjectConstants.MANAGER_ROLE_CODE); + manager.setName("项目负责人"); + ObjectRoleRespDTO dev = new ObjectRoleRespDTO(); + dev.setId(20L); + dev.setCode("dev"); + dev.setName("开发"); + when(objectPermissionApi.getObjectRoleList(anyCollection(), any(), any())) + .thenReturn(success(List.of(manager, dev))); + // 终态集 + 任务计数 + when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE)) + .thenReturn(List.of("completed", "cancelled")); + when(projectTaskMapper.selectMyTaskCountGroupByProjectIds(eq(100L), anyCollection(), anyCollection())) + .thenReturn(List.>of(Map.of( + "projectId", 2001L, "totalCount", 8L, "pendingCount", 3L))); + + PageResult result = myProjectService.getMyParticipatedPage(allPageReq()); + + assertEquals(1L, result.getTotal()); + MyProjectParticipatedRespVO vo = result.getList().get(0); + assertEquals(2001L, vo.getId()); + assertEquals("MALL-V2", vo.getCode()); + assertEquals("进行中", vo.getStatusName()); + assertEquals(70, vo.getProgress()); // 69.6 → HALF_UP → 70 + assertEquals("项目负责人 / 开发", vo.getMyRole()); // manager 主 + 附加 + assertEquals(8, vo.getMyTaskCount()); + assertEquals(3, vo.getMyPendingTaskCount()); + } + } + + @Test + void testParticipated_excludesProjectWhenOnlyInvisibleRole() { + try (MockedStatic mock = mockStatic(SecurityFrameworkUtils.class)) { + mock.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(100L); + // 我在项目 2001 只有一个隐式角色 roleId=90(visible=0,如创建者),无任何可见角色 + when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId( + eq(ProjectObjectConstants.OBJECT_TYPE), eq(100L))) + .thenReturn(List.of(role(1L, 2001L, 90L))); + ProjectDO project = new ProjectDO(); + project.setId(2001L); + project.setProjectName("商城 V2 升级"); + project.setProjectCode("MALL-V2"); + project.setStatusCode("active"); + when(projectMapper.selectBatchIds(anyCollection())).thenReturn(List.of(project)); + when(objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectObjectConstants.OBJECT_TYPE)) + .thenReturn(emptyList()); + // 角色 90:创建者,visible=0 + ObjectRoleRespDTO creator = new ObjectRoleRespDTO(); + creator.setId(90L); + creator.setCode("project_creator"); + creator.setName("创建者"); + creator.setVisible(0); + when(objectPermissionApi.getObjectRoleList(anyCollection(), any(), any())) + .thenReturn(success(List.of(creator))); + when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE)) + .thenReturn(List.of("completed", "cancelled")); + when(projectTaskMapper.selectMyTaskCountGroupByProjectIds(eq(100L), anyCollection(), anyCollection())) + .thenReturn(emptyList()); + + PageResult result = myProjectService.getMyParticipatedPage(allPageReq()); + + // 仅隐式角色 → 该项目整项剔除 + assertEquals(0L, result.getTotal()); + assertEquals(0, result.getList().size()); + } + } + + @Test + void testOwned_emptyWhenNoManagedProjects() { + try (MockedStatic mock = mockStatic(SecurityFrameworkUtils.class)) { + mock.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(100L); + when(projectMapper.selectList(any(com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX.class))) + .thenReturn(emptyList()); + + PageResult + result = myProjectService.getMyOwnedPage(allPageReq()); + + assertEquals(0L, result.getTotal()); + assertEquals(0, result.getList().size()); + } + } + + @Test + void testOwned_assemblesCountsAndMembers() { + try (MockedStatic mock = mockStatic(SecurityFrameworkUtils.class)) { + mock.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(100L); + ProjectDO project = new ProjectDO(); + project.setId(2001L); + project.setProjectName("商城 V2 升级"); + project.setProjectCode("MALL-V2"); + project.setProgressRate(new BigDecimal("70.0")); + when(projectMapper.selectList(any(com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX.class))) + .thenReturn(List.of(project)); + when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE)) + .thenReturn(List.of("completed", "cancelled")); + when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled( + com.njcn.rdms.module.project.constant.ProjectExecutionConstants.OBJECT_TYPE)) + .thenReturn(List.of("completed", "cancelled")); + when(projectTaskMapper.selectTaskAndOverdueCountGroupByProjectIds(anyCollection(), anyCollection(), any())) + .thenReturn(List.>of(Map.of( + "projectId", 2001L, "taskCount", 24L, "overdueCount", 2L))); + when(projectExecutionMapper.selectExecutionCountGroupByProjectIds(anyCollection(), anyCollection())) + .thenReturn(List.>of(Map.of("projectId", 2001L, "executionCount", 6L))); + when(projectTaskMapper.selectActiveTaskCountGroupByProjectIdAndOwner(anyCollection(), anyCollection())) + .thenReturn(List.>of( + Map.of("projectId", 2001L, "ownerId", 101L, "activeTaskCount", 6L), + Map.of("projectId", 2001L, "ownerId", 102L, "activeTaskCount", 3L))); + // 成员清单:101 / 102(102 出现两行多角色,去重后仍一个成员) + UserObjectRoleDO m1 = role(11L, 2001L, 10L); + m1.setUserId(101L); + UserObjectRoleDO m2 = role(12L, 2001L, 20L); + m2.setUserId(102L); + UserObjectRoleDO m3 = role(13L, 2001L, 30L); + m3.setUserId(102L); + when(userObjectRoleMapper.selectActiveListByObjectTypeAndObjectIds( + eq(ProjectObjectConstants.OBJECT_TYPE), anyCollection())) + .thenReturn(List.of(m1, m2, m3)); + com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO u101 = + new com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO(); + u101.setId(101L); + u101.setNickname("张三"); + com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO u102 = + new com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO(); + u102.setId(102L); + u102.setNickname("李四"); + when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(101L, u101, 102L, u102)); + ObjectRoleRespDTO manager = new ObjectRoleRespDTO(); + manager.setName("项目负责人"); + when(objectPermissionApi.getObjectRoleByCode(eq(ProjectObjectConstants.MANAGER_ROLE_CODE), any(), any())) + .thenReturn(success(manager)); + + PageResult + result = myProjectService.getMyOwnedPage(allPageReq()); + + assertEquals(1L, result.getTotal()); + var vo = result.getList().get(0); + assertEquals(70, vo.getProgress()); + assertEquals("项目负责人", vo.getMyRole()); + assertEquals(24, vo.getTaskCount()); + assertEquals(2, vo.getOverdueCount()); + assertEquals(6, vo.getExecutionCount()); + assertEquals(2, vo.getMemberCount()); // 101 + 102 去重 + assertEquals(2, vo.getMembers().size()); + assertEquals(6, vo.getMembers().get(0).getActiveTaskCount()); // 101 → 6 + } + } +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectRequirementServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectRequirementServiceImplTest.java index 2285933..518c042 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectRequirementServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectRequirementServiceImplTest.java @@ -58,6 +58,8 @@ class ProjectRequirementServiceImplTest extends BaseMockitoUnitTest { private AttachmentFileIdResolver attachmentFileIdResolver; @Mock private ProjectExecutionMapper projectExecutionMapper; + @Mock + private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver; @Test void validateUsableForExecution_whenRequirementIdIsNull_shouldDoNothing() { diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java index f9a5991..8904355 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java @@ -89,6 +89,8 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest { private DictDataApi dictDataApi; @Mock private ObjectDataScopeService objectDataScopeService; + @Mock + private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver; @Test void getProjectDetail_shouldFillProductNameAndManagerNickname() { diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java index 3efc4e9..195c2fe 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java @@ -4,6 +4,8 @@ import com.njcn.rdms.framework.common.exception.ServiceException; import com.njcn.rdms.framework.common.pojo.PageResult; import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; +import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.MyProjectExecutionPageReqVO; +import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.MyProjectExecutionRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionOwnerChangeReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO; @@ -101,6 +103,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { private ProjectRequirementService projectRequirementService; @Mock private ProjectRequirementMapper projectRequirementMapper; + @Mock + private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver; /** * 默认让 dictDataApi.validateDictDataList 对 REQ_PRIORITY 返回 true,既有测试不因 priority 校验失败。 @@ -421,11 +425,16 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution); when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "active", "complete")) .thenReturn(null); + when(statusActionTextResolver.statusName("execution", "active")).thenReturn("进行中"); + when(statusActionTextResolver.actionName("execution", "complete")).thenReturn("完成"); try (MockedStatic mockedStatic = mockLoginUser(3001L)) { ServiceException ex = assertThrows(ServiceException.class, () -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO)); assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED.getCode(), ex.getCode()); + org.junit.jupiter.api.Assertions.assertTrue(ex.getMessage().contains("进行中")); + org.junit.jupiter.api.Assertions.assertTrue(ex.getMessage().contains("完成")); + org.junit.jupiter.api.Assertions.assertFalse(ex.getMessage().contains("complete")); } } @@ -640,6 +649,88 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest { } } + @Test + void getMyExecutionPage_shouldExcludeTerminalAndFullProgressAndAssembleProjectName() { + Long loginUserId = 3001L; + Long projectId = 2001L; + // 5001 active 进度 25.555 → 保留(26);5002 completed 终态 → 排除; + // 5003 active 进度 100 → 排除;5004 active 无根任务 → 进度 0 → 保留 + ProjectExecutionDO e1 = createExecution(projectId, 5001L, loginUserId); + e1.setStatusCode("active"); + ProjectExecutionDO e2 = createExecution(projectId, 5002L, loginUserId); + e2.setStatusCode("completed"); + ProjectExecutionDO e3 = createExecution(projectId, 5003L, loginUserId); + e3.setStatusCode("active"); + ProjectExecutionDO e4 = createExecution(projectId, 5004L, loginUserId); + e4.setStatusCode("active"); + + when(projectExecutionMapper.selectListByOwnerId(loginUserId)) + .thenReturn(List.of(e1, e2, e3, e4)); + // 执行域终态 + when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled("execution")) + .thenReturn(List.of("completed", "cancelled")); + // 任务域进度排除状态(loadProgressExcludedTaskStatusCodes 内部按 "task" 查) + when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task")) + .thenReturn(List.of("cancelled")); + // 聚合进度:5001=25.555, 5003=100;5004 缺失 → 0 + when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection(), eq(List.of("cancelled")))) + .thenReturn(List.of( + Map.of("execution_id", 5001L, "progress_rate", new BigDecimal("25.555")), + Map.of("execution_id", 5003L, "progress_rate", new BigDecimal("100.00")))); + // 执行域状态模型(建 statusCode -> statusName) + ObjectStatusModelDO activeStatus = new ObjectStatusModelDO(); + activeStatus.setObjectType("execution"); + activeStatus.setStatusCode("active"); + activeStatus.setStatusName("进行中"); + when(objectStatusModelMapper.selectListByObjectTypeEnabled("execution")) + .thenReturn(List.of(activeStatus)); + // 项目名回填 + when(projectMapper.selectBatchIds(anyCollection())) + .thenReturn(List.of(createEditableProject(projectId))); + + MyProjectExecutionPageReqVO reqVO = new MyProjectExecutionPageReqVO(); + reqVO.setPageNo(1); + reqVO.setPageSize(-1); + + try (MockedStatic mocked = mockStatic(SecurityFrameworkUtils.class)) { + mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId); + + PageResult result = projectExecutionService.getMyExecutionPage(reqVO); + + // 仅 5001、5004 保留 + assertEquals(2L, result.getTotal()); + assertEquals(2, result.getList().size()); + assertEquals(List.of(5001L, 5004L), + result.getList().stream().map(MyProjectExecutionRespVO::getId).collect(java.util.stream.Collectors.toList())); + // 进度:25.555 → 四舍五入 26;无根任务 → 0 + assertEquals(Integer.valueOf(26), result.getList().get(0).getProgressRate()); + assertEquals(Integer.valueOf(0), result.getList().get(1).getProgressRate()); + // projectName / statusName 回填 + assertEquals("测试项目", result.getList().get(0).getProjectName()); + assertEquals("进行中", result.getList().get(0).getStatusName()); + assertEquals("active", result.getList().get(0).getStatusCode()); + } + } + + @Test + void getMyExecutionPage_whenNoExecutions_shouldReturnEmptyPage() { + Long loginUserId = 3001L; + when(projectExecutionMapper.selectListByOwnerId(loginUserId)).thenReturn(List.of()); + + MyProjectExecutionPageReqVO reqVO = new MyProjectExecutionPageReqVO(); + reqVO.setPageNo(1); + reqVO.setPageSize(-1); + + try (MockedStatic mocked = mockStatic(SecurityFrameworkUtils.class)) { + mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId); + + PageResult result = projectExecutionService.getMyExecutionPage(reqVO); + + assertEquals(0L, result.getTotal()); + assertEquals(0, result.getList().size()); + } + } + @Test void getExecutionPage_shouldDelegateMapper() { Long projectId = 2001L; diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/status/StatusActionTextResolverTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/status/StatusActionTextResolverTest.java new file mode 100644 index 0000000..eb1191f --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/status/StatusActionTextResolverTest.java @@ -0,0 +1,53 @@ +package com.njcn.rdms.module.project.service.status; + +import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; +import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO; +import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; +import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +class StatusActionTextResolverTest extends BaseMockitoUnitTest { + + @InjectMocks + private StatusActionTextResolver resolver; + @Mock + private ObjectStatusTransitionMapper transitionMapper; + @Mock + private ObjectStatusModelMapper statusModelMapper; + + @Test + void actionName_hit_returnsChineseName() { + when(transitionMapper.selectActionNameByObjectTypeAndAction("task", "complete")).thenReturn("完成"); + assertEquals("完成", resolver.actionName("task", "complete")); + } + + @Test + void actionName_miss_fallsBackToCode() { + when(transitionMapper.selectActionNameByObjectTypeAndAction("task", "weird")).thenReturn(null); + assertEquals("weird", resolver.actionName("task", "weird")); + } + + @Test + void actionName_blankInput_returnsInput() { + assertEquals("", resolver.actionName("task", "")); + } + + @Test + void statusName_hit_returnsChineseName() { + ObjectStatusModelDO model = new ObjectStatusModelDO(); + model.setStatusName("已完成"); + when(statusModelMapper.selectByObjectTypeAndStatusCode("task", "completed")).thenReturn(model); + assertEquals("已完成", resolver.statusName("task", "completed")); + } + + @Test + void statusName_miss_fallsBackToCode() { + when(statusModelMapper.selectByObjectTypeAndStatusCode("task", "ghost")).thenReturn(null); + assertEquals("ghost", resolver.statusName("task", "ghost")); + } +}