11 Commits

Author SHA1 Message Date
622b30733e feat(project): 个人事项转项目任务后触发进度重算及对象域权限控制
- 个人事项转为项目根任务后调用项目进度重算功能
- 为产品项目列表查询接口增加对象域鉴权注解
- 引入ProjectService依赖注入以支持进度重算调用
- 导入ProductObjectConstants常量用于对象域权限控制
2026-06-04 20:21:30 +08:00
f13286aaff docs: 删除工单需求规格文档并更新开发规范
- 删除了工单需求规格说明文档 2026-05-22-ticket-design.md
- 在安全注解 CheckObjectPermission 中新增 accessible 参数配置
- 更新 CLAUDE.md 开发规范文档,补充 MySQL 客户端使用说明
- 优化错误码常量中的错误消息格式,使用中文状态和操作名称
- 修复权限拒绝提示消息,提供更友好的用户提示
- 更新开发规范关于演示库同步补丁和文档输出格式的要求
2026-06-04 18:46:41 +08:00
dk
f23f1930e9 fix(加班申请): 给加班申请提供一个专门返回状态dict的接口。 2026-06-04 10:46:45 +08:00
dk
55a50eb3d5 fix(加班申请): 修复加班申请的状态机相关代码的不合理的地方。 2026-06-03 21:00:11 +08:00
caozehui
679edf08ba fix(person-item): 权限放开 2026-06-02 09:14:40 +08:00
dk
e71140d8a2 feat(新增加班申请功能): 新增申请功能,可在工作台进行审核。
fix(dict_data): 字典数据的颜色类型字段不允许null更新。
2026-06-01 21:25:02 +08:00
5c7dbf7286 docs(guide): 更新对象域权限文档与数据库连接配置
- 修改开发环境和本地环境的数据库连接从 rdms_v3 切换到 rdms_view
- 更新 CLAUDE.md 中的对象域权限校验说明,统一在 Service 层使用 @CheckObjectPermission
- 移除 ExecutionAssigneeMapper 中的废弃查询方法和相关注解
- 优化 ObjectPermissionService 中的权限码描述信息
- 新增执行查询权限常量 PERMISSION_QUERY
- 重构 ProjectExecutionMapper 分页查询逻辑,使用 @Select 注解替代 LambdaQueryWrapper
- 添加执行状态面板和截止时间范围过滤功能
- 在 ProjectExecutionServiceImpl 中集成对象域权限检查
- 更新状态面板服务中的权限校验注解配置
2026-05-29 16:24:09 +08:00
9f03dc27cc fix(security): 修复token认证过滤器异步刷新异常处理
- 添加Slf4j注解用于日志记录
- 在load方法中添加try-catch块捕获ServiceException异常
- 当远端token过期或校验失败时返回LOGIN_USER_EMPTY而不是抛出异常
- 记录token校验失败的日志信息避免被Guava包装为ExecutionException
- 防止异步刷新线程将预期的验证异常作为未捕获异常打印到日志中
2026-05-26 19:11:17 +08:00
d669d53a80 feat(project): 添加项目进度自动计算功能
- 在 ProjectMapper 中新增 updateProgressRateById 方法,支持单独更新项目进度
- 在 ProjectService 中新增 recalcProgress 接口,用于重新计算项目进度
- 实现 ProjectServiceImpl 的进度重计算逻辑,通过根任务平均进度更新项目进度
- 新增 ProjectTaskMapper 的 selectRootTaskAvgProgressByProjectId 查询方法
- 在任务创建、更新、删除、状态变更等操作后触发项目进度重计算
- 添加进度归一化处理,确保数值精度为两位小数
- 更新 CLAUDE.md 文档,加强技术风险判断要求
2026-05-25 14:17:37 +08:00
df13a90107 feat(project): 添加项目任务跨执行聚合查询功能
- 新增 ObjectPermissionService.hasPermission 非抛模式权限检查方法
- 实现 ProjectObjectPermissionService.hasPermission 权限验证逻辑
- 为 ProductObjectPermissionService 预留空实现并添加日志警告
- 在 ProjectExecutionController 中支持负数 pageSize 查询全部功能
- 添加 ProjectTaskConstants.PERMISSION_LIST_ALL 权限码常量定义
- 扩展 ProjectTaskMapper 支持跨执行聚合分页、状态统计和摘要查询
- 更新 ProjectTaskRespVO 包含执行名称和状态码字段
- 实现 ProjectTaskService.assembleTaskRespVOPageCrossExecution 跨执行装配方法
- 优化任务服务中的执行信息批量回填和生命周期应用逻辑
- 统一使用服务器时区 Asia/Shanghai 处理日期时间操作
- 为 .claude 设置添加新的代码搜索和分析命令
2026-05-23 14:18:15 +08:00
8a36b49128 feat(project): 添加项目任务跨执行聚合查询功能
- 新增 ObjectPermissionService.hasPermission 非抛模式权限检查方法
- 实现 ProjectObjectPermissionService.hasPermission 权限验证逻辑
- 为 ProductObjectPermissionService 预留空实现并添加日志警告
- 在 ProjectExecutionController 中支持负数 pageSize 查询全部功能
- 添加 ProjectTaskConstants.PERMISSION_LIST_ALL 权限码常量定义
- 扩展 ProjectTaskMapper 支持跨执行聚合分页、状态统计和摘要查询
- 更新 ProjectTaskRespVO 包含执行名称和状态码字段
- 实现 ProjectTaskService.assembleTaskRespVOPageCrossExecution 跨执行装配方法
- 优化任务服务中的执行信息批量回填和生命周期应用逻辑
- 统一使用服务器时区 Asia/Shanghai 处理日期时间操作
- 为 .claude 设置添加新的代码搜索和分析命令
2026-05-23 14:18:04 +08:00
109 changed files with 5042 additions and 1097 deletions

View File

@@ -105,7 +105,14 @@
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; $env:PATH = \"$env:JAVA_HOME\\\\bin;$env:PATH\"; $out = & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am compile -DskipTests 2>&1; $code = $LASTEXITCODE; $out | Select-Object -Last 15; Write-Output \"EXIT=$code\")", "PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; $env:PATH = \"$env:JAVA_HOME\\\\bin;$env:PATH\"; $out = & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am compile -DskipTests 2>&1; $code = $LASTEXITCODE; $out | Select-Object -Last 15; Write-Output \"EXIT=$code\")",
"Bash(set \"JAVA_HOME=C:\\\\Program Files\\\\Java\\\\jdk-17\")", "Bash(set \"JAVA_HOME=C:\\\\Program Files\\\\Java\\\\jdk-17\")",
"Bash(\"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot -am compile -DskipTests -q)", "Bash(\"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot -am compile -DskipTests -q)",
"Bash(\"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot -am compile -DskipTests)" "Bash(\"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot -am compile -DskipTests)",
"Bash(grep -E \"\\\\.\\(sql|java|md\\)$\")",
"Bash(xargs grep -l \"INSERT INTO.*system_menu\")",
"Bash(Get-ChildItem *)",
"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)"
] ]
} }
} }

View File

@@ -9,12 +9,14 @@
- 描述仓库现状以**当前**代码、配置、文档可验证的事实为准;不要拿历史实现、过渡方案或已废弃模型解释当前状态。 - 描述仓库现状以**当前**代码、配置、文档可验证的事实为准;不要拿历史实现、过渡方案或已废弃模型解释当前状态。
- **输出极简**:先给结论、改动点、必要风险;用自然语言给判断和影响面,少贴代码片段;涉及代码用 `file_path:line_number` 引用;用户追问再展开。 - **输出极简**:先给结论、改动点、必要风险;用自然语言给判断和影响面,少贴代码片段;涉及代码用 `file_path:line_number` 引用;用户追问再展开。
- **下定论需要充足证据**。疑似 bug 时先判断是否稳定复现:跑了很久没动过的功能**首次**报错优先怀疑运行时状态污染devtools / IDE 热替换、ApplicationContext 残留、缓存、Redis / DB 连接、JVM 静态字段被旧 context 设过等),**不要凭单次堆栈就断言代码 bug更不要直接甩修改方案**。先给"可能原因 + 最便宜的取证步骤"(多数场景是**冷重启 JVM**),用户确认能稳定复现,再讨论代码层面的修复。同款写法在仓库其它位置存在并不能反推"也是 bug",长期能跑的代码突然失效 ≠ 代码本身错。 - **下定论需要充足证据**。疑似 bug 时先判断是否稳定复现:跑了很久没动过的功能**首次**报错优先怀疑运行时状态污染devtools / IDE 热替换、ApplicationContext 残留、缓存、Redis / DB 连接、JVM 静态字段被旧 context 设过等),**不要凭单次堆栈就断言代码 bug更不要直接甩修改方案**。先给"可能原因 + 最便宜的取证步骤"(多数场景是**冷重启 JVM**),用户确认能稳定复现,再讨论代码层面的修复。同款写法在仓库其它位置存在并不能反推"也是 bug",长期能跑的代码突然失效 ≠ 代码本身错。
- **技术风险判断(性能 / N+1 / 索引缺失 / 架构缺陷 / 并发安全 / 内存泄漏 等)与"bug 判断"同等严格**:未读到实现层不下结论。不要凭 subagent 摘要、字段名、注释或印象"顺嘴提一句风险/瓶颈/可能问题"——那也是下结论,**且杀伤力更大**:用户会基于"风险提示"决定要不要立项整改。如果当前上下文没核实到实现,就明说"这部分未核实,需要打开 X 文件确认",不要把猜测包装成"风险提示"塞出去。已识别教训:执行进度查询答完"已批量聚合无 N+1"后又凭印象抛"列表 N+1 风险",被追问才收回。
## 本机环境 ## 本机环境
- JDK必须使用 `JDK 17`,路径 `C:\Program Files\Java\jdk-17`。不要使用 JDK 8 / 11 / 其他版本。 - 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`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否则在该命令上下文中显式切换。 - 执行任何 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)。
## 仓库结构 ## 仓库结构
@@ -47,7 +49,6 @@
不要: 不要:
- 把后续业务长期堆进 `rdms-system` - 把后续业务长期堆进 `rdms-system`
- 为新增子域引入一套平行的 `application/domain/infrastructure/adapter` 分层。 - 为新增子域引入一套平行的 `application/domain/infrastructure/adapter` 分层。
- 让外部模块直接依赖 `*-boot` 的 service 或 mapper必须走 `*-api`)。
## 分层职责 ## 分层职责
@@ -63,7 +64,7 @@
## 认证与跨模块调用 ## 认证与跨模块调用
- 默认沿用 OAuth2 / Token / `LoginUser` / `login-user` 透传主链。**不要**另造 ThreadLocal / Session / 自定义 header。 - 默认沿用 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` 契约同步更新。
### 鉴权:必须按"全域 / 对象域"分通道挂 ### 鉴权:必须按"全域 / 对象域"分通道挂
@@ -79,7 +80,7 @@
- **对象内接口绝不能挂 `@PreAuthorize("@ss.hasPermission(...)")`**。该注解走的链路在 `PermissionServiceImpl` 里强制按 GLOBAL 取角色line 343-347+ 强制按 GLOBAL 查菜单line 92-94对象域角色与对象域菜单都进不来即使授权配置完全正确也必然 403。 - **对象内接口绝不能挂 `@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`**,原因:路径里 `objectId` 通常以 `#projectId`/`#productId` 等 SpEL 解析Controller 的参数校验前置阶段不便复用;与同模块(`ProjectMemberServiceImpl` / `ProjectExecutionServiceImpl` / `ProjectExecutionAssigneeServiceImpl` / `ProjectTaskServiceImpl`)保持一致。
- **同一接口不要两条通道叠加**。要么全域,要么对象域;叠加只会让对象域用户被全域那条卡死。 - **同一接口不要两条通道叠加**。要么全域,要么对象域;叠加只会让对象域用户被全域那条卡死。
- 列表/详情这类对象内**读路径**目前未`@CheckObjectPermission`(属已识别负债,台账 TD-001新增读接口暂沿用现状即可不要顺手改造等独立立项 - 对象内**读路径**(列表 / 详情 / 状态看板 / 聚合)同样要`@CheckObjectPermission(objectType=PROJECT, permission=...PERMISSION_QUERY)`——查询同样扫库耗资源,必须按对象域鉴权。**Controller 方法层一律不挂权限注解**,新增读接口照此在 Service 层挂对象域权限;不要只在 Controller 留空,更不要误判"Controller 没注解 = 无鉴权"
判定口诀:**URL 里有 `{projectId}` / `{productId}` 等对象 ID → 对象域;没有 → 全域**。 判定口诀:**URL 里有 `{projectId}` / `{productId}` 等对象 ID → 对象域;没有 → 全域**。
@@ -99,8 +100,32 @@
- 新增 update 接口时,必须在 API 文档对应章节明示"PUT 全字段回传"约定;DO 上对允许 null 的字段补 `FieldStrategy.ALWAYS` 注解,并加注释说明语义来源(指向本节)。 - 新增 update 接口时,必须在 API 文档对应章节明示"PUT 全字段回传"约定;DO 上对允许 null 的字段补 `FieldStrategy.ALWAYS` 注解,并加注释说明语义来源(指向本节)。
- 历史接口若是稀疏 PATCH 风格(传 null = 不动),保留现状但不要拓展;遇到清空诉求时按 PUT 方向重构。 - 历史接口若是稀疏 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 补丁手工同步。一旦补丁缺失或漏项,演示库就会因数据结构 / 字典 / 权限缺失导致功能异常。因此——
- **每次开发引起的任何数据库变动,都必须同步产出一个可直接运行的 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` / 审计字段风格,不要再引一套审计基类(除非该表本身明确不需要逻辑删除)。 - 新表 DO 复用现有 `BaseDO` / 审计字段风格,不要再引一套审计基类(除非该表本身明确不需要逻辑删除)。
- **不要假设运行时自动数据库迁移**:依赖新表/新字段/新索引时,必须同步补 SQL 脚本与文档。 - **不要假设运行时自动数据库迁移**:依赖新表/新字段/新索引时,必须同步补 SQL 脚本与文档。
- SQL 放在目标模块 `src/main/resources/sql/...`,可审阅、可单独执行。 - SQL 放在目标模块 `src/main/resources/sql/...`,可审阅、可单独执行。
@@ -108,13 +133,13 @@
### 种子 SQL纯 SQL INSERT 雪花 ID 表) ### 种子 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 冲突 - **必须显式给 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**:每条 INSERT **前重新取** `MAX+1`——不要用 `base+1 / +2 / +3` 一次性算多个。配合 `NOT EXISTS` 守卫,部分已存在场景(半路重跑)才不会出现两条共用一个 id - **必须幂等**:每条 INSERT `NOT EXISTS` 守卫,可重复执行不重插
- **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 隐式解析,不冲突 - **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)
## 注释与编码 ## 注释与编码
@@ -124,9 +149,29 @@
## 文档输出格式 ## 文档输出格式
- 新写文档默认输出 **HTML 格式**(便于浏览器直接打开、自带样式)。 - 新写文档默认输出 **HTML 格式**(便于浏览器直接打开、自带样式)。
- 例外:`docs/superpowers/` 下保持 markdown(工作流约定)。 - 例外:`docs/superpowers/``docs/agent/` 下保持 **markdown**——前者是工作流约定,后者是专给 Agent 看的操作手册Agent 读 md 更直接,不需要浏览器样式)。
- 历史已有的 markdown 文档不强制迁移;只有新写的按 HTML。 - 历史已有的 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. 优先做有边界的模块内改动,避免跨模块扩散。 1. 优先做有边界的模块内改动,避免跨模块扩散。

View File

@@ -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` 资源入口,后端接口落地时需要与前端路由和菜单权限同步。

View File

@@ -14,6 +14,7 @@ import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.gateway.util.SecurityFrameworkUtils; import com.njcn.rdms.gateway.util.SecurityFrameworkUtils;
import com.njcn.rdms.gateway.util.WebFrameworkUtils; import com.njcn.rdms.gateway.util.WebFrameworkUtils;
import com.njcn.rdms.module.system.enums.ErrorCodeConstants; import com.njcn.rdms.module.system.enums.ErrorCodeConstants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.client.loadbalancer.reactive.ReactorLoadBalancerExchangeFilterFunction; import org.springframework.cloud.client.loadbalancer.reactive.ReactorLoadBalancerExchangeFilterFunction;
import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.cloud.gateway.filter.GlobalFilter;
@@ -30,6 +31,7 @@ import java.util.function.Function;
import static com.njcn.rdms.framework.common.util.cache.CacheUtils.buildAsyncReloadingCache; import static com.njcn.rdms.framework.common.util.cache.CacheUtils.buildAsyncReloadingCache;
@Slf4j
@Component @Component
public class TokenAuthenticationFilter implements GlobalFilter, Ordered { public class TokenAuthenticationFilter implements GlobalFilter, Ordered {
@@ -57,8 +59,16 @@ public class TokenAuthenticationFilter implements GlobalFilter, Ordered {
@Override @Override
public LoginUser load(String token) { public LoginUser load(String token) {
// 仅异步 refresh 走这里(同步链路用 getIfPresent + 直接 checkAccessToken不触发 load
// 远端 token 已过期/校验失败时吞掉 ServiceException
// 若抛出,会被 Guava 包成 ExecutionException 并由刷新线程池作为 UncaughtException 打到日志,看起来像故障。
try {
String body = checkAccessToken(token).block(); String body = checkAccessToken(token).block();
return buildUser(body, token); return buildUser(body, token);
} catch (ServiceException ex) {
log.info("[loginUserCache] 异步刷新忽略 token 校验失败code={}, msg={}", ex.getCode(), ex.getMessage());
return LOGIN_USER_EMPTY;
}
} }
}); });

View File

@@ -14,8 +14,8 @@ public interface ErrorCodeConstants {
ErrorCode PRODUCT_CODE_DUPLICATE = new ErrorCode(1_008_001_001, "已经存在编码为【{}】的产品"); ErrorCode PRODUCT_CODE_DUPLICATE = new ErrorCode(1_008_001_001, "已经存在编码为【{}】的产品");
ErrorCode PRODUCT_NAME_DUPLICATE = new ErrorCode(1_008_001_002, "已经存在名称为【{}】的产品"); ErrorCode PRODUCT_NAME_DUPLICATE = new ErrorCode(1_008_001_002, "已经存在名称为【{}】的产品");
ErrorCode PRODUCT_CODE_NOT_MODIFIABLE = new ErrorCode(1_008_001_003, "产品编码创建后不允许修改"); 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_NOT_ALLOWED = new ErrorCode(1_008_001_004, "当前产品为「{}」状态不支持「{}」操作");
ErrorCode PRODUCT_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_001_005, "动作【{}】必须填写原因"); 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_DELETE_NAME_MISMATCH = new ErrorCode(1_008_001_006, "删除确认名称与当前产品名称不一致");
ErrorCode PRODUCT_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_001_007, "当前产品状态不允许编辑"); ErrorCode PRODUCT_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_001_007, "当前产品状态不允许编辑");
ErrorCode PRODUCT_PAUSED_ONLY_ALLOW_LIMITED_UPDATE = new ErrorCode(1_008_001_008, "产品暂停后仅允许修正描述,产品经理请通过产品团队维护"); ErrorCode PRODUCT_PAUSED_ONLY_ALLOW_LIMITED_UPDATE = new ErrorCode(1_008_001_008, "产品暂停后仅允许修正描述,产品经理请通过产品团队维护");
@@ -29,7 +29,7 @@ public interface ErrorCodeConstants {
ErrorCode PRODUCT_MANAGER_TRANSFER_SOURCE_INVALID = new ErrorCode(1_008_001_017, "原产品经理信息与当前产品经理不一致"); 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_TRANSFER_ROLE_INVALID = new ErrorCode(1_008_001_018, "原产品经理交接后的角色不能仍为产品经理");
ErrorCode PRODUCT_MANAGER_NOT_MODIFIABLE = new ErrorCode(1_008_001_019, "产品主数据编辑不允许直接变更产品经理,请通过产品团队维护"); 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_DELETE_CONFIRM_TEXT_INVALID = new ErrorCode(1_008_001_021, "删除确认口令不正确");
ErrorCode PRODUCT_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_001_022, "产品状态已发生变化,请刷新后重试"); 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, "产品状态定义不存在或已停用"); 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_MEMBER_DUPLICATE = new ErrorCode(1_008_001_025, "初始团队成员存在重复");
ErrorCode PRODUCT_INITIAL_TEAM_ROLE_INVALID = new ErrorCode(1_008_001_026, "初始团队中存在非法角色"); 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_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, "产品成员不是有效系统用户"); ErrorCode PRODUCT_MEMBER_USER_INVALID = new ErrorCode(1_008_001_029, "产品成员不是有效系统用户");
// 批量新增POST /project/product/{id}/members/batch专用同一请求内 userId 重复 / 经理拦截 // 批量新增POST /project/product/{id}/members/batch专用同一请求内 userId 重复 / 经理拦截
ErrorCode PRODUCT_MEMBER_BATCH_USER_DUPLICATE = new ErrorCode(1_008_001_030, "请勿在批量列表中重复添加同一成员"); ErrorCode PRODUCT_MEMBER_BATCH_USER_DUPLICATE = new ErrorCode(1_008_001_030, "请勿在批量列表中重复添加同一成员");
@@ -48,8 +48,8 @@ public interface ErrorCodeConstants {
// ========== 产品需求 1-008-002-000 ========== // ========== 产品需求 1-008-002-000 ==========
ErrorCode REQUIREMENT_NOT_EXISTS = new ErrorCode(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_NOT_ALLOWED = new ErrorCode(1_008_002_001, "当前需求为「{}」状态不支持「{}」操作");
ErrorCode REQUIREMENT_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_002_002, "动作【{}】必须填写原因"); 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_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_EDIT = new ErrorCode(1_008_002_004, "当前需求状态不允许编辑");
ErrorCode REQUIREMENT_STATUS_NOT_ALLOW_CLOSE = new ErrorCode(1_008_002_005, "只有已验收的需求才能关闭"); 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_TYPE_INVALID = new ErrorCode(1_008_002_006, "项目类型不是有效字典值");
ErrorCode PROJECT_MANAGER_USER_INVALID = new ErrorCode(1_008_002_007, "项目负责人不是有效系统用户"); ErrorCode PROJECT_MANAGER_USER_INVALID = new ErrorCode(1_008_002_007, "项目负责人不是有效系统用户");
ErrorCode PROJECT_MEMBER_USER_INVALID = new ErrorCode(1_008_002_008, "项目成员不是有效系统用户"); 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_NOT_ALLOWED = new ErrorCode(1_008_002_009, "当前项目为「{}」状态不支持「{}」操作");
ErrorCode PROJECT_STATUS_ACTION_REASON_REQUIRED = new ErrorCode(1_008_002_010, "动作【{}】必须填写原因"); 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_CONCURRENT_MODIFIED = new ErrorCode(1_008_002_011, "项目状态已发生变化,请刷新后重试");
ErrorCode PROJECT_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_002_012, "当前项目状态不允许编辑"); ErrorCode PROJECT_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_002_012, "当前项目状态不允许编辑");
ErrorCode PROJECT_MEMBER_NOT_EXISTS = new ErrorCode(1_008_002_013, "项目成员不存在"); 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_CONFIRM_TEXT_INVALID = new ErrorCode(1_008_002_018, "删除确认口令不正确");
ErrorCode PROJECT_DELETE_NAME_MISMATCH = new ErrorCode(1_008_002_019, "删除确认名称与当前项目名称不一致"); ErrorCode PROJECT_DELETE_NAME_MISMATCH = new ErrorCode(1_008_002_019, "删除确认名称与当前项目名称不一致");
ErrorCode PROJECT_NOT_ALLOW_DELETE = new ErrorCode(1_008_002_020, "当前项目不允许删除"); 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_STATUS_MODEL_NOT_EXISTS_OR_DISABLED = new ErrorCode(1_008_002_022, "项目状态定义不存在或已停用");
ErrorCode PROJECT_DIRECTION_INVALID = new ErrorCode(1_008_002_023, "项目方向不是有效字典值"); ErrorCode PROJECT_DIRECTION_INVALID = new ErrorCode(1_008_002_023, "项目方向不是有效字典值");
ErrorCode PROJECT_MANAGER_TRANSFER_INFO_REQUIRED = new ErrorCode(1_008_002_024, "切换项目经理时必须同时传入原项目经理用户和交接后角色"); 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_INITIAL_TEAM_ROLE_INVALID = new ErrorCode(1_008_002_031, "初始团队中存在非法角色");
ErrorCode PROJECT_DIRECTION_NOT_MATCH_PRODUCT = new ErrorCode(1_008_002_032, "项目方向与所属产品方向不一致"); 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_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 重复 / 经理拦截 // 批量新增POST /project/project/{id}/members/batch专用同一请求内 userId 重复 / 经理拦截
ErrorCode PROJECT_MEMBER_BATCH_USER_DUPLICATE = new ErrorCode(1_008_002_035, "请勿在批量列表中重复添加同一成员"); 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, "批量新增不允许指定为经理,请通过编辑成员调整"); 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_NOT_ALLOW_EDIT = new ErrorCode(1_008_003_008, "当前项目状态不允许维护执行");
ErrorCode PROJECT_EXECUTION_OWNER_HANDOFF_REQUIRED = new ErrorCode(1_008_003_009, "该项目成员仍担任未终态执行负责人,请先完成执行负责人交接"); 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_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_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_REASON_REQUIRED = new ErrorCode(1_008_003_012, "「{}」操作必须填写原因");
ErrorCode PROJECT_EXECUTION_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_003_013, "执行状态已发生变化,请刷新后重试"); 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_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_003_014, "当前执行状态不允许维护执行");
ErrorCode PROJECT_EXECUTION_TYPE_INVALID = new ErrorCode(1_008_003_015, "执行类型不是有效字典值"); 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_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_COMPLETE_TASKS_REQUIRED = new ErrorCode(1_008_003_018, "完成执行前,执行下所有任务必须全部完成或取消");
ErrorCode PROJECT_EXECUTION_NOT_ALLOW_DELETE = new ErrorCode(1_008_003_019, "已完成的执行不允许删除"); ErrorCode PROJECT_EXECUTION_NOT_ALLOW_DELETE = new ErrorCode(1_008_003_019, "已完成的执行不允许删除");
ErrorCode PROJECT_EXECUTION_DELETE_NAME_MISMATCH = new ErrorCode(1_008_003_020, "确认执行名称与实际不一致"); 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_PARENT_INVALID = new ErrorCode(1_008_004_002, "父任务必须属于当前项目和执行");
ErrorCode PROJECT_TASK_NOT_ALLOW_EDIT = new ErrorCode(1_008_004_003, "当前项目或执行状态不允许维护任务"); 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_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_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_REASON_REQUIRED = new ErrorCode(1_008_004_006, "「{}」操作必须填写原因");
ErrorCode PROJECT_TASK_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_004_007, "任务状态已发生变化,请刷新后重试"); 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_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_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_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_NAME_MISMATCH = new ErrorCode(1_008_004_013, "确认任务名称与实际不一致");
ErrorCode PROJECT_TASK_DELETE_CONFIRM_TEXT_INVALID = new ErrorCode(1_008_004_014, "删除确认口令必须为 DELETE 或 删除"); 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 ========== // ========== 项目需求 1_008_007_xxx ==========
ErrorCode PROJECT_REQUIREMENT_NOT_EXISTS = new ErrorCode(1_008_007_000, "项目需求不存在"); 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_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_REASON_REQUIRED = new ErrorCode(1_008_007_002, "「{}」操作必须填写原因");
ErrorCode PROJECT_REQUIREMENT_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_007_003, "项目需求状态已发生变化,请刷新后重试"); 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_EDIT = new ErrorCode(1_008_007_004, "当前项目需求状态不允许编辑");
ErrorCode PROJECT_REQUIREMENT_STATUS_NOT_ALLOW_CLOSE = new ErrorCode(1_008_007_005, "只有已验收的项目需求才能关闭"); ErrorCode PROJECT_REQUIREMENT_STATUS_NOT_ALLOW_CLOSE = new ErrorCode(1_008_007_005, "只有已验收的项目需求才能关闭");
@@ -221,10 +221,23 @@ public interface ErrorCodeConstants {
ErrorCode PERSONAL_ITEM_NOT_EXISTS = new ErrorCode(1_008_008_001, "个人事项不存在"); 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_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_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_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_REASON_REQUIRED = new ErrorCode(1_008_008_005, "「{}」操作必须填写原因");
ErrorCode PERSONAL_ITEM_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_008_006, "个人事项状态已发生变化,请刷新后重试"); 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_STATUS_NOT_ALLOW_EDIT = new ErrorCode(1_008_008_007, "当前个人事项状态不允许编辑");
ErrorCode PERSONAL_ITEM_NOT_ALLOW_DELETE = new ErrorCode(1_008_008_008, "仅初始态(待开始)的个人事项允许删除"); ErrorCode PERSONAL_ITEM_NOT_ALLOW_DELETE = new ErrorCode(1_008_008_008, "仅初始态(待开始)的个人事项允许删除");
ErrorCode PERSONAL_ITEM_WRITE_FORBIDDEN = new ErrorCode(1_008_008_009, "无权修改个人事项"); ErrorCode PERSONAL_ITEM_WRITE_FORBIDDEN = new ErrorCode(1_008_008_009, "无权修改个人事项");
// ========== 加班申请 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_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, "仅当前审核人可执行该操作");
ErrorCode OVERTIME_APPLICATION_APPROVER_INVALID = new ErrorCode(1_008_009_008, "审核人不是有效系统用户");
ErrorCode OVERTIME_APPLICATION_APPROVER_SELF_FORBIDDEN = new ErrorCode(1_008_009_009, "审核人不能选择申请人本人");
ErrorCode OVERTIME_APPLICATION_READ_FORBIDDEN = new ErrorCode(1_008_009_010, "无权查看该加班申请");
ErrorCode OVERTIME_APPLICATION_DELETE_ONLY_CANCELLED = new ErrorCode(1_008_009_011, "仅已撤销的加班申请允许删除");
} }

View File

@@ -0,0 +1,38 @@
package com.njcn.rdms.module.project.constant;
/**
* 加班申请常量。
*/
public final class OvertimeApplicationConstants {
private OvertimeApplicationConstants() {
}
public static final String BIZ_TYPE = "overtime_application";
public static final String STATUS_OBJECT_TYPE = BIZ_TYPE;
public static final String STATUS_PENDING = "pending";
public static final String STATUS_APPROVED = "approved";
public static final String STATUS_REJECTED = "rejected";
public static final String STATUS_CANCELLED = "cancelled";
/**
* 新建即提交的业务日志动作。
* <p>
* 该动作仅用于状态日志、审计日志留痕,不要求在 rdms_object_status_transition 中存在显式流转配置。
*/
public static final String ACTION_SUBMIT = "submit";
public static final String ACTION_RESUBMIT = "resubmit";
public static final String ACTION_APPROVE = "approve";
public static final String ACTION_REJECT = "reject";
public static final String ACTION_CANCEL = "cancel";
public static final String ACTION_DELETE = "delete";
public static final String PERMISSION_QUERY = "project:overtime-application:query";
public static final String PERMISSION_CREATE = "project:overtime-application:create";
public static final String PERMISSION_UPDATE = "project:overtime-application:update";
public static final String PERMISSION_DELETE = "project:overtime-application:delete";
public static final String PERMISSION_APPROVE = "project:overtime-application:approve";
public static final String PERMISSION_EXPORT = "project:overtime-application:export";
}

View File

@@ -20,6 +20,13 @@ public final class ProjectExecutionConstants {
*/ */
public static final String BIZ_TYPE = "project_execution"; public static final String BIZ_TYPE = "project_execution";
/**
* 执行读路径查询权限码对象域object_type='project')。
* 覆盖执行对象所有读路径page / status-board / detail。
* "我参与 / 所有"视角由前端发不发 involveUserId 决定;进得来 = 看项目下全部,无此权限码直接 403。
*/
public static final String PERMISSION_QUERY = "project:execution:query";
/** /**
* 创建执行权限码。 * 创建执行权限码。
*/ */

View File

@@ -26,6 +26,13 @@ public final class ProjectTaskConstants {
*/ */
public static final String BIZ_TYPE = "project_task"; public static final String BIZ_TYPE = "project_task";
/**
* 任务读路径查询权限码对象域object_type='project')。
* 覆盖任务对象所有读路径page / status-board / board-page / detail / summary含跨执行 aggregate
* "我参与 / 所有"视角由前端发不发 involveUserId 决定;进得来 = 看项目下全部,无此权限码直接 403。
*/
public static final String PERMISSION_QUERY = "project:task:query";
/** /**
* 创建任务权限码。 * 创建任务权限码。
*/ */

View File

@@ -0,0 +1,143 @@
package com.njcn.rdms.module.project.controller.admin.overtime;
import com.njcn.rdms.framework.apilog.core.annotation.ApiAccessLog;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.excel.core.util.ExcelUtils;
import com.njcn.rdms.module.project.constant.OvertimeApplicationConstants;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationExportVO;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationPageReqVO;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationRespVO;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationStatusActionReqVO;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationStatusDictRespVO;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationStatusLogRespVO;
import com.njcn.rdms.module.project.service.overtime.OvertimeApplicationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.time.LocalDate;
import java.util.List;
import static com.njcn.rdms.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - 加班申请")
@RestController
@RequestMapping("/project/overtime-applications")
@Validated
public class OvertimeApplicationController {
@Resource
private OvertimeApplicationService overtimeApplicationService;
@PostMapping
@Operation(summary = "新增加班申请并提交")
@PreAuthorize("@ss.hasPermission('" + OvertimeApplicationConstants.PERMISSION_CREATE + "')")
public CommonResult<Long> create(@Valid @RequestBody OvertimeApplicationSaveReqVO reqVO) {
return success(overtimeApplicationService.createApplication(reqVO));
}
@PutMapping("/{id}")
@Operation(summary = "退回或撤销后修改并重新提交加班申请")
@PreAuthorize("@ss.hasPermission('" + OvertimeApplicationConstants.PERMISSION_UPDATE + "')")
public CommonResult<Boolean> resubmit(@PathVariable("id") Long id,
@Valid @RequestBody OvertimeApplicationSaveReqVO reqVO) {
overtimeApplicationService.resubmitApplication(id, reqVO);
return success(true);
}
@GetMapping("/{id}")
@Operation(summary = "获取加班申请详情")
@PreAuthorize("@ss.hasPermission('" + OvertimeApplicationConstants.PERMISSION_QUERY + "')")
public CommonResult<OvertimeApplicationRespVO> get(@PathVariable("id") Long id) {
return success(overtimeApplicationService.getApplication(id));
}
@GetMapping("/status/dict")
@Operation(summary = "获取加班申请所有状态字典")
@PreAuthorize("@ss.hasPermission('" + OvertimeApplicationConstants.PERMISSION_QUERY + "')")
public CommonResult<List<OvertimeApplicationStatusDictRespVO>> getStatusDict() {
return success(overtimeApplicationService.getStatusDict());
}
@GetMapping("/page")
@Operation(summary = "获取我的加班申请分页")
@PreAuthorize("@ss.hasPermission('" + OvertimeApplicationConstants.PERMISSION_QUERY + "')")
public CommonResult<PageResult<OvertimeApplicationRespVO>> page(@Valid OvertimeApplicationPageReqVO reqVO) {
return success(overtimeApplicationService.getMyPage(reqVO));
}
@GetMapping("/approval-page")
@Operation(summary = "获取待我审批的加班申请分页")
@PreAuthorize("@ss.hasPermission('" + OvertimeApplicationConstants.PERMISSION_APPROVE + "')")
public CommonResult<PageResult<OvertimeApplicationRespVO>> approvalPage(@Valid OvertimeApplicationPageReqVO reqVO) {
return success(overtimeApplicationService.getApprovalPage(reqVO));
}
@PostMapping("/{id}/approve")
@Operation(summary = "审核通过加班申请")
@PreAuthorize("@ss.hasPermission('" + OvertimeApplicationConstants.PERMISSION_APPROVE + "')")
public CommonResult<Boolean> approve(@PathVariable("id") Long id,
@Valid @RequestBody OvertimeApplicationStatusActionReqVO reqVO) {
overtimeApplicationService.approve(id, reqVO);
return success(true);
}
@PostMapping("/{id}/reject")
@Operation(summary = "审核退回加班申请")
@PreAuthorize("@ss.hasPermission('" + OvertimeApplicationConstants.PERMISSION_APPROVE + "')")
public CommonResult<Boolean> reject(@PathVariable("id") Long id,
@Valid @RequestBody OvertimeApplicationStatusActionReqVO reqVO) {
overtimeApplicationService.reject(id, reqVO);
return success(true);
}
@PostMapping("/{id}/cancel")
@Operation(summary = "撤销加班申请")
@PreAuthorize("@ss.hasPermission('" + OvertimeApplicationConstants.PERMISSION_UPDATE + "')")
public CommonResult<Boolean> cancel(@PathVariable("id") Long id,
@Valid @RequestBody OvertimeApplicationStatusActionReqVO reqVO) {
overtimeApplicationService.cancel(id, reqVO);
return success(true);
}
@DeleteMapping("/{id}")
@Operation(summary = "删除已撤销的加班申请")
@PreAuthorize("@ss.hasPermission('" + OvertimeApplicationConstants.PERMISSION_DELETE + "')")
public CommonResult<Boolean> delete(@PathVariable("id") Long id) {
overtimeApplicationService.deleteApplication(id);
return success(true);
}
@GetMapping("/{id}/status-logs")
@Operation(summary = "获取加班申请状态日志")
@PreAuthorize("@ss.hasPermission('" + OvertimeApplicationConstants.PERMISSION_QUERY + "')")
public CommonResult<List<OvertimeApplicationStatusLogRespVO>> statusLogs(@PathVariable("id") Long id) {
return success(overtimeApplicationService.getStatusLogs(id));
}
@GetMapping("/export")
@Operation(summary = "导出我的加班申请")
@PreAuthorize("@ss.hasPermission('" + OvertimeApplicationConstants.PERMISSION_EXPORT + "')")
@ApiAccessLog(operateType = EXPORT)
public void export(@Valid OvertimeApplicationPageReqVO reqVO, HttpServletResponse response) throws IOException {
List<OvertimeApplicationExportVO> list = overtimeApplicationService.getExportList(reqVO);
ExcelUtils.write(response, "加班申请_" + LocalDate.now() + ".xls", "加班申请",
OvertimeApplicationExportVO.class, list);
}
}

View File

@@ -0,0 +1,40 @@
package com.njcn.rdms.module.project.controller.admin.overtime.vo;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@ExcelIgnoreUnannotated
public class OvertimeApplicationExportVO {
@ExcelProperty("申请人")
private String applicantName;
@ExcelProperty("加班日期")
private LocalDate overtimeDate;
@ExcelProperty("加班时长")
private String overtimeDuration;
@ExcelProperty("加班原因")
private String overtimeReason;
@ExcelProperty("加班内容")
private String overtimeContent;
@ExcelProperty("状态")
private String statusName;
@ExcelProperty("审核人")
private String approverName;
@ExcelProperty("提交时间")
private LocalDateTime submitTime;
@ExcelProperty("审核时间")
private LocalDateTime approvalTime;
}

View File

@@ -0,0 +1,44 @@
package com.njcn.rdms.module.project.controller.admin.overtime.vo;
import com.njcn.rdms.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY;
import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - 加班申请分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class OvertimeApplicationPageReqVO extends PageParam {
@Schema(description = "关键词,匹配加班原因或加班内容", example = "上线")
private String keyword;
@Schema(description = "申请人姓名,模糊匹配", example = "张三")
private String applicantName;
@Schema(description = "审核人用户编号", example = "1001")
private Long approverId;
@Schema(description = "审核人姓名,模糊匹配", example = "李四")
private String approverName;
@Schema(description = "状态编码", example = "pending")
@Size(max = 32, message = "状态编码长度不能超过32个字符")
private String statusCode;
@Schema(description = "加班日期范围", example = "[2026-06-01, 2026-06-30]")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY)
private LocalDate[] overtimeDate;
@Schema(description = "创建时间", example = "[2026-06-01 00:00:00, 2026-06-30 23:59:59]")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] createTime;
}

View File

@@ -0,0 +1,66 @@
package com.njcn.rdms.module.project.controller.admin.overtime.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - 加班申请 Response VO")
@Data
public class OvertimeApplicationRespVO {
@Schema(description = "加班申请编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "9001")
private Long id;
@Schema(description = "申请人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3001")
private Long applicantId;
@Schema(description = "申请人姓名", requiredMode = Schema.RequiredMode.REQUIRED, example = "张三")
private String applicantName;
@Schema(description = "加班日期", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDate overtimeDate;
@Schema(description = "加班时长", requiredMode = Schema.RequiredMode.REQUIRED, example = "1天")
private String overtimeDuration;
@Schema(description = "加班原因", requiredMode = Schema.RequiredMode.REQUIRED)
private String overtimeReason;
@Schema(description = "加班内容", requiredMode = Schema.RequiredMode.REQUIRED)
private String overtimeContent;
@Schema(description = "审核人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
private Long approverId;
@Schema(description = "审核人姓名", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四")
private String approverName;
@Schema(description = "状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "pending")
private String statusCode;
@Schema(description = "状态名称", example = "待审批")
private String statusName;
@Schema(description = "当前状态是否允许编辑", example = "false")
private Boolean allowEdit;
@Schema(description = "是否终态", example = "false")
private Boolean terminal;
@Schema(description = "最近一次审核意见")
private String approvalComment;
@Schema(description = "提交时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime submitTime;
@Schema(description = "最近一次审核时间")
private LocalDateTime approvalTime;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;
@Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,37 @@
package com.njcn.rdms.module.project.controller.admin.overtime.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDate;
@Schema(description = "管理后台 - 加班申请保存 Request VO")
@Data
public class OvertimeApplicationSaveReqVO {
@Schema(description = "加班日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06-01")
@NotNull(message = "加班日期不能为空")
private LocalDate overtimeDate;
@Schema(description = "加班时长", requiredMode = Schema.RequiredMode.REQUIRED, example = "1天")
@NotBlank(message = "加班时长不能为空")
@Size(max = 30, message = "加班时长长度不能超过30个字符")
private String overtimeDuration;
@Schema(description = "加班原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "版本上线保障")
@NotBlank(message = "加班原因不能为空")
@Size(max = 500, message = "加班原因长度不能超过500个字符")
private String overtimeReason;
@Schema(description = "加班内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "处理上线验证和问题修复")
@NotBlank(message = "加班内容不能为空")
@Size(max = 1000, message = "加班内容长度不能超过1000个字符")
private String overtimeContent;
@Schema(description = "审核人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
@NotNull(message = "审核人不能为空")
private Long approverId;
}

View File

@@ -0,0 +1,15 @@
package com.njcn.rdms.module.project.controller.admin.overtime.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - 加班申请状态动作 Request VO")
@Data
public class OvertimeApplicationStatusActionReqVO {
@Schema(description = "动作原因或审核意见。是否必填以状态机配置为准;当前退回必填,撤销选填",
example = "请补充加班内容")
@Size(max = 1000, message = "动作原因长度不能超过 1000 个字符")
private String reason;
}

View File

@@ -0,0 +1,30 @@
package com.njcn.rdms.module.project.controller.admin.overtime.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 管理后台 - 加班申请状态字典 Response VO
*/
@Schema(description = "管理后台 - 加班申请状态字典 Response VO")
@Data
public class OvertimeApplicationStatusDictRespVO {
@Schema(description = "状态编码", example = "pending")
private String statusCode;
@Schema(description = "状态名称", example = "待审批")
private String statusName;
@Schema(description = "排序值", example = "10")
private Integer sort;
@Schema(description = "是否初始状态", example = "true")
private Boolean initialFlag;
@Schema(description = "是否终态", example = "false")
private Boolean terminalFlag;
@Schema(description = "是否允许编辑", example = "false")
private Boolean allowEdit;
}

View File

@@ -0,0 +1,51 @@
package com.njcn.rdms.module.project.controller.admin.overtime.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - 加班申请状态日志 Response VO")
@Data
public class OvertimeApplicationStatusLogRespVO {
@Schema(description = "日志编号", requiredMode = Schema.RequiredMode.REQUIRED)
private Long id;
@Schema(description = "加班申请编号", requiredMode = Schema.RequiredMode.REQUIRED)
private Long applicationId;
@Schema(description = "动作编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "approve")
private String actionType;
@Schema(description = "变更前状态", example = "pending")
private String fromStatus;
@Schema(description = "变更后状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "approved")
private String toStatus;
@Schema(description = "原因或审核意见")
private String reason;
@Schema(description = "操作人用户编号", requiredMode = Schema.RequiredMode.REQUIRED)
private Long operatorUserId;
@Schema(description = "操作人名称", requiredMode = Schema.RequiredMode.REQUIRED)
private String operatorName;
@Schema(description = "申请人姓名快照", requiredMode = Schema.RequiredMode.REQUIRED)
private String applicantNameSnapshot;
@Schema(description = "加班日期快照", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDate overtimeDateSnapshot;
@Schema(description = "加班时长快照", requiredMode = Schema.RequiredMode.REQUIRED)
private String overtimeDurationSnapshot;
@Schema(description = "备注")
private String remark;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;
}

View File

@@ -46,14 +46,14 @@ public class PersonalItemController {
@PostMapping @PostMapping
@Operation(summary = "创建个人事项") @Operation(summary = "创建个人事项")
@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_CREATE + "')") //@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_CREATE + "')")
public CommonResult<Long> create(@Valid @RequestBody PersonalItemSaveReqVO reqVO) { public CommonResult<Long> create(@Valid @RequestBody PersonalItemSaveReqVO reqVO) {
return success(personalItemService.createItem(reqVO)); return success(personalItemService.createItem(reqVO));
} }
@PutMapping("/{id}") @PutMapping("/{id}")
@Operation(summary = "更新个人事项") @Operation(summary = "更新个人事项")
@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_UPDATE + "')") //@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_UPDATE + "')")
public CommonResult<Boolean> update(@PathVariable("id") Long id, public CommonResult<Boolean> update(@PathVariable("id") Long id,
@Valid @RequestBody PersonalItemSaveReqVO reqVO) { @Valid @RequestBody PersonalItemSaveReqVO reqVO) {
personalItemService.updateItem(id, reqVO); personalItemService.updateItem(id, reqVO);
@@ -62,21 +62,21 @@ public class PersonalItemController {
@GetMapping("/{id}") @GetMapping("/{id}")
@Operation(summary = "获取个人事项详情") @Operation(summary = "获取个人事项详情")
@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_QUERY + "')") //@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_QUERY + "')")
public CommonResult<PersonalItemRespVO> get(@PathVariable("id") Long id) { public CommonResult<PersonalItemRespVO> get(@PathVariable("id") Long id) {
return success(personalItemService.getItemRespVO(id)); return success(personalItemService.getItemRespVO(id));
} }
@GetMapping("/page") @GetMapping("/page")
@Operation(summary = "获取个人事项分页") @Operation(summary = "获取个人事项分页")
@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_QUERY + "')") //@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_QUERY + "')")
public CommonResult<PageResult<PersonalItemRespVO>> page(@Valid PersonalItemPageReqVO reqVO) { public CommonResult<PageResult<PersonalItemRespVO>> page(@Valid PersonalItemPageReqVO reqVO) {
return success(personalItemService.getItemRespVOPage(reqVO)); return success(personalItemService.getItemRespVOPage(reqVO));
} }
@PostMapping("/{id}/change-status") @PostMapping("/{id}/change-status")
@Operation(summary = "变更个人事项状态") @Operation(summary = "变更个人事项状态")
@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_STATUS + "')") //@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_STATUS + "')")
public CommonResult<Boolean> changeStatus(@PathVariable("id") Long id, public CommonResult<Boolean> changeStatus(@PathVariable("id") Long id,
@Valid @RequestBody PersonalItemStatusActionReqVO reqVO) { @Valid @RequestBody PersonalItemStatusActionReqVO reqVO) {
personalItemService.changeStatus(id, reqVO); personalItemService.changeStatus(id, reqVO);
@@ -85,7 +85,7 @@ public class PersonalItemController {
@PostMapping("/{id}/worklogs") @PostMapping("/{id}/worklogs")
@Operation(summary = "新增个人事项工作日志") @Operation(summary = "新增个人事项工作日志")
@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_UPDATE + "')") //@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_UPDATE + "')")
public CommonResult<Long> createWorklog(@PathVariable("id") Long id, public CommonResult<Long> createWorklog(@PathVariable("id") Long id,
@Valid @RequestBody TaskWorklogSaveReqVO reqVO) { @Valid @RequestBody TaskWorklogSaveReqVO reqVO) {
return success(personalItemService.createWorklog(id, reqVO)); return success(personalItemService.createWorklog(id, reqVO));
@@ -93,7 +93,7 @@ public class PersonalItemController {
@GetMapping("/{id}/worklogs") @GetMapping("/{id}/worklogs")
@Operation(summary = "获取个人事项工作日志分页") @Operation(summary = "获取个人事项工作日志分页")
@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_QUERY + "')") //@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_QUERY + "')")
public CommonResult<PageResult<TaskWorklogRespVO>> getWorklogPage(@PathVariable("id") Long id, public CommonResult<PageResult<TaskWorklogRespVO>> getWorklogPage(@PathVariable("id") Long id,
@Valid TaskWorklogPageReqVO reqVO) { @Valid TaskWorklogPageReqVO reqVO) {
return success(personalItemService.getWorklogPage(id, reqVO)); return success(personalItemService.getWorklogPage(id, reqVO));
@@ -101,7 +101,7 @@ public class PersonalItemController {
@PutMapping("/{id}/worklogs/{worklogId}") @PutMapping("/{id}/worklogs/{worklogId}")
@Operation(summary = "修改个人事项工作日志") @Operation(summary = "修改个人事项工作日志")
@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_UPDATE + "')") //@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_UPDATE + "')")
public CommonResult<Boolean> updateWorklog(@PathVariable("id") Long id, public CommonResult<Boolean> updateWorklog(@PathVariable("id") Long id,
@PathVariable("worklogId") Long worklogId, @PathVariable("worklogId") Long worklogId,
@Valid @RequestBody TaskWorklogSaveReqVO reqVO) { @Valid @RequestBody TaskWorklogSaveReqVO reqVO) {
@@ -111,7 +111,7 @@ public class PersonalItemController {
@DeleteMapping("/{id}/worklogs/{worklogId}") @DeleteMapping("/{id}/worklogs/{worklogId}")
@Operation(summary = "删除个人事项工作日志") @Operation(summary = "删除个人事项工作日志")
@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_UPDATE + "')") //@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_UPDATE + "')")
public CommonResult<Boolean> deleteWorklog(@PathVariable("id") Long id, public CommonResult<Boolean> deleteWorklog(@PathVariable("id") Long id,
@PathVariable("worklogId") Long worklogId) { @PathVariable("worklogId") Long worklogId) {
personalItemService.deleteWorklog(id, worklogId); personalItemService.deleteWorklog(id, worklogId);
@@ -120,7 +120,7 @@ public class PersonalItemController {
@DeleteMapping("/{id}/worklogs/delete-list") @DeleteMapping("/{id}/worklogs/delete-list")
@Operation(summary = "批量删除个人事项工作日志") @Operation(summary = "批量删除个人事项工作日志")
@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_UPDATE + "')") //@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_UPDATE + "')")
public CommonResult<Boolean> deleteWorklogs(@PathVariable("id") Long id, public CommonResult<Boolean> deleteWorklogs(@PathVariable("id") Long id,
@RequestParam("ids") List<Long> ids) { @RequestParam("ids") List<Long> ids) {
personalItemService.deleteWorklogs(id, ids); personalItemService.deleteWorklogs(id, ids);
@@ -129,7 +129,7 @@ public class PersonalItemController {
@DeleteMapping("/delete") @DeleteMapping("/delete")
@Operation(summary = "删除个人事项") @Operation(summary = "删除个人事项")
@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_DELETE + "')") //@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_DELETE + "')")
public CommonResult<Boolean> delete(@RequestParam("id") Long id) { public CommonResult<Boolean> delete(@RequestParam("id") Long id) {
personalItemService.deleteItem(id); personalItemService.deleteItem(id);
return success(true); return success(true);
@@ -137,7 +137,7 @@ public class PersonalItemController {
@DeleteMapping("/delete-list") @DeleteMapping("/delete-list")
@Operation(summary = "批量删除个人事项") @Operation(summary = "批量删除个人事项")
@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_DELETE + "')") //@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_DELETE + "')")
public CommonResult<Boolean> deleteList(@RequestParam("ids") List<Long> ids) { public CommonResult<Boolean> deleteList(@RequestParam("ids") List<Long> ids) {
personalItemService.deleteItems(ids); personalItemService.deleteItems(ids);
return success(true); return success(true);
@@ -145,7 +145,7 @@ public class PersonalItemController {
@PostMapping("/relate-execution") @PostMapping("/relate-execution")
@Operation(summary = "批量个人事项关联执行") @Operation(summary = "批量个人事项关联执行")
@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_UPDATE + "')") //@PreAuthorize("@ss.hasPermission('" + PersonalItemConstants.PERMISSION_UPDATE + "')")
public CommonResult<Boolean> relateExecution(@RequestParam("itemIds") List<Long> itemIds, public CommonResult<Boolean> relateExecution(@RequestParam("itemIds") List<Long> itemIds,
@RequestParam("executionId") Long executionId) { @RequestParam("executionId") Long executionId) {
personalItemService.relateExecution(itemIds, executionId); personalItemService.relateExecution(itemIds, executionId);

View File

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

View File

@@ -1,6 +1,7 @@
package com.njcn.rdms.module.project.controller.admin.project.execution; 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.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageParam;
import com.njcn.rdms.framework.common.pojo.PageResult; import com.njcn.rdms.framework.common.pojo.PageResult;
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.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.ProjectExecutionPageReqVO;
@@ -65,6 +66,11 @@ public class ProjectExecutionController {
@Operation(summary = "获取执行分页") @Operation(summary = "获取执行分页")
public CommonResult<PageResult<ProjectExecutionRespVO>> getExecutionPage(@PathVariable("projectId") Long projectId, public CommonResult<PageResult<ProjectExecutionRespVO>> getExecutionPage(@PathVariable("projectId") Long projectId,
@Valid ProjectExecutionPageReqVO reqVO) { @Valid ProjectExecutionPageReqVO reqVO) {
// 前端用负数 pageSize 表示"查询全部",统一归一为框架约定的 PAGE_SIZE_NONE(-1)
// 走 BaseMapperX.selectPage 的不分页短路;@Max(200) 仅拦上界,负数不会被 validator 卡。
if (reqVO.getPageSize() != null && reqVO.getPageSize() < 0) {
reqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
}
return success(projectExecutionService.getExecutionRespVOPage(projectId, reqVO)); return success(projectExecutionService.getExecutionRespVOPage(projectId, reqVO));
} }

View File

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

View File

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

View File

@@ -23,7 +23,10 @@ public class ProjectExecutionPageReqVO extends PageParam {
@Size(max = 32, message = "执行类型长度不能超过32个字符") @Size(max = 32, message = "执行类型长度不能超过32个字符")
private String executionType; private String executionType;
@Schema(description = "执行负责人用户编号", example = "3001") @Schema(description = "我参与语义:该 userId 是 owner 或活跃协办;与 ownerId 二选一;不传 = 项目内全部执行")
private Long involveUserId;
@Schema(description = "仅作为 owner 匹配(不含协办);与 involveUserId 二选一", example = "3001")
private Long ownerId; private Long ownerId;
@Schema(description = "执行状态编码", example = "pending") @Schema(description = "执行状态编码", example = "pending")
@@ -38,4 +41,8 @@ public class ProjectExecutionPageReqVO extends PageParam {
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] updateTime; private LocalDateTime[] updateTime;
@Schema(description = "截止时间范围 chipoverdue逾期/ today今天到期/ thisWeek本周到期" +
"基于 plannedEndDate 且排除终态执行;不传 = 不按截止时间过滤", example = "overdue")
private String dueRange;
} }

View File

@@ -22,11 +22,18 @@ public class ProjectExecutionStatusBoardReqVO {
@Size(max = 32, message = "执行类型长度不能超过32个字符") @Size(max = 32, message = "执行类型长度不能超过32个字符")
private String executionType; private String executionType;
@Schema(description = "执行负责人用户编号", example = "3001") @Schema(description = "我参与语义:该 userId 是 owner 或活跃协办;与 ownerId 二选一;不传 = 项目内全部执行")
private Long involveUserId;
@Schema(description = "仅作为 owner 匹配(不含协办);与 involveUserId 二选一", example = "3001")
private Long ownerId; private Long ownerId;
@Schema(description = "更新时间", example = "[2026-05-01 00:00:00, 2026-05-31 23:59:59]") @Schema(description = "更新时间", example = "[2026-05-01 00:00:00, 2026-05-31 23:59:59]")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] updateTime; private LocalDateTime[] updateTime;
@Schema(description = "截止时间范围 chipoverdue逾期/ today今天到期/ thisWeek本周到期" +
"基于 plannedEndDate 且排除终态执行;不传 = 不按截止时间过滤", example = "overdue")
private String dueRange;
} }

View File

@@ -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<PageResult<MyProjectParticipatedRespVO>> getMyParticipatedPage(@Valid MyProjectPageReqVO reqVO) {
normalizePageSize(reqVO);
return success(myProjectService.getMyParticipatedPage(reqVO));
}
@GetMapping("/owned/page")
@Operation(summary = "分页获取当前登录用户负责的项目managerUserId=当前用户)")
public CommonResult<PageResult<MyProjectOwnedRespVO>> 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);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,66 @@
package com.njcn.rdms.module.project.controller.admin.project.task;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskAggregateBoardPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskAggregatePageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskAggregateStatusBoardReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskSummaryReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskSummaryRespVO;
import com.njcn.rdms.module.project.service.project.task.ProjectTaskAggregateService;
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.PathVariable;
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/{projectId}/tasks")
@Validated
public class ProjectTaskAggregateController {
@Resource
private ProjectTaskAggregateService projectTaskAggregateService;
@GetMapping("/page")
@Operation(summary = "获取项目级跨执行任务分页")
public CommonResult<PageResult<ProjectTaskRespVO>> getTaskPage(
@PathVariable("projectId") Long projectId,
@Valid ProjectTaskAggregatePageReqVO reqVO) {
return success(projectTaskAggregateService.getAggregateTaskPage(projectId, reqVO));
}
@GetMapping("/status-board")
@Operation(summary = "获取项目级跨执行任务状态看板")
public CommonResult<ProjectTaskStatusBoardRespVO> getTaskStatusBoard(
@PathVariable("projectId") Long projectId,
@Valid ProjectTaskAggregateStatusBoardReqVO reqVO) {
return success(projectTaskAggregateService.getAggregateTaskStatusBoard(projectId, reqVO));
}
@GetMapping("/board-page")
@Operation(summary = "获取项目级跨执行任务看板分页")
public CommonResult<ProjectTaskBoardPageRespVO> getTaskBoardPage(
@PathVariable("projectId") Long projectId,
@Valid ProjectTaskAggregateBoardPageReqVO reqVO) {
return success(projectTaskAggregateService.getAggregateTaskBoardPage(projectId, reqVO));
}
@GetMapping("/summary")
@Operation(summary = "获取项目任务今日小条involveUserId 控制是否限定 owner / 活跃协办)")
public CommonResult<ProjectTaskSummaryRespVO> getTaskSummary(
@PathVariable("projectId") Long projectId,
@Valid ProjectTaskSummaryReqVO reqVO) {
return success(projectTaskAggregateService.getAggregateTaskSummary(projectId, reqVO));
}
}

View File

@@ -36,7 +36,10 @@ public class ProjectTaskBoardPageReqVO extends PageParam {
@Schema(description = "父任务编号", example = "9001") @Schema(description = "父任务编号", example = "9001")
private Long parentTaskId; private Long parentTaskId;
@Schema(description = "任务负责人用户编号", example = "3002") @Schema(description = "我参与语义:该 userId 是 owner 或活跃协办;与 ownerId 二选一;不传 = 执行下全部任务")
private Long involveUserId;
@Schema(description = "仅作为 owner 匹配(不含协办);与 involveUserId 二选一", example = "3002")
private Long ownerId; private Long ownerId;
@Schema(description = "更新时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") @Schema(description = "更新时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]")

View File

@@ -22,7 +22,10 @@ public class ProjectTaskPageReqVO extends PageParam {
@Schema(description = "父任务编号") @Schema(description = "父任务编号")
private Long parentTaskId; private Long parentTaskId;
@Schema(description = "任务负责人用户编号", example = "3002") @Schema(description = "我参与语义:该 userId 是 owner 或活跃协办;与 ownerId 二选一;不传 = 执行下全部任务")
private Long involveUserId;
@Schema(description = "仅作为 owner 匹配(不含协办);与 involveUserId 二选一", example = "3002")
private Long ownerId; private Long ownerId;
@Schema(description = "任务状态编码", example = "pending") @Schema(description = "任务状态编码", example = "pending")

View File

@@ -21,6 +21,10 @@ public class ProjectTaskRespVO {
private Long projectId; private Long projectId;
@Schema(description = "所属执行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001") @Schema(description = "所属执行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001")
private Long executionId; private Long executionId;
@Schema(description = "所属执行名称", example = "迭代 V1.0")
private String executionName;
@Schema(description = "所属执行状态码")
private String executionStatusCode;
@Schema(description = "所属执行关联的项目需求编号service 层批量回填)") @Schema(description = "所属执行关联的项目需求编号service 层批量回填)")
private Long projectRequirementId; private Long projectRequirementId;
@Schema(description = "项目需求名称service 层批量回填,避免 N+1", example = "前端联调-需求A") @Schema(description = "项目需求名称service 层批量回填,避免 N+1", example = "前端联调-需求A")

View File

@@ -21,7 +21,10 @@ public class ProjectTaskStatusBoardReqVO {
@Schema(description = "父任务编号", example = "9001") @Schema(description = "父任务编号", example = "9001")
private Long parentTaskId; private Long parentTaskId;
@Schema(description = "任务负责人用户编号", example = "3002") @Schema(description = "我参与语义:该 userId 是 owner 或活跃协办;与 ownerId 二选一;不传 = 执行下全部任务")
private Long involveUserId;
@Schema(description = "仅作为 owner 匹配(不含协办);与 involveUserId 二选一", example = "3002")
private Long ownerId; private Long ownerId;
@Schema(description = "更新时间", example = "[2026-05-01 00:00:00, 2026-05-31 23:59:59]") @Schema(description = "更新时间", example = "[2026-05-01 00:00:00, 2026-05-31 23:59:59]")

View File

@@ -0,0 +1,54 @@
package com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate;
import com.njcn.rdms.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import java.util.List;
import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - 项目级跨执行任务看板分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class ProjectTaskAggregateBoardPageReqVO extends PageParam {
@Schema(description = "任务名称模糊匹配关键字")
private String keyword;
@Schema(description = "限定执行 id 列表;空数组 = 明确返空;不传 = 项目内全部执行")
private List<Long> executionIds;
@Schema(description = "限定任务所属执行的状态码多选;空数组 = 明确返空;不传 = 不按执行状态过滤")
private List<String> executionStatusCodes;
@Schema(description = "我参与语义;与 ownerId 二选一")
private Long involveUserId;
@Schema(description = "执行成员语义:该 userId 是执行 owner 或活跃执行协办;过滤其参与的执行下的任务。与 involveUserId(任务成员)正交,可同传;用户未参与任何执行时返空")
private Long executionInvolveUserId;
@Schema(description = "仅作为 owner 匹配;与 involveUserId 二选一")
private Long ownerId;
@Schema(description = "限定状态列(板上显示哪些列);空 / 不传 = 字典全状态")
private List<String> statusCodes;
@Schema(description = "优先级")
@Size(max = 8)
private String priority;
@Schema(description = "父任务 id")
private Long parentTaskId;
@Schema(description = "到期范围 chip:overdue / today / thisWeek")
private String dueRange;
@Schema(description = "更新时间范围")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] updateTime;
}

View File

@@ -0,0 +1,60 @@
package com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate;
import com.njcn.rdms.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import java.util.List;
import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - 项目级跨执行任务分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class ProjectTaskAggregatePageReqVO extends PageParam {
@Schema(description = "任务名称模糊匹配关键字")
private String keyword;
@Schema(description = "限定执行 id 列表;空数组 = 明确返空;不传 = 项目内全部执行")
private List<Long> executionIds;
@Schema(description = "限定任务所属执行的状态码多选;空数组 = 明确返空;不传 = 不按执行状态过滤")
private List<String> executionStatusCodes;
@Schema(description = "我参与语义:该 userId 是 owner 或活跃协办;与 ownerId 二选一")
private Long involveUserId;
@Schema(description = "执行成员语义:该 userId 是执行 owner 或活跃执行协办;过滤其参与的执行下的任务。与 involveUserId(任务成员)正交,可同传;用户未参与任何执行时返空")
private Long executionInvolveUserId;
@Schema(description = "仅作为 owner 匹配(不含协办);与 involveUserId 二选一")
private Long ownerId;
@Schema(description = "状态码多选;空 / 不传 = 全部")
private List<String> statusCodes;
@Schema(description = "优先级字典 value (0~3)")
@Size(max = 8)
private String priority;
@Schema(description = "父任务 id(限定到某父任务下)")
private Long parentTaskId;
@Schema(description = "到期范围 chip:overdue / today / thisWeek")
private String dueRange;
@Schema(description = "更新时间范围(2 长度数组)")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] updateTime;
@Schema(description = "排序字段:plannedEndDate / priority / updateTime / createTime(默认 plannedEndDate)")
private String sortBy;
@Schema(description = "排序方向:asc / desc(默认 asc)")
private String sortOrder;
}

View File

@@ -0,0 +1,48 @@
package com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import java.util.List;
import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - 项目级跨执行任务状态看板 Request VO(入参同 page 去掉分页 / statusCodes / sort)")
@Data
public class ProjectTaskAggregateStatusBoardReqVO {
@Schema(description = "任务名称模糊匹配关键字")
private String keyword;
@Schema(description = "限定执行 id 列表;空数组 = 明确返空;不传 = 项目内全部执行")
private List<Long> executionIds;
@Schema(description = "限定任务所属执行的状态码多选;空数组 = 明确返空;不传 = 不按执行状态过滤")
private List<String> executionStatusCodes;
@Schema(description = "我参与语义;与 ownerId 二选一")
private Long involveUserId;
@Schema(description = "执行成员语义:该 userId 是执行 owner 或活跃执行协办;过滤其参与的执行下的任务。与 involveUserId(任务成员)正交,可同传;用户未参与任何执行时返空")
private Long executionInvolveUserId;
@Schema(description = "仅作为 owner 匹配;与 involveUserId 二选一")
private Long ownerId;
@Schema(description = "优先级")
@Size(max = 8)
private String priority;
@Schema(description = "父任务 id")
private Long parentTaskId;
@Schema(description = "到期范围 chip:overdue / today / thisWeek")
private String dueRange;
@Schema(description = "更新时间范围")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] updateTime;
}

View File

@@ -0,0 +1,16 @@
package com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - 项目任务今日小条 Request VO")
@Data
public class ProjectTaskSummaryReqVO {
/**
* 我参与语义:传入的 userId 是 owner 或活跃协办;不传 = 项目内全部任务。
* 切换"我参与 / 所有"由前端直接控制此字段是否携带与其他读接口page / status-board / board-page契约一致。
*/
@Schema(description = "我参与语义:该 userId 是 owner 或活跃协办;不传 = 项目内全部")
private Long involveUserId;
}

View File

@@ -0,0 +1,32 @@
package com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDate;
@Schema(description = "管理后台 - 项目任务今日小条 Response VO")
@Data
public class ProjectTaskSummaryRespVO {
@Schema(description = "逾期任务数:计划完成日 < 今天,且任务状态非终态")
private Long overdue;
@Schema(description = "今日截止任务数:计划完成日 = 今天,且任务状态非终态")
private Long dueToday;
@Schema(description = "本周到期任务数:计划完成日 ∈ [本周一, 本周日],且任务状态非终态(与 chip thisWeek 过滤同口径)")
private Long dueThisWeek;
@Schema(description = "本周已完成任务数:actualEndDate ∈ [本周一, 今天],且任务状态 = completed")
private Long doneThisWeek;
@Schema(description = "服务器当日(YYYY-MM-DD,Asia/Shanghai)", example = "2026-05-22")
private LocalDate today;
@Schema(description = "服务器本周一(YYYY-MM-DD,Asia/Shanghai)", example = "2026-05-18")
private LocalDate weekStart;
@Schema(description = "服务器本周日(YYYY-MM-DD,Asia/Shanghai)", example = "2026-05-24")
private LocalDate weekEnd;
}

View File

@@ -0,0 +1,51 @@
package com.njcn.rdms.module.project.dal.dataobject.overtime;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 加班申请表。
*/
@TableName("rdms_overtime_application")
@Data
@EqualsAndHashCode(callSuper = true)
public class OvertimeApplicationDO extends BaseDO {
@TableId
private Long id;
private Long applicantId;
private String applicantName;
private LocalDate overtimeDate;
private String overtimeDuration;
private String overtimeReason;
private String overtimeContent;
private Long approverId;
private String approverName;
private String statusCode;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String approvalComment;
private LocalDateTime submitTime;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private LocalDateTime approvalTime;
}

View File

@@ -0,0 +1,44 @@
package com.njcn.rdms.module.project.dal.dataobject.overtime;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDate;
/**
* 加班申请状态日志表。
*/
@TableName("rdms_overtime_application_status_log")
@Data
@EqualsAndHashCode(callSuper = true)
public class OvertimeApplicationStatusLogDO extends BaseDO {
@TableId
private Long id;
private Long applicationId;
private String actionType;
private String fromStatus;
private String toStatus;
private String reason;
private Long operatorUserId;
private String operatorName;
private String applicantNameSnapshot;
private LocalDate overtimeDateSnapshot;
private String overtimeDurationSnapshot;
private String remark;
}

View File

@@ -111,4 +111,18 @@ public interface UserObjectRoleMapper extends BaseMapperX<UserObjectRoleDO> {
.eq(UserObjectRoleDO::getStatus, 0)); .eq(UserObjectRoleDO::getStatus, 0));
} }
/**
* 工作台「我负责的项目」批量查一批对象下的活跃成员角色行status=0
* 一次拿全,内存按 objectId 分组,避免逐项目 N+1。
*/
default List<UserObjectRoleDO> selectActiveListByObjectTypeAndObjectIds(String objectType, Collection<Long> objectIds) {
if (objectIds == null || objectIds.isEmpty()) {
return Collections.emptyList();
}
return selectList(new LambdaQueryWrapperX<UserObjectRoleDO>()
.eq(UserObjectRoleDO::getObjectType, objectType)
.in(UserObjectRoleDO::getObjectId, objectIds)
.eq(UserObjectRoleDO::getStatus, 0));
}
} }

View File

@@ -0,0 +1,76 @@
package com.njcn.rdms.module.project.dal.mysql.overtime;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationPageReqVO;
import com.njcn.rdms.module.project.dal.dataobject.overtime.OvertimeApplicationDO;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.util.StringUtils;
import java.util.Collection;
@Mapper
public interface OvertimeApplicationMapper extends BaseMapperX<OvertimeApplicationDO> {
default PageResult<OvertimeApplicationDO> selectMyPage(Long applicantId, OvertimeApplicationPageReqVO reqVO) {
LambdaQueryWrapperX<OvertimeApplicationDO> queryWrapper = buildPageQuery(reqVO);
queryWrapper.eq(OvertimeApplicationDO::getApplicantId, applicantId);
queryWrapper.orderByDesc(OvertimeApplicationDO::getSubmitTime)
.orderByDesc(OvertimeApplicationDO::getId);
return selectPage(reqVO, queryWrapper);
}
default PageResult<OvertimeApplicationDO> selectApprovalPage(Long approverId, OvertimeApplicationPageReqVO reqVO) {
LambdaQueryWrapperX<OvertimeApplicationDO> queryWrapper = buildPageQuery(reqVO);
queryWrapper.eq(OvertimeApplicationDO::getApproverId, approverId);
queryWrapper.orderByDesc(OvertimeApplicationDO::getSubmitTime)
.orderByDesc(OvertimeApplicationDO::getId);
return selectPage(reqVO, queryWrapper);
}
default OvertimeApplicationDO selectByIdAndApplicantId(Long id, Long applicantId) {
return selectOne(new LambdaQueryWrapperX<OvertimeApplicationDO>()
.eq(OvertimeApplicationDO::getId, id)
.eq(OvertimeApplicationDO::getApplicantId, applicantId));
}
default int updateByIdAndStatus(OvertimeApplicationDO update, Long id, String fromStatus) {
return update(update, new LambdaQueryWrapperX<OvertimeApplicationDO>()
.eq(OvertimeApplicationDO::getId, id)
.eq(OvertimeApplicationDO::getStatusCode, fromStatus));
}
default int updateByIdAndStatusAndApplicantId(OvertimeApplicationDO update, Long id, String fromStatus,
Long applicantId) {
return update(update, new LambdaQueryWrapperX<OvertimeApplicationDO>()
.eq(OvertimeApplicationDO::getId, id)
.eq(OvertimeApplicationDO::getStatusCode, fromStatus)
.eq(OvertimeApplicationDO::getApplicantId, applicantId));
}
default int updateByIdAndStatusesAndApplicantId(OvertimeApplicationDO update, Long id,
Collection<String> fromStatuses, Long applicantId) {
return update(update, new LambdaQueryWrapperX<OvertimeApplicationDO>()
.eq(OvertimeApplicationDO::getId, id)
.in(OvertimeApplicationDO::getStatusCode, fromStatuses)
.eq(OvertimeApplicationDO::getApplicantId, applicantId));
}
private LambdaQueryWrapperX<OvertimeApplicationDO> buildPageQuery(OvertimeApplicationPageReqVO reqVO) {
LambdaQueryWrapperX<OvertimeApplicationDO> queryWrapper = new LambdaQueryWrapperX<>();
queryWrapper.likeIfPresent(OvertimeApplicationDO::getApplicantName, reqVO.getApplicantName())
.eqIfPresent(OvertimeApplicationDO::getApproverId, reqVO.getApproverId())
.likeIfPresent(OvertimeApplicationDO::getApproverName, reqVO.getApproverName())
.eqIfPresent(OvertimeApplicationDO::getStatusCode, reqVO.getStatusCode())
.betweenIfPresent(OvertimeApplicationDO::getOvertimeDate, reqVO.getOvertimeDate())
.betweenIfPresent(BaseDO::getCreateTime, reqVO.getCreateTime());
if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(OvertimeApplicationDO::getOvertimeReason, reqVO.getKeyword())
.or()
.like(OvertimeApplicationDO::getOvertimeContent, reqVO.getKeyword()));
}
return queryWrapper;
}
}

View File

@@ -0,0 +1,19 @@
package com.njcn.rdms.module.project.dal.mysql.overtime;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.dal.dataobject.overtime.OvertimeApplicationStatusLogDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface OvertimeApplicationStatusLogMapper extends BaseMapperX<OvertimeApplicationStatusLogDO> {
default List<OvertimeApplicationStatusLogDO> selectListByApplicationId(Long applicationId) {
return selectList(new LambdaQueryWrapperX<OvertimeApplicationStatusLogDO>()
.eq(OvertimeApplicationStatusLogDO::getApplicationId, applicationId)
.orderByDesc(OvertimeApplicationStatusLogDO::getCreateTime)
.orderByDesc(OvertimeApplicationStatusLogDO::getId));
}
}

View File

@@ -73,4 +73,14 @@ public interface ProjectMapper extends BaseMapperX<ProjectDO> {
.eq(ProjectDO::getStatusCode, statusCode)); .eq(ProjectDO::getStatusCode, statusCode));
} }
/**
* 仅更新项目 progressRate不动其他字段避免污染 lastStatusReason 等)。
*/
default int updateProgressRateById(Long id, java.math.BigDecimal progressRate) {
ProjectDO update = new ProjectDO();
update.setProgressRate(progressRate);
return update(update, new LambdaQueryWrapperX<ProjectDO>()
.eq(ProjectDO::getId, id));
}
} }

View File

@@ -4,8 +4,6 @@ import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX; import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List; import java.util.List;
@@ -50,23 +48,6 @@ public interface ExecutionAssigneeMapper extends BaseMapperX<ExecutionAssigneeDO
.isNull(ExecutionAssigneeDO::getRemovedAt)); .isNull(ExecutionAssigneeDO::getRemovedAt));
} }
/**
* 查 userId 当前在指定项目下,活跃协办的所有执行 IDremoved_at IS NULL
* 走 JOIN 是因为 execution_assignee 表没有 project_id 冗余字段。
* 用于 VisibilityScopeResolver 收集"我是执行协办人"的 scope 来源。
*/
@Select("""
SELECT a.execution_id
FROM rdms_execution_assignee a
JOIN rdms_project_execution e ON e.id = a.execution_id AND e.deleted = b'0'
WHERE a.deleted = b'0'
AND a.removed_at IS NULL
AND e.project_id = #{projectId}
AND a.user_id = #{userId}
""")
List<Long> selectActiveExecutionIdsByProjectIdAndUserId(@Param("projectId") Long projectId,
@Param("userId") Long userId);
/** /**
* 按 execution_id 批量软删执行协办(含已 removed 的历史段)。 * 按 execution_id 批量软删执行协办(含已 removed 的历史段)。
*/ */

View File

@@ -1,5 +1,7 @@
package com.njcn.rdms.module.project.dal.mysql.project.execution; package com.njcn.rdms.module.project.dal.mysql.project.execution;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.rdms.framework.common.pojo.PageResult; import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX; import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
@@ -7,12 +9,11 @@ import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardReqVO;
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.ProjectExecutionPageReqVO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -33,44 +34,97 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
} }
default PageResult<ProjectExecutionDO> selectPageByProjectId(Long projectId, default PageResult<ProjectExecutionDO> selectPageByProjectId(Long projectId,
VisibilityScope scope, ProjectExecutionPageReqVO reqVO,
ProjectExecutionPageReqVO reqVO) { List<String> terminalStatusCodes,
// 可见性短路:非 seesAll 且无任何可见执行 → 空页,避免后续 IN () SQL LocalDate today,
if (!scope.seesAll() && scope.executionIds().isEmpty()) { LocalDate weekStart,
return PageResult.empty(); LocalDate weekEnd) {
} Page<ProjectExecutionDO> page = new Page<>(reqVO.getPageNo(), reqVO.getPageSize());
LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<>(); IPage<ProjectExecutionDO> ipage = doSelectPageByProjectId(
queryWrapper.eq(ProjectExecutionDO::getProjectId, projectId); projectId, reqVO, terminalStatusCodes, today, weekStart, weekEnd, page);
queryWrapper.eqIfPresent(ProjectExecutionDO::getExecutionType, reqVO.getExecutionType()); return new PageResult<>(ipage.getRecords(), ipage.getTotal());
queryWrapper.eqIfPresent(ProjectExecutionDO::getOwnerId, reqVO.getOwnerId());
queryWrapper.eqIfPresent(ProjectExecutionDO::getStatusCode, reqVO.getStatusCode());
queryWrapper.eqIfPresent(ProjectExecutionDO::getPriority, reqVO.getPriority());
queryWrapper.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime());
queryWrapper.orderByAsc(ProjectExecutionDO::getPriority);
queryWrapper.orderByDesc(BaseDO::getCreateTime);
queryWrapper.orderByDesc(ProjectExecutionDO::getId);
if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectExecutionDO::getExecutionName, reqVO.getKeyword()));
}
if (!scope.seesAll()) {
queryWrapper.in(ProjectExecutionDO::getId, scope.executionIds());
}
return selectPage(reqVO, queryWrapper);
} }
/** /**
* 查 userId 在指定项目下,作为 owner 的所有执行 ID * 项目下执行分页查询
* 用于 VisibilityScopeResolver 收集"我是执行负责人"的 scope 来源。 * <p>SQL 用 @Select 直接控制,主表以别名 t 暴露EXISTS 子查询用 t.id 关联;
* 避免 LambdaWrapper + .apply 嵌入裸 SQL 时依赖 "MyBatis-Plus 不给主表加别名" 这一实现细节。
* 与任务侧 {@code ProjectTaskMapper.selectAggregatePageByProjectId} 同款风格。
*
* <p>involveUserId 非空时附加 (owner_id = involveUserId OR 活跃协办含 involveUserId)
* 否则该过滤分支跳过("看项目下全部")。
*/ */
default List<Long> selectIdsByProjectIdAndOwnerId(Long projectId, Long userId) { @Select("""
return selectList(new LambdaQueryWrapperX<ProjectExecutionDO>() <script>
.select(ProjectExecutionDO::getId) SELECT t.*
.eq(ProjectExecutionDO::getProjectId, projectId) FROM rdms_project_execution t
.eq(ProjectExecutionDO::getOwnerId, userId)) <where>
.stream() t.deleted = b'0'
.map(ProjectExecutionDO::getId) AND t.project_id = #{projectId}
.toList(); <if test="reqVO.executionType != null and reqVO.executionType != ''">
} AND t.execution_type = #{reqVO.executionType}
</if>
<if test="reqVO.ownerId != null">
AND t.owner_id = #{reqVO.ownerId}
</if>
<if test="reqVO.statusCode != null and reqVO.statusCode != ''">
AND t.status_code = #{reqVO.statusCode}
</if>
<if test="reqVO.priority != null and reqVO.priority != ''">
AND t.priority = #{reqVO.priority}
</if>
<if test="reqVO.updateTime != null and reqVO.updateTime.length == 2">
AND t.update_time BETWEEN #{reqVO.updateTime[0]} AND #{reqVO.updateTime[1]}
</if>
<if test="reqVO.keyword != null and reqVO.keyword != ''">
AND t.execution_name LIKE CONCAT('%', #{reqVO.keyword}, '%')
</if>
<if test="reqVO.involveUserId != null">
AND (
t.owner_id = #{reqVO.involveUserId}
OR EXISTS (
SELECT 1 FROM rdms_execution_assignee a
WHERE a.execution_id = t.id
AND a.user_id = #{reqVO.involveUserId}
AND a.removed_at IS NULL
AND a.deleted = b'0'
)
)
</if>
<!-- 截止时间范围 chip基于 planned_end_date三个桶均排除终态对齐任务 summary 口径) -->
<if test="reqVO.dueRange == 'overdue'">
AND t.planned_end_date &lt; #{today}
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
</if>
<if test="reqVO.dueRange == 'today'">
AND t.planned_end_date = #{today}
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
</if>
<if test="reqVO.dueRange == 'thisWeek'">
AND t.planned_end_date BETWEEN #{weekStart} AND #{weekEnd}
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
</if>
</where>
ORDER BY t.priority ASC, t.create_time DESC, t.id DESC
</script>
""")
IPage<ProjectExecutionDO> doSelectPageByProjectId(
@Param("projectId") Long projectId,
@Param("reqVO") ProjectExecutionPageReqVO reqVO,
@Param("terminalStatusCodes") List<String> terminalStatusCodes,
@Param("today") LocalDate today,
@Param("weekStart") LocalDate weekStart,
@Param("weekEnd") LocalDate weekEnd,
Page<ProjectExecutionDO> page);
default List<ProjectExecutionDO> selectListByOwnerId(Long ownerId) { default List<ProjectExecutionDO> selectListByOwnerId(Long ownerId) {
return selectList(new LambdaQueryWrapperX<ProjectExecutionDO>() return selectList(new LambdaQueryWrapperX<ProjectExecutionDO>()
@@ -90,28 +144,86 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
} }
default Integer countByProjectIdAndStatusCode(Long projectId, default Integer countByProjectIdAndStatusCode(Long projectId,
VisibilityScope scope,
ProjectExecutionStatusBoardReqVO reqVO, ProjectExecutionStatusBoardReqVO reqVO,
String statusCode) { String statusCode,
// 可见性短路:非 seesAll 且无任何可见执行 → 计数 0 List<String> terminalStatusCodes,
if (!scope.seesAll() && scope.executionIds().isEmpty()) { LocalDate today,
return 0; LocalDate weekStart,
} LocalDate weekEnd) {
LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<ProjectExecutionDO>() return Math.toIntExact(doCountByProjectIdAndStatusCode(
.eq(ProjectExecutionDO::getProjectId, projectId) projectId, reqVO, statusCode, terminalStatusCodes, today, weekStart, weekEnd));
.eq(ProjectExecutionDO::getStatusCode, statusCode)
.eqIfPresent(ProjectExecutionDO::getExecutionType, reqVO.getExecutionType())
.eqIfPresent(ProjectExecutionDO::getOwnerId, reqVO.getOwnerId())
.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime());
if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectExecutionDO::getExecutionName, reqVO.getKeyword()));
}
if (!scope.seesAll()) {
queryWrapper.in(ProjectExecutionDO::getId, scope.executionIds());
}
return Math.toIntExact(selectCount(queryWrapper));
} }
/**
* 项目下指定状态的执行计数(与 doSelectPageByProjectId 同款过滤口径)。
* 同上:用 @Select 显式表别名 t 替代 LambdaWrapper + .apply EXISTS 写法。
*/
@Select("""
<script>
SELECT COUNT(*)
FROM rdms_project_execution t
<where>
t.deleted = b'0'
AND t.project_id = #{projectId}
AND t.status_code = #{statusCode}
<if test="reqVO.executionType != null and reqVO.executionType != ''">
AND t.execution_type = #{reqVO.executionType}
</if>
<if test="reqVO.ownerId != null">
AND t.owner_id = #{reqVO.ownerId}
</if>
<if test="reqVO.updateTime != null and reqVO.updateTime.length == 2">
AND t.update_time BETWEEN #{reqVO.updateTime[0]} AND #{reqVO.updateTime[1]}
</if>
<if test="reqVO.keyword != null and reqVO.keyword != ''">
AND t.execution_name LIKE CONCAT('%', #{reqVO.keyword}, '%')
</if>
<if test="reqVO.involveUserId != null">
AND (
t.owner_id = #{reqVO.involveUserId}
OR EXISTS (
SELECT 1 FROM rdms_execution_assignee a
WHERE a.execution_id = t.id
AND a.user_id = #{reqVO.involveUserId}
AND a.removed_at IS NULL
AND a.deleted = b'0'
)
)
</if>
<!-- 截止时间范围 chip基于 planned_end_date三个桶均排除终态对齐任务 summary 口径) -->
<if test="reqVO.dueRange == 'overdue'">
AND t.planned_end_date &lt; #{today}
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
</if>
<if test="reqVO.dueRange == 'today'">
AND t.planned_end_date = #{today}
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
</if>
<if test="reqVO.dueRange == 'thisWeek'">
AND t.planned_end_date BETWEEN #{weekStart} AND #{weekEnd}
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
</if>
</where>
</script>
""")
Long doCountByProjectIdAndStatusCode(
@Param("projectId") Long projectId,
@Param("reqVO") ProjectExecutionStatusBoardReqVO reqVO,
@Param("statusCode") String statusCode,
@Param("terminalStatusCodes") List<String> terminalStatusCodes,
@Param("today") LocalDate today,
@Param("weekStart") LocalDate weekStart,
@Param("weekEnd") LocalDate weekEnd);
/** /**
* TD-016按 projectRequirementIds 批量聚合承接执行的平均进度,避免列表 N+1。 * TD-016按 projectRequirementIds 批量聚合承接执行的平均进度,避免列表 N+1。
* <p>口径:仅统计 deleted = 0 + status_code NOT IN(excludedStatusCodes) 的执行; * <p>口径:仅统计 deleted = 0 + status_code NOT IN(excludedStatusCodes) 的执行;
@@ -165,4 +277,27 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
.eq(ProjectExecutionDO::getStatusCode, fromStatus)); .eq(ProjectExecutionDO::getStatusCode, fromStatus));
} }
/**
* 接口二:一批项目下的进行中执行数(按 project_id 分组,排除终态)。
* 返回 MapprojectId(Long) / executionCount(Long)。
*/
@Select("""
<script>
SELECT project_id AS projectId,
CAST(COUNT(*) AS SIGNED) AS executionCount
FROM rdms_project_execution
WHERE deleted = b'0'
AND project_id IN
<foreach collection="projectIds" item="pid" open="(" separator="," close=")">#{pid}</foreach>
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
GROUP BY project_id
</script>
""")
List<Map<String, Object>> selectExecutionCountGroupByProjectIds(
@Param("projectIds") Collection<Long> projectIds,
@Param("terminalStatusCodes") Collection<String> terminalStatusCodes);
} }

View File

@@ -1,17 +1,19 @@
package com.njcn.rdms.module.project.dal.mysql.project.task; package com.njcn.rdms.module.project.dal.mysql.project.task;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.rdms.framework.common.pojo.PageResult; import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX; import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX; import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskAggregatePageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskAggregateStatusBoardReqVO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO; import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope; import lombok.Data;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.springframework.util.StringUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
@@ -31,33 +33,68 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
} }
default PageResult<ProjectTaskDO> selectPageByExecutionId(Long projectId, Long executionId, default PageResult<ProjectTaskDO> selectPageByExecutionId(Long projectId, Long executionId,
VisibilityScope scope,
ProjectTaskPageReqVO reqVO) { ProjectTaskPageReqVO reqVO) {
// 可见性短路:非 seesAll 且无任何可见任务 → 空页 Page<ProjectTaskDO> page = new Page<>(reqVO.getPageNo(), reqVO.getPageSize());
if (!scope.seesAll() && scope.taskIds().isEmpty()) { IPage<ProjectTaskDO> ipage = doSelectPageByExecutionId(projectId, executionId, reqVO, page);
return PageResult.empty(); return new PageResult<>(ipage.getRecords(), ipage.getTotal());
}
LambdaQueryWrapperX<ProjectTaskDO> queryWrapper = new LambdaQueryWrapperX<>();
queryWrapper.eq(ProjectTaskDO::getProjectId, projectId);
queryWrapper.eq(ProjectTaskDO::getExecutionId, executionId);
queryWrapper.eqIfPresent(ProjectTaskDO::getParentTaskId, reqVO.getParentTaskId());
queryWrapper.eqIfPresent(ProjectTaskDO::getOwnerId, reqVO.getOwnerId());
queryWrapper.eqIfPresent(ProjectTaskDO::getStatusCode, reqVO.getStatusCode());
queryWrapper.eqIfPresent(ProjectTaskDO::getPriority, reqVO.getPriority());
queryWrapper.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime());
queryWrapper.orderByAsc(ProjectTaskDO::getParentTaskId);
queryWrapper.orderByAsc(ProjectTaskDO::getPriority);
queryWrapper.orderByDesc(BaseDO::getCreateTime);
queryWrapper.orderByDesc(ProjectTaskDO::getId);
if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectTaskDO::getTaskTitle, reqVO.getKeyword()));
}
if (!scope.seesAll()) {
queryWrapper.in(ProjectTaskDO::getId, scope.taskIds());
}
return selectPage(reqVO, queryWrapper);
} }
/**
* 执行内任务分页查询。
* <p>SQL 用 @Select 显式表别名 tEXISTS 子查询用 t.id 关联 rdms_task_assignee
* 与项目级 aggregate page 同款风格。
*
* <p>involveUserId 非空时附加 (owner_id = involveUserId OR 活跃协办含 involveUserId)
* 与 ownerId 文档标注「二选一」由前端保证(不做后端互斥校验)。
*/
@Select("""
<script>
SELECT t.*
FROM rdms_task t
<where>
t.deleted = b'0'
AND t.project_id = #{projectId}
AND t.execution_id = #{executionId}
<if test="reqVO.parentTaskId != null">
AND t.parent_task_id = #{reqVO.parentTaskId}
</if>
<if test="reqVO.ownerId != null">
AND t.owner_id = #{reqVO.ownerId}
</if>
<if test="reqVO.statusCode != null and reqVO.statusCode != ''">
AND t.status_code = #{reqVO.statusCode}
</if>
<if test="reqVO.priority != null and reqVO.priority != ''">
AND t.priority = #{reqVO.priority}
</if>
<if test="reqVO.updateTime != null and reqVO.updateTime.length == 2">
AND t.update_time BETWEEN #{reqVO.updateTime[0]} AND #{reqVO.updateTime[1]}
</if>
<if test="reqVO.keyword != null and reqVO.keyword != ''">
AND t.task_title LIKE CONCAT('%', #{reqVO.keyword}, '%')
</if>
<if test="reqVO.involveUserId != null">
AND (
t.owner_id = #{reqVO.involveUserId}
OR EXISTS (
SELECT 1 FROM rdms_task_assignee a
WHERE a.task_id = t.id
AND a.user_id = #{reqVO.involveUserId}
AND a.removed_at IS NULL
AND a.deleted = b'0'
)
)
</if>
</where>
ORDER BY t.parent_task_id ASC, t.priority ASC, t.create_time DESC, t.id DESC
</script>
""")
IPage<ProjectTaskDO> doSelectPageByExecutionId(
@Param("projectId") Long projectId,
@Param("executionId") Long executionId,
@Param("reqVO") ProjectTaskPageReqVO reqVO,
Page<ProjectTaskDO> page);
default int updateStatusByIdAndStatus(Long id, String fromStatus, String toStatus, String lastStatusReason) { default int updateStatusByIdAndStatus(Long id, String fromStatus, String toStatus, String lastStatusReason) {
ProjectTaskDO update = new ProjectTaskDO(); ProjectTaskDO update = new ProjectTaskDO();
update.setStatusCode(toStatus); update.setStatusCode(toStatus);
@@ -186,6 +223,26 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
@Param("executionIds") Collection<Long> executionIds, @Param("executionIds") Collection<Long> executionIds,
@Param("excludedStatusCodes") Collection<String> excludedStatusCodes); @Param("excludedStatusCodes") Collection<String> excludedStatusCodes);
/**
* 项目进度推算:跨执行聚合,按项目下所有根任务 progressRate 简单平均;无根任务时 SQL 返回 null。
* 与执行口径一致parent_task_id IS NULL + excludedStatusCodes区别仅是不限定 execution_id。
*/
@Select("""
<script>
SELECT AVG(COALESCE(progress_rate, 0))
FROM rdms_task
WHERE deleted = b'0'
AND project_id = #{projectId}
AND parent_task_id IS NULL
<if test="excludedStatusCodes != null and excludedStatusCodes.size() > 0">
AND status_code NOT IN
<foreach collection="excludedStatusCodes" item="statusCode" open="(" separator="," close=")">#{statusCode}</foreach>
</if>
</script>
""")
BigDecimal selectRootTaskAvgProgressByProjectId(@Param("projectId") Long projectId,
@Param("excludedStatusCodes") Collection<String> excludedStatusCodes);
/** /**
* 执行详情完成态聚合:返回根任务总数与"已完成"数,用于 execution complete 按钮判定。 * 执行详情完成态聚合:返回根任务总数与"已完成"数,用于 execution complete 按钮判定。
* 与 selectRootTaskAvgProgressByExecutionId 共用同一根任务筛选口径execution_id + parent_task_id IS NULL + excludedStatusCodes * 与 selectRootTaskAvgProgressByExecutionId 共用同一根任务筛选口径execution_id + parent_task_id IS NULL + excludedStatusCodes
@@ -249,78 +306,57 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
.eq(ProjectTaskDO::getId, id)); .eq(ProjectTaskDO::getId, id));
} }
/**
* 递归 CTE从"userId 在指定项目下作为 owner_id 的全部任务"出发,向下展开包含所有子孙的任务 ID。
* 用于 VisibilityScopeResolver 中"任务负责人 → 自己 + 全部子孙"规则的 scope 收集。
*
* 任务表已逻辑删除的行不参与递归WHERE 子句过滤 deleted
* 单棵子树最大深度受 MySQL `cte_max_recursion_depth`(默认 1000限制业务实际任务树远低于此。
*/
@Select("""
WITH RECURSIVE owned (id) AS (
SELECT id FROM rdms_task
WHERE deleted = b'0'
AND project_id = #{projectId}
AND owner_id = #{userId}
UNION ALL
SELECT t.id FROM rdms_task t
JOIN owned o ON t.parent_task_id = o.id
WHERE t.deleted = b'0'
)
SELECT id FROM owned
""")
List<Long> selectOwnedTaskAndDescendantIdsByProjectIdAndUserId(
@Param("projectId") Long projectId,
@Param("userId") Long userId);
/**
* 同 selectOwnedTaskAndDescendantIdsByProjectIdAndUserId 但再加 execution_id 维度。
* 注意:递归向下展开只跟着 parent_task_id子任务必然与父任务在同一 execution 下,
* 因此 execution_id 过滤仅作用于种子owned那一步即可。
*/
@Select("""
WITH RECURSIVE owned (id) AS (
SELECT id FROM rdms_task
WHERE deleted = b'0'
AND project_id = #{projectId}
AND execution_id = #{executionId}
AND owner_id = #{userId}
UNION ALL
SELECT t.id FROM rdms_task t
JOIN owned o ON t.parent_task_id = o.id
WHERE t.deleted = b'0'
)
SELECT id FROM owned
""")
List<Long> selectOwnedTaskAndDescendantIdsByExecutionIdAndUserId(
@Param("projectId") Long projectId,
@Param("executionId") Long executionId,
@Param("userId") Long userId);
default Integer countByProjectIdAndExecutionIdAndStatusCode(Long projectId, Long executionId, default Integer countByProjectIdAndExecutionIdAndStatusCode(Long projectId, Long executionId,
VisibilityScope scope,
ProjectTaskStatusBoardReqVO reqVO, ProjectTaskStatusBoardReqVO reqVO,
String statusCode) { String statusCode) {
// 可见性短路:非 seesAll 且无任何可见任务 → 0 return Math.toIntExact(doCountByProjectIdAndExecutionIdAndStatusCode(projectId, executionId, reqVO, statusCode));
if (!scope.seesAll() && scope.taskIds().isEmpty()) {
return 0;
}
LambdaQueryWrapperX<ProjectTaskDO> queryWrapper = new LambdaQueryWrapperX<ProjectTaskDO>()
.eq(ProjectTaskDO::getProjectId, projectId)
.eq(ProjectTaskDO::getExecutionId, executionId)
.eq(ProjectTaskDO::getStatusCode, statusCode)
.eqIfPresent(ProjectTaskDO::getParentTaskId, reqVO.getParentTaskId())
.eqIfPresent(ProjectTaskDO::getOwnerId, reqVO.getOwnerId())
.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime());
if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectTaskDO::getTaskTitle, reqVO.getKeyword()));
}
if (!scope.seesAll()) {
queryWrapper.in(ProjectTaskDO::getId, scope.taskIds());
}
return Math.toIntExact(selectCount(queryWrapper));
} }
/**
* 执行内任务按状态计数(与 doSelectPageByExecutionId 同款过滤口径,含 involveUserId 协办分支)。
*/
@Select("""
<script>
SELECT COUNT(*)
FROM rdms_task t
<where>
t.deleted = b'0'
AND t.project_id = #{projectId}
AND t.execution_id = #{executionId}
AND t.status_code = #{statusCode}
<if test="reqVO.parentTaskId != null">
AND t.parent_task_id = #{reqVO.parentTaskId}
</if>
<if test="reqVO.ownerId != null">
AND t.owner_id = #{reqVO.ownerId}
</if>
<if test="reqVO.updateTime != null and reqVO.updateTime.length == 2">
AND t.update_time BETWEEN #{reqVO.updateTime[0]} AND #{reqVO.updateTime[1]}
</if>
<if test="reqVO.keyword != null and reqVO.keyword != ''">
AND t.task_title LIKE CONCAT('%', #{reqVO.keyword}, '%')
</if>
<if test="reqVO.involveUserId != null">
AND (
t.owner_id = #{reqVO.involveUserId}
OR EXISTS (
SELECT 1 FROM rdms_task_assignee a
WHERE a.task_id = t.id
AND a.user_id = #{reqVO.involveUserId}
AND a.removed_at IS NULL
AND a.deleted = b'0'
)
)
</if>
</where>
</script>
""")
Long doCountByProjectIdAndExecutionIdAndStatusCode(
@Param("projectId") Long projectId,
@Param("executionId") Long executionId,
@Param("reqVO") ProjectTaskStatusBoardReqVO reqVO,
@Param("statusCode") String statusCode);
/** /**
* 收集执行下的所有任务 id含子孙——子孙必然同 execution_id所以一把抓即可 * 收集执行下的所有任务 id含子孙——子孙必然同 execution_id所以一把抓即可
* 用于"删除执行"时的级联软删。 * 用于"删除执行"时的级联软删。
@@ -339,7 +375,7 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
/** /**
* 从给定任务出发,递归向下收集自身 + 所有子孙任务 id递归 CTE * 从给定任务出发,递归向下收集自身 + 所有子孙任务 id递归 CTE
* 用于"删除任务"时的级联软删。复用与 selectOwnedTaskAndDescendantIdsByProjectIdAndUserId 同款 CTE 模式。 * 用于"删除任务"时的级联软删。
* *
* 任务表已逻辑删除的行不参与递归。 * 任务表已逻辑删除的行不参与递归。
*/ */
@@ -379,4 +415,368 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
.eq(ProjectTaskDO::getExecutionId, executionId))); .eq(ProjectTaskDO::getExecutionId, executionId)));
} }
// ======================== 项目级跨执行聚合查询 ========================
/**
* 项目级跨执行任务分页查询。
*
* 语义:
* - involveUserId 不为 null → 附加 (t.owner_id = ? OR exists active assignee user_id = ?)
* - statusCodes 非空 → t.status_code IN (...)
* - dueRange='overdue' 且 terminalStatusCodes 非空 → 排除终态
*/
@Select("""
<script>
SELECT t.*
FROM rdms_task t
<where>
t.project_id = #{projectId}
AND t.deleted = b'0'
<if test="reqVO.keyword != null and reqVO.keyword != ''">
AND t.task_title LIKE CONCAT('%', #{reqVO.keyword}, '%')
</if>
<if test="reqVO.executionIds != null and !reqVO.executionIds.isEmpty()">
AND t.execution_id IN
<foreach collection="reqVO.executionIds" item="eid" open="(" separator="," close=")">#{eid}</foreach>
</if>
<if test="reqVO.executionStatusCodes != null and !reqVO.executionStatusCodes.isEmpty()">
AND EXISTS (
SELECT 1 FROM rdms_project_execution e
WHERE e.id = t.execution_id
AND e.deleted = b'0'
AND e.status_code IN
<foreach collection="reqVO.executionStatusCodes" item="esc" open="(" separator="," close=")">#{esc}</foreach>
)
</if>
<if test="reqVO.executionInvolveUserId != null">
AND EXISTS (
SELECT 1 FROM rdms_project_execution e
WHERE e.id = t.execution_id
AND e.deleted = b'0'
AND (
e.owner_id = #{reqVO.executionInvolveUserId}
OR EXISTS (
SELECT 1 FROM rdms_execution_assignee ea
WHERE ea.execution_id = e.id
AND ea.user_id = #{reqVO.executionInvolveUserId}
AND ea.removed_at IS NULL
AND ea.deleted = b'0'
)
)
)
</if>
<if test="reqVO.involveUserId != null">
AND (
t.owner_id = #{reqVO.involveUserId}
OR EXISTS (
SELECT 1 FROM rdms_task_assignee a
WHERE a.task_id = t.id
AND a.user_id = #{reqVO.involveUserId}
AND a.removed_at IS NULL
AND a.deleted = b'0'
)
)
</if>
<if test="reqVO.ownerId != null">
AND t.owner_id = #{reqVO.ownerId}
</if>
<if test="reqVO.statusCodes != null and !reqVO.statusCodes.isEmpty()">
AND t.status_code IN
<foreach collection="reqVO.statusCodes" item="sc" open="(" separator="," close=")">#{sc}</foreach>
</if>
<if test="reqVO.priority != null and reqVO.priority != ''">
AND t.priority = #{reqVO.priority}
</if>
<if test="reqVO.parentTaskId != null">
AND t.parent_task_id = #{reqVO.parentTaskId}
</if>
<if test="reqVO.dueRange == 'overdue'">
AND t.planned_end_date &lt; #{today}
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
</if>
<if test="reqVO.dueRange == 'today'">
AND t.planned_end_date = #{today}
</if>
<if test="reqVO.dueRange == 'thisWeek'">
AND t.planned_end_date BETWEEN #{weekStart} AND #{weekEnd}
</if>
<if test="reqVO.updateTime != null and reqVO.updateTime.length == 2">
AND t.update_time BETWEEN #{reqVO.updateTime[0]} AND #{reqVO.updateTime[1]}
</if>
</where>
<choose>
<when test="reqVO.sortBy == 'priority'">ORDER BY t.priority</when>
<when test="reqVO.sortBy == 'updateTime'">ORDER BY t.update_time</when>
<when test="reqVO.sortBy == 'createTime'">ORDER BY t.create_time</when>
<otherwise>ORDER BY t.planned_end_date</otherwise>
</choose>
<choose>
<when test="reqVO.sortOrder == 'desc'">DESC</when>
<otherwise>ASC</otherwise>
</choose>
, t.id DESC
</script>
""")
IPage<ProjectTaskDO> selectAggregatePageByProjectId(
@Param("projectId") Long projectId,
@Param("reqVO") ProjectTaskAggregatePageReqVO reqVO,
@Param("terminalStatusCodes") Collection<String> terminalStatusCodes,
@Param("weekStart") LocalDate weekStart,
@Param("weekEnd") LocalDate weekEnd,
@Param("today") LocalDate today,
Page<ProjectTaskDO> page);
/**
* 项目级跨执行任务按状态分组计数(status-board)。
* 入参同 page 但去除分页 / sort / statusCodes 筛选。
*/
@Select("""
<script>
SELECT t.status_code AS statusCode, COUNT(*) AS count
FROM rdms_task t
<where>
t.project_id = #{projectId}
AND t.deleted = b'0'
<if test="reqVO.keyword != null and reqVO.keyword != ''">
AND t.task_title LIKE CONCAT('%', #{reqVO.keyword}, '%')
</if>
<if test="reqVO.executionIds != null and !reqVO.executionIds.isEmpty()">
AND t.execution_id IN
<foreach collection="reqVO.executionIds" item="eid" open="(" separator="," close=")">#{eid}</foreach>
</if>
<if test="reqVO.executionStatusCodes != null and !reqVO.executionStatusCodes.isEmpty()">
AND EXISTS (
SELECT 1 FROM rdms_project_execution e
WHERE e.id = t.execution_id
AND e.deleted = b'0'
AND e.status_code IN
<foreach collection="reqVO.executionStatusCodes" item="esc" open="(" separator="," close=")">#{esc}</foreach>
)
</if>
<if test="reqVO.executionInvolveUserId != null">
AND EXISTS (
SELECT 1 FROM rdms_project_execution e
WHERE e.id = t.execution_id
AND e.deleted = b'0'
AND (
e.owner_id = #{reqVO.executionInvolveUserId}
OR EXISTS (
SELECT 1 FROM rdms_execution_assignee ea
WHERE ea.execution_id = e.id
AND ea.user_id = #{reqVO.executionInvolveUserId}
AND ea.removed_at IS NULL
AND ea.deleted = b'0'
)
)
)
</if>
<if test="reqVO.involveUserId != null">
AND (
t.owner_id = #{reqVO.involveUserId}
OR EXISTS (
SELECT 1 FROM rdms_task_assignee a
WHERE a.task_id = t.id
AND a.user_id = #{reqVO.involveUserId}
AND a.removed_at IS NULL
AND a.deleted = b'0'
)
)
</if>
<if test="reqVO.ownerId != null">
AND t.owner_id = #{reqVO.ownerId}
</if>
<if test="reqVO.priority != null and reqVO.priority != ''">
AND t.priority = #{reqVO.priority}
</if>
<if test="reqVO.parentTaskId != null">
AND t.parent_task_id = #{reqVO.parentTaskId}
</if>
<if test="reqVO.dueRange == 'overdue'">
AND t.planned_end_date &lt; #{today}
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
</if>
<if test="reqVO.dueRange == 'today'">
AND t.planned_end_date = #{today}
</if>
<if test="reqVO.dueRange == 'thisWeek'">
AND t.planned_end_date BETWEEN #{weekStart} AND #{weekEnd}
</if>
<if test="reqVO.updateTime != null and reqVO.updateTime.length == 2">
AND t.update_time BETWEEN #{reqVO.updateTime[0]} AND #{reqVO.updateTime[1]}
</if>
</where>
GROUP BY t.status_code
</script>
""")
List<StatusCountRow> selectAggregateStatusCount(
@Param("projectId") Long projectId,
@Param("reqVO") ProjectTaskAggregateStatusBoardReqVO reqVO,
@Param("terminalStatusCodes") Collection<String> terminalStatusCodes,
@Param("weekStart") LocalDate weekStart,
@Param("weekEnd") LocalDate weekEnd,
@Param("today") LocalDate today);
/**
* summary 的 4 个数字一次查出来,避免 4 次扫表。
*
* 返回 Map 结构:
* overdue → Long
* dueToday → Long
* dueThisWeek → Long
* doneThisWeek → Long
*/
@Select("""
<script>
SELECT
CAST(SUM(CASE WHEN t.planned_end_date &lt; #{today}
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
THEN 1 ELSE 0 END) AS SIGNED) AS overdue,
CAST(SUM(CASE WHEN t.planned_end_date = #{today}
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
THEN 1 ELSE 0 END) AS SIGNED) AS dueToday,
CAST(SUM(CASE WHEN t.planned_end_date BETWEEN #{weekStart} AND #{weekEnd}
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND t.status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
THEN 1 ELSE 0 END) AS SIGNED) AS dueThisWeek,
CAST(SUM(CASE WHEN t.actual_end_date BETWEEN #{weekStart} AND #{today}
AND t.status_code = #{completedStatusCode}
THEN 1 ELSE 0 END) AS SIGNED) AS doneThisWeek
FROM rdms_task t
<where>
t.project_id = #{projectId}
AND t.deleted = b'0'
<if test="involveUserId != null">
AND (
t.owner_id = #{involveUserId}
OR EXISTS (
SELECT 1 FROM rdms_task_assignee a
WHERE a.task_id = t.id
AND a.user_id = #{involveUserId}
AND a.removed_at IS NULL
AND a.deleted = b'0'
)
)
</if>
</where>
</script>
""")
Map<String, Long> selectAggregateSummaryCounts(
@Param("projectId") Long projectId,
@Param("involveUserId") Long involveUserId,
@Param("terminalStatusCodes") Collection<String> terminalStatusCodes,
@Param("completedStatusCode") String completedStatusCode,
@Param("today") LocalDate today,
@Param("weekStart") LocalDate weekStart,
@Param("weekEnd") LocalDate weekEnd);
/**
* status-board 计数行 — 内嵌静态类,与 mapper 共享生命周期。
*/
@Data
class StatusCountRow {
private String statusCode;
private Long count;
}
// ======================== 工作台「我的项目」聚合计数 ========================
/**
* 接口一:当前用户(owner_id)在一批项目下的任务总数与未完成数(按 project_id 分组)。
* totalCount=全部我负责任务pendingCount=状态非终态(终态集为空则等于 totalCount
* 返回 MapprojectId(Long) / totalCount(Long) / pendingCount(Long)。
*/
@Select("""
<script>
SELECT project_id AS projectId,
CAST(COUNT(*) AS SIGNED) AS totalCount,
CAST(SUM(CASE WHEN 1 = 1
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
THEN 1 ELSE 0 END) AS SIGNED) AS pendingCount
FROM rdms_task
WHERE deleted = b'0'
AND owner_id = #{ownerId}
AND project_id IN
<foreach collection="projectIds" item="pid" open="(" separator="," close=")">#{pid}</foreach>
GROUP BY project_id
</script>
""")
List<Map<String, Object>> selectMyTaskCountGroupByProjectIds(
@Param("ownerId") Long ownerId,
@Param("projectIds") Collection<Long> projectIds,
@Param("terminalStatusCodes") Collection<String> terminalStatusCodes);
/**
* 接口二:一批项目下的进行中任务数与逾期任务数(按 project_id 分组,一次扫表出两数)。
* taskCount=状态非终态overdueCount=planned_end_date &lt; today 且状态非终态。
* 返回 MapprojectId(Long) / taskCount(Long) / overdueCount(Long)。
*/
@Select("""
<script>
SELECT project_id AS projectId,
CAST(SUM(CASE WHEN 1 = 1
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
THEN 1 ELSE 0 END) AS SIGNED) AS taskCount,
CAST(SUM(CASE WHEN planned_end_date &lt; #{today}
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
THEN 1 ELSE 0 END) AS SIGNED) AS overdueCount
FROM rdms_task
WHERE deleted = b'0'
AND project_id IN
<foreach collection="projectIds" item="pid" open="(" separator="," close=")">#{pid}</foreach>
GROUP BY project_id
</script>
""")
List<Map<String, Object>> selectTaskAndOverdueCountGroupByProjectIds(
@Param("projectIds") Collection<Long> projectIds,
@Param("terminalStatusCodes") Collection<String> terminalStatusCodes,
@Param("today") LocalDate today);
/**
* 接口二 members一批项目下每个负责人(owner_id)的进行中任务数(按 project_id, owner_id 分组)。
* 排除 owner_id 为空的任务。返回 MapprojectId(Long) / ownerId(Long) / activeTaskCount(Long)。
*/
@Select("""
<script>
SELECT project_id AS projectId,
owner_id AS ownerId,
CAST(COUNT(*) AS SIGNED) AS activeTaskCount
FROM rdms_task
WHERE deleted = b'0'
AND owner_id IS NOT NULL
AND project_id IN
<foreach collection="projectIds" item="pid" open="(" separator="," close=")">#{pid}</foreach>
<if test="terminalStatusCodes != null and !terminalStatusCodes.isEmpty()">
AND status_code NOT IN
<foreach collection="terminalStatusCodes" item="tc" open="(" separator="," close=")">#{tc}</foreach>
</if>
GROUP BY project_id, owner_id
</script>
""")
List<Map<String, Object>> selectActiveTaskCountGroupByProjectIdAndOwner(
@Param("projectIds") Collection<Long> projectIds,
@Param("terminalStatusCodes") Collection<String> terminalStatusCodes);
} }

View File

@@ -4,8 +4,6 @@ import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX; import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO; import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
@@ -50,41 +48,6 @@ public interface TaskAssigneeMapper extends BaseMapperX<TaskAssigneeDO> {
.orderByAsc(TaskAssigneeDO::getId)); .orderByAsc(TaskAssigneeDO::getId));
} }
/**
* 查 userId 在指定项目下,当前活跃协办的所有任务 IDremoved_at IS NULL
* 走 JOIN 是因为 task_assignee 表没有 project_id 冗余字段。
* 用于 VisibilityScopeResolver 收集"我是任务协办人"的 scope 来源(项目维度)。
*/
@Select("""
SELECT a.task_id
FROM rdms_task_assignee a
JOIN rdms_task t ON t.id = a.task_id AND t.deleted = b'0'
WHERE a.deleted = b'0'
AND a.removed_at IS NULL
AND t.project_id = #{projectId}
AND a.user_id = #{userId}
""")
List<Long> selectActiveTaskIdsByProjectIdAndUserId(@Param("projectId") Long projectId,
@Param("userId") Long userId);
/**
* 同上,但再加 execution_id 维度,用于"任务分页(执行内)"的 scope。
*/
@Select("""
SELECT a.task_id
FROM rdms_task_assignee a
JOIN rdms_task t ON t.id = a.task_id AND t.deleted = b'0'
WHERE a.deleted = b'0'
AND a.removed_at IS NULL
AND t.project_id = #{projectId}
AND t.execution_id = #{executionId}
AND a.user_id = #{userId}
""")
List<Long> selectActiveTaskIdsByProjectIdAndExecutionIdAndUserId(
@Param("projectId") Long projectId,
@Param("executionId") Long executionId,
@Param("userId") Long userId);
/** /**
* 按主键 + 任务 ID 双键查返回的记录可能已失效removed_at != null由调用方判断。 * 按主键 + 任务 ID 双键查返回的记录可能已失效removed_at != null由调用方判断。
*/ */

View File

@@ -79,6 +79,18 @@ public interface ObjectStatusTransitionMapper extends BaseMapperX<ObjectStatusTr
.eq(ObjectStatusTransitionDO::getToStatusCode, statusCode))); .eq(ObjectStatusTransitionDO::getToStatusCode, statusCode)));
} }
/**
* 反查动作中文名:同 objectType + action_code 下 action_name 唯一一致(已核实),取任一行。
* 供错误提示等用户可见文案使用;查不到返回 null由上层回退到原 actionCode。
*/
default String selectActionNameByObjectTypeAndAction(String objectType, String actionCode) {
List<ObjectStatusTransitionDO> list = selectList(new LambdaQueryWrapperX<ObjectStatusTransitionDO>()
.eq(ObjectStatusTransitionDO::getObjectType, objectType)
.eq(ObjectStatusTransitionDO::getActionCode, actionCode)
.last("LIMIT 1"));
return list.isEmpty() ? null : list.get(0).getActionName();
}
/** /**
* 物理删除 * 物理删除
*/ */

View File

@@ -0,0 +1,36 @@
package com.njcn.rdms.module.project.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 任务到期范围快速筛选枚举(用于跨执行任务查询 chip)。
*
* <p>含义:</p>
* <ul>
* <li>{@link #OVERDUE} 计划完成日 &lt; 今天,且任务状态非终态</li>
* <li>{@link #TODAY} 计划完成日 = 今天</li>
* <li>{@link #THIS_WEEK} 计划完成日 在本周(周一~周日,Asia/Shanghai)</li>
* </ul>
*
* <p>非终态的判定走 {@code ObjectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled('task')}
* 动态查询,不在此枚举里硬编码状态字面值。</p>
*/
@Getter
@AllArgsConstructor
public enum DueRangeEnum {
OVERDUE("overdue"),
TODAY("today"),
THIS_WEEK("thisWeek");
private final String value;
public static DueRangeEnum of(String value) {
if (value == null) return null;
for (DueRangeEnum e : values()) {
if (e.value.equals(value)) return e;
}
return null;
}
}

View File

@@ -32,4 +32,10 @@ public @interface CheckObjectPermission {
*/ */
boolean memberOnly() default false; boolean memberOnly() default false;
/**
* 是否走「可访问性门禁」:显式成员 OR 数据范围 scope 兜底(与 getXxxContext 入口口径一致)。
* 为 true 时切面调用 checkAccessible忽略 permission / memberOnly优先级 accessible > memberOnly > permission
*/
boolean accessible() default false;
} }

View File

@@ -41,8 +41,13 @@ public class ObjectPermissionAspect {
throw invalidParamException("暂不支持对象类型:{}", checkObjectPermission.objectType()); throw invalidParamException("暂不支持对象类型:{}", checkObjectPermission.objectType());
} }
Long objectId = resolveObjectId(joinPoint, checkObjectPermission.objectId()); Long objectId = resolveObjectId(joinPoint, checkObjectPermission.objectId());
// 分发优先级accessible可访问性门禁> memberOnly / permission权限码
if (checkObjectPermission.accessible()) {
permissionService.checkAccessible(objectId);
} else {
permissionService.checkPermission(objectId, checkObjectPermission.permission(), permissionService.checkPermission(objectId, checkObjectPermission.permission(),
checkObjectPermission.memberOnly()); checkObjectPermission.memberOnly());
}
return joinPoint.proceed(); return joinPoint.proceed();
} }

View File

@@ -19,4 +19,24 @@ public interface ObjectPermissionService {
*/ */
void checkPermission(Long objectId, String permission, boolean memberOnly); void checkPermission(Long objectId, String permission, boolean memberOnly);
/**
* 判断当前登录用户是否具备指定对象上的指定权限码(非抛模式)。
*
* 与 {@link #checkPermission(Long, String, boolean)} 区别:
* 本方法不抛异常,纯返回 boolean用于"无权限就走降级路径"而非"无权限就 403"的场景。
*
* @param objectId 对象 ID如 projectId
* @param permission 权限码,如 {@code project:task:query}
* @return true=具备false=不具备
*/
boolean hasPermission(Long objectId, String permission);
/**
* 可访问性门禁:当前登录用户是否「能进入」该对象(显式成员 OR 数据范围 scope 兜底)。
* 不可访问(含对象不存在)一律抛 ..._OBJECT_PERMISSION_DENIED不暴露对象是否存在见 spec §3.3)。
*
* @param objectId 对象编号
*/
void checkAccessible(Long objectId);
} }

View File

@@ -4,10 +4,15 @@ import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectRoleConstants; import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
import com.njcn.rdms.module.project.constant.ProductObjectConstants; 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.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.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.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.api.permission.ObjectPermissionApi;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -23,6 +28,7 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
/** /**
* 产品对象权限服务。 * 产品对象权限服务。
*/ */
@Slf4j
@Service @Service
public class ProductObjectPermissionService implements ObjectPermissionService { public class ProductObjectPermissionService implements ObjectPermissionService {
@@ -30,12 +36,25 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
private UserObjectRoleMapper userObjectRoleMapper; private UserObjectRoleMapper userObjectRoleMapper;
@Resource @Resource
private ObjectPermissionApi objectPermissionApi; private ObjectPermissionApi objectPermissionApi;
@Resource
private ProductMapper productMapper;
@Resource
private ObjectDataScopeService objectDataScopeService;
@Override @Override
public String getObjectType() { public String getObjectType() {
return ProductObjectConstants.OBJECT_TYPE; return ProductObjectConstants.OBJECT_TYPE;
} }
@Override
public boolean hasPermission(Long objectId, String permission) {
// 当前产品域无 hasPermission 非抛模式调用场景,预留空实现。
// 启用时参考 ProjectObjectPermissionService.hasPermission 同款实现。
log.warn("[ProductObjectPermissionService.hasPermission] 未实现,默认返回 false;objectId={}, permission={}",
objectId, permission);
return false;
}
@Override @Override
public void checkPermission(Long objectId, String permission, boolean memberOnly) { public void checkPermission(Long objectId, String permission, boolean memberOnly) {
if (objectId == null) { if (objectId == null) {
@@ -46,8 +65,9 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
List<UserObjectRoleDO> userRoles = userObjectRoleMapper List<UserObjectRoleDO> userRoles = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, objectId, loginUserId); .selectActiveListByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, objectId, loginUserId);
if (userRoles.isEmpty()) { 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) { if (memberOnly) {
return; return;
@@ -59,7 +79,34 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
.distinct() .distinct()
.anyMatch(roleId -> getRolePermissions(roleId).contains(normalizedPermission)); .anyMatch(roleId -> getRolePermissions(roleId).contains(normalizedPermission));
if (!allowed) { 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<UserObjectRoleDO> 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);
} }
} }
@@ -84,8 +131,4 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
return permission.trim(); return permission.trim();
} }
private String buildDeniedPermission(String permission, boolean memberOnly) {
return memberOnly ? "member" : normalizePermission(permission);
}
} }

View File

@@ -1,13 +1,19 @@
package com.njcn.rdms.module.project.framework.security.service; package com.njcn.rdms.module.project.framework.security.service;
import cn.hutool.core.util.StrUtil;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectRoleConstants; import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants; 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.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.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.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.api.permission.ObjectPermissionApi;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -23,6 +29,7 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
/** /**
* 项目对象权限服务。 * 项目对象权限服务。
*/ */
@Slf4j
@Service @Service
public class ProjectObjectPermissionService implements ObjectPermissionService { public class ProjectObjectPermissionService implements ObjectPermissionService {
@@ -30,6 +37,10 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
private UserObjectRoleMapper userObjectRoleMapper; private UserObjectRoleMapper userObjectRoleMapper;
@Resource @Resource
private ObjectPermissionApi objectPermissionApi; private ObjectPermissionApi objectPermissionApi;
@Resource
private ProjectMapper projectMapper;
@Resource
private ObjectDataScopeService objectDataScopeService;
@Override @Override
public String getObjectType() { public String getObjectType() {
@@ -46,20 +57,75 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
List<UserObjectRoleDO> userRoles = userObjectRoleMapper List<UserObjectRoleDO> userRoles = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, objectId, loginUserId); .selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, objectId, loginUserId);
if (userRoles.isEmpty()) { 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) { if (memberOnly) {
return; return;
} }
String normalizedPermission = normalizePermission(permission); String normalizedPermission = normalizePermission(permission);
// 任一角色含该权限码即放行(等价于多角色 union短路求值,权限码命中早 return // 任一角色含该权限码即放行(等价于多角色 union短路求值
boolean allowed = userRoles.stream() boolean allowed = userRoles.stream()
.map(UserObjectRoleDO::getRoleId) .map(UserObjectRoleDO::getRoleId)
.distinct() .distinct()
.anyMatch(roleId -> getRolePermissions(roleId).contains(normalizedPermission)); .anyMatch(roleId -> getRolePermissions(roleId).contains(normalizedPermission));
if (!allowed) { 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<UserObjectRoleDO> 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);
}
}
@Override
public boolean hasPermission(Long objectId, String permission) {
if (objectId == null || StrUtil.isBlank(permission)) {
return false;
}
Long userId = SecurityFrameworkUtils.getLoginUserId();
if (userId == null) {
return false;
}
try {
// 先确认是对象成员,非成员直接返回 false
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, objectId, userId);
if (userRoles.isEmpty()) {
return false;
}
String normalizedPermission = permission.trim();
return userRoles.stream()
.map(UserObjectRoleDO::getRoleId)
.distinct()
.anyMatch(roleId -> getRolePermissions(roleId).contains(normalizedPermission));
} catch (Exception e) {
log.warn("[hasPermission] objectId={}, permission={} 查询权限失败", objectId, permission, e);
return false;
} }
} }
@@ -83,8 +149,4 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
return permission.trim(); return permission.trim();
} }
private String buildDeniedPermission(String permission, boolean memberOnly) {
return memberOnly ? "member" : normalizePermission(permission);
}
} }

View File

@@ -0,0 +1,39 @@
package com.njcn.rdms.module.project.service.overtime;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationExportVO;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationPageReqVO;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationRespVO;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationStatusActionReqVO;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationStatusDictRespVO;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationStatusLogRespVO;
import java.util.List;
public interface OvertimeApplicationService {
Long createApplication(OvertimeApplicationSaveReqVO reqVO);
void resubmitApplication(Long id, OvertimeApplicationSaveReqVO reqVO);
void approve(Long id, OvertimeApplicationStatusActionReqVO reqVO);
void reject(Long id, OvertimeApplicationStatusActionReqVO reqVO);
void cancel(Long id, OvertimeApplicationStatusActionReqVO reqVO);
void deleteApplication(Long id);
OvertimeApplicationRespVO getApplication(Long id);
List<OvertimeApplicationStatusDictRespVO> getStatusDict();
PageResult<OvertimeApplicationRespVO> getMyPage(OvertimeApplicationPageReqVO reqVO);
PageResult<OvertimeApplicationRespVO> getApprovalPage(OvertimeApplicationPageReqVO reqVO);
List<OvertimeApplicationStatusLogRespVO> getStatusLogs(Long id);
List<OvertimeApplicationExportVO> getExportList(OvertimeApplicationPageReqVO reqVO);
}

View File

@@ -0,0 +1,496 @@
package com.njcn.rdms.module.project.service.overtime;
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.framework.common.util.json.JsonUtils;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.OvertimeApplicationConstants;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationExportVO;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationPageReqVO;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationRespVO;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationStatusActionReqVO;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationStatusDictRespVO;
import com.njcn.rdms.module.project.controller.admin.overtime.vo.OvertimeApplicationStatusLogRespVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.overtime.OvertimeApplicationDO;
import com.njcn.rdms.module.project.dal.dataobject.overtime.OvertimeApplicationStatusLogDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.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 com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
@Service
public class OvertimeApplicationServiceImpl implements OvertimeApplicationService {
@Resource
private OvertimeApplicationMapper overtimeApplicationMapper;
@Resource
private OvertimeApplicationStatusLogMapper overtimeApplicationStatusLogMapper;
@Resource
private BizAuditLogMapper bizAuditLogMapper;
@Resource
private ObjectStatusModelMapper objectStatusModelMapper;
@Resource
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
@Resource
private StatusActionTextResolver statusActionTextResolver;
@Resource
private AdminUserApi adminUserApi;
@Override
@Transactional(rollbackFor = Exception.class)
public Long createApplication(OvertimeApplicationSaveReqVO reqVO) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
AdminUserRespDTO approver = validateApprover(reqVO.getApproverId());
String initialStatus = getInitialStatusCode();
OvertimeApplicationDO application = new OvertimeApplicationDO();
application.setApplicantId(loginUserId);
application.setApplicantName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
applySaveFields(application, reqVO, approver);
application.setStatusCode(initialStatus);
application.setApprovalComment(null);
application.setSubmitTime(LocalDateTime.now());
application.setApprovalTime(null);
overtimeApplicationMapper.insert(application);
// 创建即进入初始状态 pending这里保留 submit 作为业务留痕动作,不依赖状态机流转表存在 submit 配置。
writeStatusLog(application, OvertimeApplicationConstants.ACTION_SUBMIT, null, initialStatus, null);
writeAuditLog(application, OvertimeApplicationConstants.ACTION_SUBMIT, null, initialStatus, null, null, null);
return application.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void resubmitApplication(Long id, OvertimeApplicationSaveReqVO reqVO) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
OvertimeApplicationDO current = validateApplicationExists(id);
if (!Objects.equals(current.getApplicantId(), loginUserId)) {
throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_APPLICANT_ONLY);
}
String fromStatus = current.getStatusCode();
ObjectStatusTransitionDO transition = validateTransition(fromStatus, OvertimeApplicationConstants.ACTION_RESUBMIT,
null);
AdminUserRespDTO approver = validateApprover(reqVO.getApproverId());
OvertimeApplicationDO before = cloneApplication(current);
OvertimeApplicationDO update = new OvertimeApplicationDO();
applySaveFields(update, reqVO, approver);
update.setStatusCode(transition.getToStatusCode());
update.setApprovalComment(null);
update.setSubmitTime(LocalDateTime.now());
update.setApprovalTime(null);
int updateCount = overtimeApplicationMapper.updateByIdAndStatusesAndApplicantId(update, id,
List.of(OvertimeApplicationConstants.STATUS_REJECTED, OvertimeApplicationConstants.STATUS_CANCELLED),
loginUserId);
if (updateCount != 1) {
throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_STATUS_CONCURRENT_MODIFIED);
}
OvertimeApplicationDO after = mergeUpdated(current, update);
writeStatusLog(after, OvertimeApplicationConstants.ACTION_RESUBMIT, fromStatus, transition.getToStatusCode(),
null);
writeAuditLog(after, OvertimeApplicationConstants.ACTION_RESUBMIT, fromStatus, transition.getToStatusCode(),
buildFieldChanges(before, after), null, null);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void approve(Long id, OvertimeApplicationStatusActionReqVO reqVO) {
processApprovalAction(id, OvertimeApplicationConstants.ACTION_APPROVE, reqVO);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void reject(Long id, OvertimeApplicationStatusActionReqVO reqVO) {
processApprovalAction(id, OvertimeApplicationConstants.ACTION_REJECT, reqVO);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void cancel(Long id, OvertimeApplicationStatusActionReqVO reqVO) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
OvertimeApplicationDO current = validateApplicationExists(id);
if (!Objects.equals(current.getApplicantId(), loginUserId)) {
throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_APPLICANT_ONLY);
}
String reason = normalizeNullableText(reqVO == null ? null : reqVO.getReason());
String fromStatus = current.getStatusCode();
ObjectStatusTransitionDO transition = validateTransition(fromStatus, OvertimeApplicationConstants.ACTION_CANCEL,
reason);
OvertimeApplicationDO update = new OvertimeApplicationDO();
update.setStatusCode(transition.getToStatusCode());
update.setApprovalComment(reason);
update.setApprovalTime(LocalDateTime.now());
int updateCount = overtimeApplicationMapper.updateByIdAndStatusAndApplicantId(update, id, fromStatus,
loginUserId);
if (updateCount != 1) {
throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_STATUS_CONCURRENT_MODIFIED);
}
OvertimeApplicationDO after = mergeUpdated(current, update);
writeStatusLog(after, OvertimeApplicationConstants.ACTION_CANCEL, fromStatus, transition.getToStatusCode(),
reason);
writeAuditLog(after, OvertimeApplicationConstants.ACTION_CANCEL, fromStatus, transition.getToStatusCode(),
null, reason, null);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteApplication(Long id) {
OvertimeApplicationDO current = validateApplicationExists(id);
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
if (!Objects.equals(current.getApplicantId(), loginUserId)) {
throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_APPLICANT_ONLY);
}
if (!OvertimeApplicationConstants.STATUS_CANCELLED.equals(current.getStatusCode())) {
throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_DELETE_ONLY_CANCELLED);
}
overtimeApplicationMapper.deleteById(id);
writeAuditLog(current, OvertimeApplicationConstants.ACTION_DELETE, current.getStatusCode(), null, null, null,
null);
}
@Override
public OvertimeApplicationRespVO getApplication(Long id) {
OvertimeApplicationDO application = validateReadableApplication(id);
return toRespVO(application);
}
@Override
public List<OvertimeApplicationStatusDictRespVO> getStatusDict() {
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper
.selectListByObjectTypeEnabled(OvertimeApplicationConstants.STATUS_OBJECT_TYPE);
return statusModels.stream()
.map(this::buildStatusDictRespVO)
.collect(Collectors.toList());
}
@Override
public PageResult<OvertimeApplicationRespVO> getMyPage(OvertimeApplicationPageReqVO reqVO) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
PageResult<OvertimeApplicationDO> page = overtimeApplicationMapper.selectMyPage(loginUserId, reqVO);
return BeanUtils.toBean(page, OvertimeApplicationRespVO.class, this::applyStatusView);
}
@Override
public PageResult<OvertimeApplicationRespVO> getApprovalPage(OvertimeApplicationPageReqVO reqVO) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
PageResult<OvertimeApplicationDO> page = overtimeApplicationMapper.selectApprovalPage(loginUserId, reqVO);
return BeanUtils.toBean(page, OvertimeApplicationRespVO.class, this::applyStatusView);
}
@Override
public List<OvertimeApplicationStatusLogRespVO> getStatusLogs(Long id) {
validateReadableApplication(id);
return BeanUtils.toBean(overtimeApplicationStatusLogMapper.selectListByApplicationId(id),
OvertimeApplicationStatusLogRespVO.class);
}
@Override
public List<OvertimeApplicationExportVO> getExportList(OvertimeApplicationPageReqVO reqVO) {
reqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
PageResult<OvertimeApplicationRespVO> page = getMyPage(reqVO);
return BeanUtils.toBean(page.getList(), OvertimeApplicationExportVO.class);
}
private void processApprovalAction(Long id, String actionCode, OvertimeApplicationStatusActionReqVO reqVO) {
OvertimeApplicationDO current = validateApplicationExists(id);
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
if (!Objects.equals(current.getApproverId(), loginUserId)) {
throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_APPROVER_ONLY);
}
String reason = normalizeNullableText(reqVO == null ? null : reqVO.getReason());
String fromStatus = current.getStatusCode();
ObjectStatusTransitionDO transition = validateTransition(fromStatus, actionCode, reason);
OvertimeApplicationDO update = new OvertimeApplicationDO();
update.setStatusCode(transition.getToStatusCode());
update.setApprovalComment(reason);
update.setApprovalTime(LocalDateTime.now());
int updateCount = overtimeApplicationMapper.updateByIdAndStatus(update, id, fromStatus);
if (updateCount != 1) {
throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_STATUS_CONCURRENT_MODIFIED);
}
OvertimeApplicationDO after = mergeUpdated(current, update);
writeStatusLog(after, actionCode, fromStatus, transition.getToStatusCode(), reason);
writeAuditLog(after, actionCode, fromStatus, transition.getToStatusCode(), null, reason, null);
}
private OvertimeApplicationDO validateApplicationExists(Long id) {
OvertimeApplicationDO application = overtimeApplicationMapper.selectById(id);
if (application == null) {
throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_NOT_EXISTS);
}
return application;
}
private OvertimeApplicationDO validateReadableApplication(Long id) {
OvertimeApplicationDO application = validateApplicationExists(id);
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
if (!Objects.equals(application.getApplicantId(), loginUserId)
&& !Objects.equals(application.getApproverId(), loginUserId)) {
throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_READ_FORBIDDEN);
}
return application;
}
private ObjectStatusTransitionDO validateTransition(String fromStatus, String actionCode, String reason) {
ObjectStatusTransitionDO transition = objectStatusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(OvertimeApplicationConstants.STATUS_OBJECT_TYPE, fromStatus,
actionCode);
if (transition == null) {
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,
statusActionTextResolver.actionName(OvertimeApplicationConstants.STATUS_OBJECT_TYPE, actionCode));
}
ObjectStatusModelDO toModel = objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled(
OvertimeApplicationConstants.STATUS_OBJECT_TYPE, transition.getToStatusCode());
if (toModel == null) {
throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED);
}
return transition;
}
private String getInitialStatusCode() {
ObjectStatusModelDO statusModel = objectStatusModelMapper
.selectInitialByObjectTypeEnabled(OvertimeApplicationConstants.STATUS_OBJECT_TYPE);
if (statusModel == null || !StringUtils.hasText(statusModel.getStatusCode())) {
throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED);
}
return statusModel.getStatusCode();
}
private AdminUserRespDTO validateApprover(Long approverId) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
if (Objects.equals(approverId, loginUserId)) {
throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_APPROVER_SELF_FORBIDDEN);
}
return loadUser(approverId);
}
private AdminUserRespDTO loadUser(Long userId) {
if (userId == null) {
throw invalidParamException("用户编号不能为空");
}
adminUserApi.validateUserList(Collections.singleton(userId)).getCheckedData();
CommonResult<AdminUserRespDTO> result = adminUserApi.getUser(userId);
AdminUserRespDTO user = result.getCheckedData();
if (user == null) {
throw exception(ErrorCodeConstants.OVERTIME_APPLICATION_APPROVER_INVALID);
}
return user;
}
private void applySaveFields(OvertimeApplicationDO target, OvertimeApplicationSaveReqVO reqVO,
AdminUserRespDTO approver) {
target.setOvertimeDate(reqVO.getOvertimeDate());
target.setOvertimeDuration(normalizeRequiredText(reqVO.getOvertimeDuration(), "加班时长不能为空"));
target.setOvertimeReason(normalizeRequiredText(reqVO.getOvertimeReason(), "加班原因不能为空"));
target.setOvertimeContent(normalizeRequiredText(reqVO.getOvertimeContent(), "加班内容不能为空"));
target.setApproverId(approver.getId());
target.setApproverName(defaultText(approver.getNickname()));
}
private OvertimeApplicationRespVO toRespVO(OvertimeApplicationDO application) {
return BeanUtils.toBean(application, OvertimeApplicationRespVO.class, this::applyStatusView);
}
private void applyStatusView(OvertimeApplicationRespVO respVO) {
ObjectStatusModelDO statusModel = objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled(
OvertimeApplicationConstants.STATUS_OBJECT_TYPE, respVO.getStatusCode());
if (statusModel == null) {
respVO.setStatusName(respVO.getStatusCode());
respVO.setAllowEdit(false);
respVO.setTerminal(false);
return;
}
respVO.setStatusName(statusModel.getStatusName());
respVO.setAllowEdit(Boolean.TRUE.equals(statusModel.getAllowEdit()));
respVO.setTerminal(Boolean.TRUE.equals(statusModel.getTerminalFlag()));
}
private OvertimeApplicationStatusDictRespVO buildStatusDictRespVO(ObjectStatusModelDO statusModel) {
OvertimeApplicationStatusDictRespVO respVO = new OvertimeApplicationStatusDictRespVO();
respVO.setStatusCode(statusModel.getStatusCode());
respVO.setStatusName(statusModel.getStatusName());
respVO.setSort(statusModel.getSort());
respVO.setInitialFlag(statusModel.getInitialFlag());
respVO.setTerminalFlag(statusModel.getTerminalFlag());
respVO.setAllowEdit(statusModel.getAllowEdit());
return respVO;
}
private void writeStatusLog(OvertimeApplicationDO application, String actionType, String fromStatus,
String toStatus, String reason) {
OvertimeApplicationStatusLogDO log = new OvertimeApplicationStatusLogDO();
log.setApplicationId(application.getId());
log.setActionType(actionType);
log.setFromStatus(fromStatus);
log.setToStatus(toStatus);
log.setReason(reason);
log.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
log.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
log.setApplicantNameSnapshot(application.getApplicantName());
log.setOvertimeDateSnapshot(application.getOvertimeDate());
log.setOvertimeDurationSnapshot(application.getOvertimeDuration());
log.setRemark(buildSnapshotRemark(application));
overtimeApplicationStatusLogMapper.insert(log);
}
private void writeAuditLog(OvertimeApplicationDO application, String actionType, String fromStatus,
String toStatus, String fieldChanges, String reason, String remark) {
BizAuditLogDO auditLog = new BizAuditLogDO();
auditLog.setBizType(OvertimeApplicationConstants.BIZ_TYPE);
auditLog.setBizId(application.getId());
auditLog.setActionType(actionType);
auditLog.setFromStatus(fromStatus);
auditLog.setToStatus(toStatus);
auditLog.setFieldChanges(fieldChanges);
auditLog.setReason(reason);
auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
auditLog.setRemark(StringUtils.hasText(remark) ? remark : buildSnapshotRemark(application));
bizAuditLogMapper.insert(auditLog);
}
private String buildSnapshotRemark(OvertimeApplicationDO application) {
return "申请人:" + defaultText(application.getApplicantName())
+ ",加班日期:" + application.getOvertimeDate()
+ ",加班时长:" + defaultText(application.getOvertimeDuration());
}
private String buildFieldChanges(OvertimeApplicationDO before, OvertimeApplicationDO after) {
Map<String, Object> fieldChanges = new LinkedHashMap<>();
appendFieldChange(fieldChanges, "overtimeDate", valueOf(before, OvertimeApplicationDO::getOvertimeDate),
valueOf(after, OvertimeApplicationDO::getOvertimeDate));
appendFieldChange(fieldChanges, "overtimeDuration",
valueOf(before, OvertimeApplicationDO::getOvertimeDuration),
valueOf(after, OvertimeApplicationDO::getOvertimeDuration));
appendFieldChange(fieldChanges, "overtimeReason", valueOf(before, OvertimeApplicationDO::getOvertimeReason),
valueOf(after, OvertimeApplicationDO::getOvertimeReason));
appendFieldChange(fieldChanges, "overtimeContent", valueOf(before, OvertimeApplicationDO::getOvertimeContent),
valueOf(after, OvertimeApplicationDO::getOvertimeContent));
appendFieldChange(fieldChanges, "approverId", valueOf(before, OvertimeApplicationDO::getApproverId),
valueOf(after, OvertimeApplicationDO::getApproverId));
appendFieldChange(fieldChanges, "approverName", valueOf(before, OvertimeApplicationDO::getApproverName),
valueOf(after, OvertimeApplicationDO::getApproverName));
appendFieldChange(fieldChanges, "statusCode", valueOf(before, OvertimeApplicationDO::getStatusCode),
valueOf(after, OvertimeApplicationDO::getStatusCode));
return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges);
}
private void appendFieldChange(Map<String, Object> fieldChanges, String fieldName, Object before, Object after) {
if (Objects.equals(before, after)) {
return;
}
Map<String, Object> value = new LinkedHashMap<>();
value.put("before", before);
value.put("after", after);
fieldChanges.put(fieldName, value);
}
private <T> T valueOf(OvertimeApplicationDO application, Function<OvertimeApplicationDO, T> getter) {
return application == null ? null : getter.apply(application);
}
private OvertimeApplicationDO cloneApplication(OvertimeApplicationDO source) {
OvertimeApplicationDO target = new OvertimeApplicationDO();
target.setId(source.getId());
target.setApplicantId(source.getApplicantId());
target.setApplicantName(source.getApplicantName());
target.setOvertimeDate(source.getOvertimeDate());
target.setOvertimeDuration(source.getOvertimeDuration());
target.setOvertimeReason(source.getOvertimeReason());
target.setOvertimeContent(source.getOvertimeContent());
target.setApproverId(source.getApproverId());
target.setApproverName(source.getApproverName());
target.setStatusCode(source.getStatusCode());
target.setApprovalComment(source.getApprovalComment());
target.setSubmitTime(source.getSubmitTime());
target.setApprovalTime(source.getApprovalTime());
return target;
}
private OvertimeApplicationDO mergeUpdated(OvertimeApplicationDO current, OvertimeApplicationDO update) {
OvertimeApplicationDO after = cloneApplication(current);
if (update.getOvertimeDate() != null) {
after.setOvertimeDate(update.getOvertimeDate());
}
if (update.getOvertimeDuration() != null) {
after.setOvertimeDuration(update.getOvertimeDuration());
}
if (update.getOvertimeReason() != null) {
after.setOvertimeReason(update.getOvertimeReason());
}
if (update.getOvertimeContent() != null) {
after.setOvertimeContent(update.getOvertimeContent());
}
if (update.getApproverId() != null) {
after.setApproverId(update.getApproverId());
}
if (update.getApproverName() != null) {
after.setApproverName(update.getApproverName());
}
if (update.getStatusCode() != null) {
after.setStatusCode(update.getStatusCode());
}
after.setApprovalComment(update.getApprovalComment());
if (update.getSubmitTime() != null) {
after.setSubmitTime(update.getSubmitTime());
}
after.setApprovalTime(update.getApprovalTime());
return after;
}
private String normalizeRequiredText(String value, String message) {
if (!StringUtils.hasText(value)) {
throw invalidParamException(message);
}
return value.trim();
}
private String normalizeNullableText(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return value.trim();
}
private String defaultText(String value) {
return StringUtils.hasText(value) ? value : "";
}
}

View File

@@ -30,6 +30,8 @@ import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMappe
import com.njcn.rdms.module.project.enums.ErrorCodeConstants; 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.AttachmentFileIdResolver;
import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator; import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator;
import com.njcn.rdms.module.project.service.project.ProjectService;
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.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -83,6 +85,10 @@ public class PersonalItemServiceImpl implements PersonalItemService {
private AttachmentFileIdResolver attachmentFileIdResolver; private AttachmentFileIdResolver attachmentFileIdResolver;
@Resource @Resource
private AdminUserApi adminUserApi; private AdminUserApi adminUserApi;
@Resource
private StatusActionTextResolver statusActionTextResolver;
@Resource
private ProjectService projectService;
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@@ -170,11 +176,13 @@ public class PersonalItemServiceImpl implements PersonalItemService {
ObjectStatusTransitionDO transition = objectStatusTransitionMapper ObjectStatusTransitionDO transition = objectStatusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(PersonalItemConstants.STATUS_OBJECT_TYPE, fromStatus, actionCode); .selectByObjectTypeAndFromStatusAndAction(PersonalItemConstants.STATUS_OBJECT_TYPE, fromStatus, actionCode);
if (transition == null) { 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()); String reason = normalizeNullableText(reqVO.getReason());
if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) { 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(); String toStatus = transition.getToStatusCode();
int updateCount = personalItemMapper.updateStatusByIdAndStatus(item.getId(), fromStatus, toStatus, reason); int updateCount = personalItemMapper.updateStatusByIdAndStatus(item.getId(), fromStatus, toStatus, reason);
@@ -354,6 +362,8 @@ public class PersonalItemServiceImpl implements PersonalItemService {
writeAuditLog(item, ObjectActivityConstants.PERSONAL_ITEM_ACTION_DELETE, writeAuditLog(item, ObjectActivityConstants.PERSONAL_ITEM_ACTION_DELETE,
item.getStatusCode(), null, null, "关联执行后转为项目任务"); item.getStatusCode(), null, null, "关联执行后转为项目任务");
} }
// 个人事项转为项目根任务后,项目根任务均值已变,需下推一次项目进度重算(与任务侧增删改入口口径一致)
projectService.recalcProgress(projectId);
} }
private ProjectTaskDO buildProjectTaskFromItem(PersonalItemDO item, Long projectId, Long executionId) { private ProjectTaskDO buildProjectTaskFromItem(PersonalItemDO item, Long projectId, Long executionId) {

View File

@@ -65,6 +65,7 @@ public class ProductMemberServiceImpl implements ProductMemberService {
private AdminUserApi adminUserApi; private AdminUserApi adminUserApi;
@Override @Override
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId", accessible = true)
public List<ProductMemberRespVO> getProductMemberList(Long productId) { public List<ProductMemberRespVO> getProductMemberList(Long productId) {
ProductDO product = validateProductExists(productId); ProductDO product = validateProductExists(productId);
List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(ProductObjectConstants.OBJECT_TYPE, productId); List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(ProductObjectConstants.OBJECT_TYPE, productId);

View File

@@ -125,6 +125,8 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
private UserObjectRoleMapper userObjectRoleMapper; private UserObjectRoleMapper userObjectRoleMapper;
@Resource @Resource
private AttachmentFileIdResolver attachmentFileIdResolver; 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) { private void validateReviewRejectedActionAllowed(ProductRequirementDO requirement, String actionCode) {
if (!isReviewRejectedActionAllowed(requirement, 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 ObjectStatusTransitionDO transition = statusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(REQUIREMENT_OBJECT_TYPE, fromStatusCode, actionCode); .selectByObjectTypeAndFromStatusAndAction(REQUIREMENT_OBJECT_TYPE, fromStatusCode, actionCode);
if (transition == null) { 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; return transition;
} }
@@ -1544,7 +1550,7 @@ public class ProductRequirementServiceImpl implements ProductRequirementService
@VisibleForTesting @VisibleForTesting
void validateTransitionReason(ObjectStatusTransitionDO transition, String reason) { void validateTransitionReason(ObjectStatusTransitionDO transition, String reason) {
if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(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());
} }
} }

View File

@@ -89,6 +89,8 @@ public class ProductServiceImpl implements ProductService {
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService; private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
@Resource @Resource
private ObjectDataScopeService objectDataScopeService; private ObjectDataScopeService objectDataScopeService;
@Resource
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@@ -274,7 +276,7 @@ public class ProductServiceImpl implements ProductService {
// 显式角色为空:走 scope.contains 判定隐式 observer 兜底(设计文档 2.1 节末段) // 显式角色为空:走 scope.contains 判定隐式 observer 兜底(设计文档 2.1 节末段)
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE); ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE);
if (!scope.contains(id, product.getDirectionCode())) { if (!scope.contains(id, product.getDirectionCode())) {
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED, "查看"); throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED);
} }
return buildImplicitObserverContext(product); return buildImplicitObserverContext(product);
} }
@@ -568,7 +570,9 @@ public class ProductServiceImpl implements ProductService {
ObjectStatusTransitionDO transition = objectStatusTransitionMapper ObjectStatusTransitionDO transition = objectStatusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(ProductObjectConstants.OBJECT_TYPE, fromStatusCode, actionCode); .selectByObjectTypeAndFromStatusAndAction(ProductObjectConstants.OBJECT_TYPE, fromStatusCode, actionCode);
if (transition == null) { 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; return transition;
} }
@@ -576,7 +580,7 @@ public class ProductServiceImpl implements ProductService {
@VisibleForTesting @VisibleForTesting
void validateTransitionReason(ObjectStatusTransitionDO transition, String reason) { void validateTransitionReason(ObjectStatusTransitionDO transition, String reason) {
if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(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());
} }
} }

View File

@@ -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.controller.admin.product.vo.setting.ProductSettingRespVO;
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO; 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.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.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.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -34,6 +36,7 @@ public class ProductSettingServiceImpl implements ProductSettingService {
private ProductActivityTimelineQueryService productActivityTimelineQueryService; private ProductActivityTimelineQueryService productActivityTimelineQueryService;
@Override @Override
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId", accessible = true)
public ProductSettingRespVO getProductSettings(Long productId) { public ProductSettingRespVO getProductSettings(Long productId) {
ProductDO product = validateProductExists(productId); ProductDO product = validateProductExists(productId);
ProductSettingRespVO respVO = new ProductSettingRespVO(); ProductSettingRespVO respVO = new ProductSettingRespVO();
@@ -43,12 +46,14 @@ public class ProductSettingServiceImpl implements ProductSettingService {
} }
@Override @Override
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId", accessible = true)
public PageResult<ProductActivityRespVO> getProductActivities(Long productId, ProductActivityPageReqVO reqVO) { public PageResult<ProductActivityRespVO> getProductActivities(Long productId, ProductActivityPageReqVO reqVO) {
validateProductExists(productId); validateProductExists(productId);
return productActivityQueryService.getProductActivities(productId, reqVO); return productActivityQueryService.getProductActivities(productId, reqVO);
} }
@Override @Override
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#productId", accessible = true)
public PageResult<ProductActivityTimelineRespVO> getProductActivityTimelinePage( public PageResult<ProductActivityTimelineRespVO> getProductActivityTimelinePage(
Long productId, ProductActivityTimelinePageReqVO reqVO) { Long productId, ProductActivityTimelinePageReqVO reqVO) {
validateProductExists(productId); validateProductExists(productId);

View File

@@ -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<MyProjectParticipatedRespVO> getMyParticipatedPage(MyProjectPageReqVO reqVO);
/** 我负责的项目managerUserId = 登录用户) */
PageResult<MyProjectOwnedRespVO> getMyOwnedPage(MyProjectPageReqVO reqVO);
}

View File

@@ -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<ProjectDO> PROJECT_CREATE_TIME_ASC =
Comparator.comparing(ProjectDO::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(ProjectDO::getId);
@Override
public PageResult<MyProjectParticipatedRespVO> getMyParticipatedPage(MyProjectPageReqVO reqVO) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
// 1. 我参与的所有 active 角色行(含 manager/dev 等多角色objectId=项目id
List<UserObjectRoleDO> myRoles = userObjectRoleMapper
.selectActiveListByObjectTypeAndUserId(ProjectObjectConstants.OBJECT_TYPE, loginUserId);
if (myRoles.isEmpty()) {
return new PageResult<>(Collections.emptyList(), 0L);
}
// 2. 按项目分组我的角色行
Map<Long, List<UserObjectRoleDO>> rolesByProject = myRoles.stream()
.filter(r -> r.getObjectId() != null)
.collect(Collectors.groupingBy(UserObjectRoleDO::getObjectId, LinkedHashMap::new, Collectors.toList()));
Set<Long> projectIds = new LinkedHashSet<>(rolesByProject.keySet());
// 3. 项目基本信息
List<ProjectDO> projects = projectMapper.selectBatchIds(projectIds);
if (projects.isEmpty()) {
return new PageResult<>(Collections.emptyList(), 0L);
}
// 3.1 仅保留非终态项目(终态项目不在工作台「我的项目」体现),并按创建时间升序(先创建的在前)
List<String> 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<String, String> statusNameMap = loadStatusNameMap(ProjectObjectConstants.OBJECT_TYPE);
// 5. 角色名 map一次性拉全部涉及 roleId
Map<Long, ObjectRoleRespDTO> roleMap = loadRoleMap(myRoles.stream()
.map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet()));
// 5.1 每个项目下"我的可见角色行":剔除 visible=0 的隐式角色(创建者 / 隐式观察者等业务自动赋予角色)。
// 若某项目下我没有任何可见角色,则不算"我参与的项目",整项剔除——与 ProjectMemberServiceImpl 团队列表口径一致。
Map<Long, List<UserObjectRoleDO>> visibleRolesByProject = new LinkedHashMap<>();
rolesByProject.forEach((pid, rows) -> {
List<UserObjectRoleDO> visible = filterVisibleRoleRows(rows, roleMap);
if (!visible.isEmpty()) {
visibleRolesByProject.put(pid, visible);
}
});
// 6. 我负责的任务计数owner=me按项目分组 total + pending
List<String> taskTerminal = objectStatusModelMapper
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
Map<Long, long[]> taskCountMap = new LinkedHashMap<>();
for (Map<String, Object> row : projectTaskMapper
.selectMyTaskCountGroupByProjectIds(loginUserId, projectIds, taskTerminal)) {
taskCountMap.put(asLong(row.get("projectId")),
new long[]{asLong(row.get("totalCount")), asLong(row.get("pendingCount"))});
}
// 7. 组装(仅保留我有可见角色的项目)
List<MyProjectParticipatedRespVO> 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<String, String> loadStatusNameMap(String objectType) {
return objectStatusModelMapper.selectListByObjectTypeEnabled(objectType).stream()
.collect(Collectors.toMap(ObjectStatusModelDO::getStatusCode,
ObjectStatusModelDO::getStatusName, (a, b) -> a));
}
private Map<Long, ObjectRoleRespDTO> loadRoleMap(Set<Long> roleIds) {
if (roleIds.isEmpty()) {
return Collections.emptyMap();
}
List<ObjectRoleRespDTO> 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<UserObjectRoleDO> filterVisibleRoleRows(List<UserObjectRoleDO> rows, Map<Long, ObjectRoleRespDTO> 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<UserObjectRoleDO> rowsVisible, Map<Long, ObjectRoleRespDTO> 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<String> 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<Long, ObjectRoleRespDTO> 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 <T> PageResult<T> paginate(List<T> 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<MyProjectOwnedRespVO> getMyOwnedPage(MyProjectPageReqVO reqVO) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
// 1. 我负责的项目managerUserId = 登录用户)
List<ProjectDO> projects = projectMapper.selectList(new LambdaQueryWrapperX<ProjectDO>()
.eq(ProjectDO::getManagerUserId, loginUserId));
if (projects.isEmpty()) {
return new PageResult<>(Collections.emptyList(), 0L);
}
// 1.1 仅保留非终态项目(终态项目不在工作台「我的项目」体现),并按创建时间升序(先创建的在前)
List<String> 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<Long> projectIds = projects.stream()
.map(ProjectDO::getId).collect(Collectors.toCollection(LinkedHashSet::new));
// 2. 终态集
List<String> taskTerminal = objectStatusModelMapper
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
List<String> execTerminal = objectStatusModelMapper
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE);
LocalDate today = LocalDate.now();
// 3. 任务数 + 逾期数(一次扫表)
Map<Long, long[]> taskMap = new LinkedHashMap<>();
for (Map<String, Object> row : projectTaskMapper
.selectTaskAndOverdueCountGroupByProjectIds(projectIds, taskTerminal, today)) {
taskMap.put(asLong(row.get("projectId")),
new long[]{asLong(row.get("taskCount")), asLong(row.get("overdueCount"))});
}
// 4. 执行数
Map<Long, Long> execMap = new LinkedHashMap<>();
for (Map<String, Object> row : projectExecutionMapper
.selectExecutionCountGroupByProjectIds(projectIds, execTerminal)) {
execMap.put(asLong(row.get("projectId")), asLong(row.get("executionCount")));
}
// 5. 每个负责人(owner)的进行中任务数projectId -> (ownerId -> count)
Map<Long, Map<Long, Long>> activeTaskMap = new LinkedHashMap<>();
for (Map<String, Object> 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<UserObjectRoleDO> memberRows = userObjectRoleMapper
.selectActiveListByObjectTypeAndObjectIds(ProjectObjectConstants.OBJECT_TYPE, projectIds);
Map<Long, List<Long>> memberUserIdsByProject = new LinkedHashMap<>();
for (UserObjectRoleDO m : memberRows) {
if (m.getObjectId() == null || m.getUserId() == null) {
continue;
}
List<Long> users = memberUserIdsByProject.computeIfAbsent(m.getObjectId(), k -> new ArrayList<>());
if (!users.contains(m.getUserId())) {
users.add(m.getUserId());
}
}
// 7. 成员昵称批量回填
Set<Long> allUserIds = memberUserIdsByProject.values().stream()
.flatMap(List::stream).collect(Collectors.toSet());
Map<Long, AdminUserRespDTO> userMap = allUserIds.isEmpty()
? Collections.emptyMap() : adminUserApi.getUserMap(allUserIds);
// 8. myRole 恒为负责人角色名(一次性解析)
String managerRoleName = resolveManagerRoleName();
// 9. 组装
List<MyProjectOwnedRespVO> 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<Long> memberUserIds = memberUserIdsByProject.getOrDefault(p.getId(), Collections.emptyList());
vo.setMemberCount(memberUserIds.size());
Map<Long, Long> ownerCounts = activeTaskMap.getOrDefault(p.getId(), Collections.emptyMap());
List<MyProjectOwnedRespVO.MemberLoadVO> 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;
}
}
}

View File

@@ -69,6 +69,7 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
private ProjectExecutionMapper projectExecutionMapper; private ProjectExecutionMapper projectExecutionMapper;
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
public List<ProjectMemberRespVO> getProjectMemberList(Long projectId) { public List<ProjectMemberRespVO> getProjectMemberList(Long projectId) {
ProjectDO project = validateProjectExists(projectId); ProjectDO project = validateProjectExists(projectId);
List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(ProjectObjectConstants.OBJECT_TYPE, projectId); List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(ProjectObjectConstants.OBJECT_TYPE, projectId);

View File

@@ -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.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver; import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver;
import com.njcn.rdms.module.project.framework.attachment.AttachmentValidator; 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 com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -131,6 +132,8 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
private AttachmentFileIdResolver attachmentFileIdResolver; private AttachmentFileIdResolver attachmentFileIdResolver;
@Resource @Resource
private ProjectExecutionMapper projectExecutionMapper; private ProjectExecutionMapper projectExecutionMapper;
@Resource
private StatusActionTextResolver statusActionTextResolver;
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@@ -882,7 +885,9 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
*/ */
private void validateReviewRejectedActionAllowed(ProjectRequirementDO requirement, String actionCode) { private void validateReviewRejectedActionAllowed(ProjectRequirementDO requirement, String actionCode) {
if (!isReviewRejectedActionAllowed(requirement, 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 ObjectStatusTransitionDO transition = statusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(REQUIREMENT_OBJECT_TYPE, fromStatusCode, actionCode); .selectByObjectTypeAndFromStatusAndAction(REQUIREMENT_OBJECT_TYPE, fromStatusCode, actionCode);
if (transition == null) { 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; return transition;
} }
@@ -1305,7 +1312,7 @@ public class ProjectRequirementServiceImpl implements ProjectRequirementService
@VisibleForTesting @VisibleForTesting
void validateTransitionReason(ObjectStatusTransitionDO transition, String reason) { void validateTransitionReason(ObjectStatusTransitionDO transition, String reason) {
if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(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());
} }
} }

View File

@@ -60,4 +60,10 @@ public interface ProjectService {
void autoStartProjectIfPending(Long projectId, String triggerAction); void autoStartProjectIfPending(Long projectId, String triggerAction);
/**
* 重算项目进度并落库AVG(项目下所有根任务 progressRate),沿用 task 维度的 progress_excluded 排除集合。
* 由任务侧在"任务进度变化 / 状态变更 / 创建删除 / 父子结构变化"等入口触发。
*/
void recalcProgress(Long projectId);
} }

View File

@@ -8,6 +8,7 @@ import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants; import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.constant.ObjectRoleConstants; import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
import com.njcn.rdms.module.project.constant.ProductObjectConstants;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants; import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberSaveReqVO; import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextNavRespVO; import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextNavRespVO;
@@ -32,9 +33,11 @@ import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransition
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper; import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper; import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.dal.mysql.product.ProductMapper; import com.njcn.rdms.module.project.dal.mysql.product.ProductMapper;
import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper; import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementModuleMapper; import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementModuleMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectStatusLogMapper; import com.njcn.rdms.module.project.dal.mysql.project.ProjectStatusLogMapper;
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.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper; import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants; import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
@@ -43,6 +46,7 @@ import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX; 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.ObjectDataScope;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService; 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.dict.DictDataApi;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.ObjectMenuRespDTO; import com.njcn.rdms.module.system.api.permission.dto.ObjectMenuRespDTO;
@@ -58,6 +62,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
@@ -108,9 +113,13 @@ class ProjectServiceImpl implements ProjectService {
@Resource @Resource
private ProjectRequirementModuleMapper projectRequirementModuleMapper; private ProjectRequirementModuleMapper projectRequirementModuleMapper;
@Resource @Resource
private ProjectTaskMapper projectTaskMapper;
@Resource
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService; private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
@Resource @Resource
private ObjectDataScopeService objectDataScopeService; private ObjectDataScopeService objectDataScopeService;
@Resource
private StatusActionTextResolver statusActionTextResolver;
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@@ -366,6 +375,9 @@ class ProjectServiceImpl implements ProjectService {
} }
@Override @Override
// 对象域鉴权:是该产品的显式成员即可拿"实现项目"派发下拉数据memberOnly不查权限码、不依赖 DB 权限配置)
@com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE,
objectId = "#productId", memberOnly = true)
public List<ProjectRespVO> getProjectListByProductId(Long productId) { public List<ProjectRespVO> getProjectListByProductId(Long productId) {
validateProductUsable(productId); validateProductUsable(productId);
return projectMapper.selectListByProductId(productId).stream() return projectMapper.selectListByProductId(productId).stream()
@@ -388,7 +400,7 @@ class ProjectServiceImpl implements ProjectService {
// 显式角色为空:走 scope.contains 判定隐式 observer 兜底(设计文档 2.1 节末段) // 显式角色为空:走 scope.contains 判定隐式 observer 兜底(设计文档 2.1 节末段)
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE); ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
if (!scope.contains(id, project.getDirectionCode())) { if (!scope.contains(id, project.getDirectionCode())) {
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED, "查看"); throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED);
} }
return buildImplicitObserverContext(project); return buildImplicitObserverContext(project);
} }
@@ -575,7 +587,9 @@ class ProjectServiceImpl implements ProjectService {
ProjectDO project = validateProjectExists(reqVO.getId()); ProjectDO project = validateProjectExists(reqVO.getId());
String actionCode = reqVO.getActionCode().trim(); String actionCode = reqVO.getActionCode().trim();
if (ObjectActivityConstants.PROJECT_ACTION_AUTO_START.equals(actionCode)) { 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())); changeStatus(project, actionCode, normalizeNullableText(reqVO.getReason()));
} }
@@ -608,10 +622,12 @@ class ProjectServiceImpl implements ProjectService {
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void autoStartProjectIfPending(Long projectId, String triggerAction) { public void autoStartProjectIfPending(Long projectId, String triggerAction) {
// auto_start 只允许由后端业务动作内部触发,前端不应直接透传该动作。 // auto_start 只允许由后端业务动作内部触发,前端不应直接透传该动作。
if (!ProjectObjectConstants.AUTO_START_TRIGGERS.contains(triggerAction)) {
throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED, triggerAction);
}
ProjectDO project = validateProjectExists(projectId); 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 ObjectStatusTransitionDO transition = objectStatusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode(), .selectByObjectTypeAndFromStatusAndAction(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode(),
ObjectActivityConstants.PROJECT_ACTION_AUTO_START); ObjectActivityConstants.PROJECT_ACTION_AUTO_START);
@@ -620,7 +636,9 @@ class ProjectServiceImpl implements ProjectService {
ObjectStatusModelDO statusModel = validateEnabledStatusModel(project.getStatusCode()); ObjectStatusModelDO statusModel = validateEnabledStatusModel(project.getStatusCode());
if (Boolean.TRUE.equals(statusModel.getInitialFlag())) { if (Boolean.TRUE.equals(statusModel.getInitialFlag())) {
throw exception(ErrorCodeConstants.PROJECT_STATUS_ACTION_NOT_ALLOWED, 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())) { if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) {
throw exception(ErrorCodeConstants.PROJECT_STATUS_NOT_ALLOW_EDIT); throw exception(ErrorCodeConstants.PROJECT_STATUS_NOT_ALLOW_EDIT);
@@ -634,6 +652,39 @@ class ProjectServiceImpl implements ProjectService {
changeStatus(project, transition, ObjectActivityConstants.PROJECT_ACTION_AUTO_START, reason); changeStatus(project, transition, ObjectActivityConstants.PROJECT_ACTION_AUTO_START, reason);
} }
@Override
public void recalcProgress(Long projectId) {
if (projectId == null) {
return;
}
ProjectDO project = projectMapper.selectById(projectId);
if (project == null) {
// 项目已被删除(删除项目时其下任务已不可达,无须再触发;此处兜底,避免上游误调)
return;
}
List<String> excludedStatusCodes = objectStatusModelMapper
.selectProgressExcludedStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
BigDecimal newProgress = normalizeProgress(projectTaskMapper
.selectRootTaskAvgProgressByProjectId(projectId,
excludedStatusCodes == null ? Collections.emptyList() : excludedStatusCodes));
// 与当前缓存值数值相等则跳过 UPDATE避免不必要的写与审计字段抖动
BigDecimal current = project.getProgressRate();
if (current != null && current.compareTo(newProgress) == 0) {
return;
}
projectMapper.updateProgressRateById(projectId, newProgress);
}
/**
* 进度归一化null → 0.00,非 null → scale=2 HALF_UP。与执行/任务层口径一致。
*/
private BigDecimal normalizeProgress(BigDecimal progress) {
if (progress == null) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
return progress.setScale(2, RoundingMode.HALF_UP);
}
@VisibleForTesting @VisibleForTesting
void validateCreateReqVO(ProjectSaveReqVO createReqVO) { void validateCreateReqVO(ProjectSaveReqVO createReqVO) {
validateProjectCodeUnique(null, createReqVO.getProjectCode()); validateProjectCodeUnique(null, createReqVO.getProjectCode());
@@ -734,10 +785,12 @@ class ProjectServiceImpl implements ProjectService {
ObjectStatusTransitionDO transition = objectStatusTransitionMapper ObjectStatusTransitionDO transition = objectStatusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(ProjectObjectConstants.OBJECT_TYPE, fromStatusCode, actionCode); .selectByObjectTypeAndFromStatusAndAction(ProjectObjectConstants.OBJECT_TYPE, fromStatusCode, actionCode);
if (transition == null) { 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)) { 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; return transition;
} }

View File

@@ -1,9 +1,10 @@
package com.njcn.rdms.module.project.service.project; package com.njcn.rdms.module.project.service.project;
import com.njcn.rdms.framework.common.pojo.PageResult; import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants; 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.constant.ProjectTaskConstants;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageReqVO;
@@ -12,18 +13,17 @@ import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTask
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardRespVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardRespVO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO; import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO; import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper; 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.project.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
import com.njcn.rdms.module.project.service.project.task.ProjectTaskService; import com.njcn.rdms.module.project.service.project.task.ProjectTaskService;
import com.njcn.rdms.module.project.util.DueRangeSupport;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
@@ -42,28 +42,28 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
@Resource @Resource
private ProjectTaskMapper projectTaskMapper; private ProjectTaskMapper projectTaskMapper;
@Resource @Resource
private VisibilityScopeResolver visibilityScopeResolver;
@Resource
private ProjectTaskService projectTaskService; private ProjectTaskService projectTaskService;
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY)
public ProjectExecutionStatusBoardRespVO getExecutionStatusBoard(Long projectId, ProjectExecutionStatusBoardReqVO reqVO) { public ProjectExecutionStatusBoardRespVO getExecutionStatusBoard(Long projectId, ProjectExecutionStatusBoardReqVO reqVO) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
VisibilityScope scope = visibilityScopeResolver.resolveForProject(projectId, userId);
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE); List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE);
return buildExecutionStatusBoard(projectId, scope, reqVO, statusModels); return buildExecutionStatusBoard(projectId, reqVO, statusModels);
} }
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO) { public ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO) {
VisibilityScope scope = resolveTaskScope(projectId, executionId);
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE); List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
return buildTaskStatusBoard(projectId, executionId, scope, reqVO, statusModels); return buildTaskStatusBoard(projectId, executionId, reqVO, statusModels);
} }
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public ProjectTaskBoardPageRespVO getTaskBoardPage(Long projectId, Long executionId, ProjectTaskBoardPageReqVO reqVO) { public ProjectTaskBoardPageRespVO getTaskBoardPage(Long projectId, Long executionId, ProjectTaskBoardPageReqVO reqVO) {
VisibilityScope scope = resolveTaskScope(projectId, executionId);
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper List<ObjectStatusModelDO> statusModels = objectStatusModelMapper
.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE); .selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
@@ -77,7 +77,7 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
ProjectTaskBoardPageRespVO respVO = new ProjectTaskBoardPageRespVO(); ProjectTaskBoardPageRespVO respVO = new ProjectTaskBoardPageRespVO();
List<ProjectTaskBoardPageRespVO.ColumnItemVO> items = targetStatusModels.stream() List<ProjectTaskBoardPageRespVO.ColumnItemVO> items = targetStatusModels.stream()
.map(statusModel -> buildBoardColumn(projectId, executionId, scope, reqVO, statusModel)) .map(statusModel -> buildBoardColumn(projectId, executionId, reqVO, statusModel))
.collect(Collectors.toList()); .collect(Collectors.toList());
respVO.setItems(items); respVO.setItems(items);
return respVO; return respVO;
@@ -98,11 +98,10 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
} }
private ProjectTaskBoardPageRespVO.ColumnItemVO buildBoardColumn(Long projectId, Long executionId, private ProjectTaskBoardPageRespVO.ColumnItemVO buildBoardColumn(Long projectId, Long executionId,
VisibilityScope scope,
ProjectTaskBoardPageReqVO reqVO, ProjectTaskBoardPageReqVO reqVO,
ObjectStatusModelDO statusModel) { ObjectStatusModelDO statusModel) {
ProjectTaskPageReqVO innerReq = toInnerPageReq(reqVO, statusModel.getStatusCode()); ProjectTaskPageReqVO innerReq = toInnerPageReq(reqVO, statusModel.getStatusCode());
PageResult<ProjectTaskDO> doPage = projectTaskMapper.selectPageByExecutionId(projectId, executionId, scope, innerReq); PageResult<ProjectTaskDO> doPage = projectTaskMapper.selectPageByExecutionId(projectId, executionId, innerReq);
PageResult<ProjectTaskRespVO> voPage = projectTaskService.assembleTaskRespVOPage(projectId, executionId, doPage); PageResult<ProjectTaskRespVO> voPage = projectTaskService.assembleTaskRespVOPage(projectId, executionId, doPage);
ProjectTaskBoardPageRespVO.ColumnItemVO item = new ProjectTaskBoardPageRespVO.ColumnItemVO(); ProjectTaskBoardPageRespVO.ColumnItemVO item = new ProjectTaskBoardPageRespVO.ColumnItemVO();
@@ -124,6 +123,7 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
innerReq.setPageSize(reqVO.getPageSize()); innerReq.setPageSize(reqVO.getPageSize());
innerReq.setKeyword(reqVO.getKeyword()); innerReq.setKeyword(reqVO.getKeyword());
innerReq.setParentTaskId(reqVO.getParentTaskId()); innerReq.setParentTaskId(reqVO.getParentTaskId());
innerReq.setInvolveUserId(reqVO.getInvolveUserId());
innerReq.setOwnerId(reqVO.getOwnerId()); innerReq.setOwnerId(reqVO.getOwnerId());
innerReq.setPriority(reqVO.getPriority()); innerReq.setPriority(reqVO.getPriority());
innerReq.setUpdateTime(reqVO.getUpdateTime()); innerReq.setUpdateTime(reqVO.getUpdateTime());
@@ -131,34 +131,24 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
return innerReq; return innerReq;
} }
/**
* 计算任务可见性 scope与 ProjectTaskServiceImpl#computeTaskScope 同款:
* 项目经理 → seesAll执行负责人 = 当前用户 → seesAll否则按 resolveForExecution 求并集。
*/
private VisibilityScope resolveTaskScope(Long projectId, Long executionId) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
VisibilityScope scope = visibilityScopeResolver.resolveForExecution(projectId, executionId, userId);
if (scope.seesAll()) {
return scope;
}
ProjectExecutionDO exec = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
if (exec != null && Objects.equals(exec.getOwnerId(), userId)) {
return VisibilityScope.all();
}
return scope;
}
private ProjectExecutionStatusBoardRespVO buildExecutionStatusBoard(Long projectId, private ProjectExecutionStatusBoardRespVO buildExecutionStatusBoard(Long projectId,
VisibilityScope scope,
ProjectExecutionStatusBoardReqVO reqVO, ProjectExecutionStatusBoardReqVO reqVO,
List<ObjectStatusModelDO> statusModels) { List<ObjectStatusModelDO> statusModels) {
// dueRange 截止时间过滤所需的日期边界与执行终态码(终态排除口径对齐任务 summary
LocalDate today = DueRangeSupport.today();
LocalDate weekStart = DueRangeSupport.weekStart(today);
LocalDate weekEnd = DueRangeSupport.weekEnd(today);
List<String> terminalStatusCodes = objectStatusModelMapper
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE);
ProjectExecutionStatusBoardRespVO respVO = new ProjectExecutionStatusBoardRespVO(); ProjectExecutionStatusBoardRespVO respVO = new ProjectExecutionStatusBoardRespVO();
List<ProjectExecutionStatusBoardRespVO.ProjectStatusBoardItemVO> items = statusModels.stream().map(statusModel -> { List<ProjectExecutionStatusBoardRespVO.ProjectStatusBoardItemVO> items = statusModels.stream().map(statusModel -> {
ProjectExecutionStatusBoardRespVO.ProjectStatusBoardItemVO item = ProjectExecutionStatusBoardRespVO.ProjectStatusBoardItemVO item =
new ProjectExecutionStatusBoardRespVO.ProjectStatusBoardItemVO(); new ProjectExecutionStatusBoardRespVO.ProjectStatusBoardItemVO();
item.setStatusCode(statusModel.getStatusCode()); item.setStatusCode(statusModel.getStatusCode());
item.setStatusName(statusModel.getStatusName()); item.setStatusName(statusModel.getStatusName());
item.setCount(projectExecutionMapper.countByProjectIdAndStatusCode(projectId, scope, reqVO, statusModel.getStatusCode()).longValue()); item.setCount(projectExecutionMapper.countByProjectIdAndStatusCode(projectId, reqVO,
statusModel.getStatusCode(), terminalStatusCodes, today, weekStart, weekEnd).longValue());
item.setSort(statusModel.getSort()); item.setSort(statusModel.getSort());
item.setTerminal(statusModel.getTerminalFlag()); item.setTerminal(statusModel.getTerminalFlag());
return item; return item;
@@ -169,7 +159,6 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
} }
private ProjectTaskStatusBoardRespVO buildTaskStatusBoard(Long projectId, Long executionId, private ProjectTaskStatusBoardRespVO buildTaskStatusBoard(Long projectId, Long executionId,
VisibilityScope scope,
ProjectTaskStatusBoardReqVO reqVO, ProjectTaskStatusBoardReqVO reqVO,
List<ObjectStatusModelDO> statusModels) { List<ObjectStatusModelDO> statusModels) {
ProjectTaskStatusBoardRespVO respVO = new ProjectTaskStatusBoardRespVO(); ProjectTaskStatusBoardRespVO respVO = new ProjectTaskStatusBoardRespVO();
@@ -177,7 +166,7 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
ProjectTaskStatusBoardRespVO.ProjectStatusBoardItemVO item = new ProjectTaskStatusBoardRespVO.ProjectStatusBoardItemVO(); ProjectTaskStatusBoardRespVO.ProjectStatusBoardItemVO item = new ProjectTaskStatusBoardRespVO.ProjectStatusBoardItemVO();
item.setStatusCode(statusModel.getStatusCode()); item.setStatusCode(statusModel.getStatusCode());
item.setStatusName(statusModel.getStatusName()); item.setStatusName(statusModel.getStatusName());
item.setCount(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(projectId, executionId, scope, reqVO, item.setCount(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(projectId, executionId, reqVO,
statusModel.getStatusCode()).longValue()); statusModel.getStatusCode()).longValue());
item.setSort(statusModel.getSort()); item.setSort(statusModel.getSort());
item.setTerminal(statusModel.getTerminalFlag()); item.setTerminal(statusModel.getTerminalFlag());

View File

@@ -78,6 +78,8 @@ public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssi
private AdminUserApi adminUserApi; private AdminUserApi adminUserApi;
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY)
public List<ExecutionAssigneeRespVO> getExecutionAssigneeList(Long projectId, Long executionId) { public List<ExecutionAssigneeRespVO> getExecutionAssigneeList(Long projectId, Long executionId) {
validateProjectExists(projectId); validateProjectExists(projectId);
validateExecutionExists(projectId, executionId); validateExecutionExists(projectId, executionId);
@@ -150,6 +152,8 @@ public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssi
} }
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY)
public PageResult<ExecutionAssigneeLogRespVO> getExecutionAssigneeLogPage(Long projectId, Long executionId, public PageResult<ExecutionAssigneeLogRespVO> getExecutionAssigneeLogPage(Long projectId, Long executionId,
ExecutionAssigneeLogPageReqVO reqVO) { ExecutionAssigneeLogPageReqVO reqVO) {
validateProjectExists(projectId); validateProjectExists(projectId);

View File

@@ -1,6 +1,8 @@
package com.njcn.rdms.module.project.service.project.execution; package com.njcn.rdms.module.project.service.project.execution;
import com.njcn.rdms.framework.common.pojo.PageResult; 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.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.ProjectExecutionPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO;
@@ -38,6 +40,13 @@ public interface ProjectExecutionService {
List<ProjectExecutionRespVO> getCurrentUserExecutionList(); List<ProjectExecutionRespVO> getCurrentUserExecutionList();
/**
* 分页查询当前登录用户作为负责人owner的执行跨所有项目聚合。
* 默认口径排除终态状态completed/cancelled且排除进度已满progressRate >= 100的执行。
* pageSize 传 -1PageParam.PAGE_SIZE_NONE= 返回全部、不切片。
*/
PageResult<MyProjectExecutionRespVO> getMyExecutionPage(MyProjectExecutionPageReqVO reqVO);
void changeOwner(Long projectId, Long executionId, ProjectExecutionOwnerChangeReqVO reqVO); void changeOwner(Long projectId, Long executionId, ProjectExecutionOwnerChangeReqVO reqVO);
/** /**

View File

@@ -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.ObjectActivityConstants;
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants; import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants; 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.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.ProjectExecutionPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO;
@@ -41,12 +43,12 @@ 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.dal.mysql.status.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants; import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants; import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
import com.njcn.rdms.module.project.util.DueRangeSupport;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission; import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import com.njcn.rdms.module.project.service.project.ProjectRequirementService; 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.ProjectService;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
import com.njcn.rdms.module.project.service.project.task.ProjectTaskService; 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.dict.DictDataApi;
import com.njcn.rdms.module.system.api.user.AdminUserApi; import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO; import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
@@ -120,7 +122,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@Resource @Resource
private ProjectRequirementMapper projectRequirementMapper; private ProjectRequirementMapper projectRequirementMapper;
@Resource @Resource
private VisibilityScopeResolver visibilityScopeResolver; private StatusActionTextResolver statusActionTextResolver;
/** /**
* 任务服务:执行 cancel / pause / resume 时级联调任务侧 internal 入口。 * 任务服务:执行 cancel / pause / resume 时级联调任务侧 internal 入口。
* 与 ProjectTaskService 互相依赖(任务侧已注入 ProjectExecutionService 用于通知执行),用 @Lazy 打破循环。 * 与 ProjectTaskService 互相依赖(任务侧已注入 ProjectExecutionService 用于通知执行),用 @Lazy 打破循环。
@@ -209,24 +211,25 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
} }
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY)
public PageResult<ProjectExecutionDO> getExecutionPage(Long projectId, ProjectExecutionPageReqVO reqVO) { public PageResult<ProjectExecutionDO> getExecutionPage(Long projectId, ProjectExecutionPageReqVO reqVO) {
validateProjectExists(projectId); validateProjectExists(projectId);
// 数据可见性:项目经理看全部;非经理按"我 owner 的执行 我活跃协办的执行"过滤 // "我参与 / 所有"视角完全由前端发不发 reqVO.involveUserId 决定。
Long userId = SecurityFrameworkUtils.getLoginUserId(); // 注getExecutionRespVOPage 内部 this.getExecutionPage() 自调用不触发 AOP但外层注解已守门
VisibilityScope scope = visibilityScopeResolver.resolveForProject(projectId, userId); // 此处独立挂注解是为了堵跨 service 直调 ProjectExecutionService.getExecutionPage 的鉴权后门。
return projectExecutionMapper.selectPageByProjectId(projectId, scope, reqVO); // dueRange 截止时间过滤所需的日期边界与执行终态码(终态排除口径对齐任务 summary
LocalDate today = DueRangeSupport.today();
return projectExecutionMapper.selectPageByProjectId(projectId, reqVO,
loadExecutionTerminalStatusCodes(), today,
DueRangeSupport.weekStart(today), DueRangeSupport.weekEnd(today));
} }
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY)
public ProjectExecutionRespVO getExecutionRespVO(Long projectId, Long executionId) { public ProjectExecutionRespVO getExecutionRespVO(Long projectId, Long executionId) {
ProjectExecutionDO execution = getExecution(projectId, executionId); ProjectExecutionDO execution = getExecution(projectId, executionId);
// 可见性卡断:项目经理放行;否则 executionId 必须在 scope.executionIds 中。
// 未命中按"执行不存在"语义返回,不暴露存在性。
Long userId = SecurityFrameworkUtils.getLoginUserId();
VisibilityScope scope = visibilityScopeResolver.resolveForProject(projectId, userId);
if (!scope.seesAll() && !scope.executionIds().contains(executionId)) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_EXISTS);
}
ProjectExecutionRespVO respVO = BeanUtils.toBean(execution, ProjectExecutionRespVO.class); ProjectExecutionRespVO respVO = BeanUtils.toBean(execution, ProjectExecutionRespVO.class);
respVO.setProgressRate(loadExecutionProgress(projectId, executionId)); respVO.setProgressRate(loadExecutionProgress(projectId, executionId));
boolean rootTasksAllCompleted = loadExecutionRootTasksAllCompleted(projectId, executionId); boolean rootTasksAllCompleted = loadExecutionRootTasksAllCompleted(projectId, executionId);
@@ -237,6 +240,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
} }
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY)
public PageResult<ProjectExecutionRespVO> getExecutionRespVOPage(Long projectId, ProjectExecutionPageReqVO reqVO) { public PageResult<ProjectExecutionRespVO> getExecutionRespVOPage(Long projectId, ProjectExecutionPageReqVO reqVO) {
PageResult<ProjectExecutionDO> pageResult = getExecutionPage(projectId, reqVO); PageResult<ProjectExecutionDO> pageResult = getExecutionPage(projectId, reqVO);
PageResult<ProjectExecutionRespVO> voPageResult = BeanUtils.toBean(pageResult, ProjectExecutionRespVO.class); PageResult<ProjectExecutionRespVO> voPageResult = BeanUtils.toBean(pageResult, ProjectExecutionRespVO.class);
@@ -302,6 +307,91 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return list; return list;
} }
@Override
public PageResult<MyProjectExecutionRespVO> getMyExecutionPage(MyProjectExecutionPageReqVO reqVO) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
List<ProjectExecutionDO> executions = projectExecutionMapper.selectListByOwnerId(loginUserId);
if (executions == null || executions.isEmpty()) {
return new PageResult<>(Collections.emptyList(), 0L);
}
// 1. 排除终态状态completed/cancelledDB 权威,不硬编码)
List<String> terminalStatusCodes =
objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE);
List<ProjectExecutionDO> 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<String> excludedTaskStatusCodes = loadProgressExcludedTaskStatusCodes();
Map<Long, BigDecimal> progressMap = new HashMap<>();
nonTerminal.stream()
.filter(e -> e.getProjectId() != null)
.collect(Collectors.groupingBy(ProjectExecutionDO::getProjectId, LinkedHashMap::new, Collectors.toList()))
.forEach((groupProjectId, groupList) -> {
Set<Long> 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<ProjectExecutionDO> 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<Long> projectIds = filtered.stream()
.map(ProjectExecutionDO::getProjectId).filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, String> projectNameMap = projectIds.isEmpty() ? Collections.emptyMap()
: projectMapper.selectBatchIds(projectIds).stream()
.collect(Collectors.toMap(ProjectDO::getId, ProjectDO::getProjectName, (a, b) -> a));
Map<String, String> statusNameMap = objectStatusModelMapper
.selectListByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE).stream()
.collect(Collectors.toMap(ObjectStatusModelDO::getStatusCode, ObjectStatusModelDO::getStatusName, (a, b) -> a));
Set<Long> requirementIds = filtered.stream()
.map(ProjectExecutionDO::getProjectRequirementId).filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, String> requirementNameMap = requirementIds.isEmpty() ? Collections.emptyMap()
: projectRequirementMapper.selectBatchIds(requirementIds).stream()
.collect(Collectors.toMap(ProjectRequirementDO::getId, ProjectRequirementDO::getTitle, (a, b) -> a));
// 5. 组装精简 VOprogressRate BigDecimal → Integer 四舍五入)
List<MyProjectExecutionRespVO> 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<0PAGE_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 @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
@@ -489,10 +579,12 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
ObjectStatusTransitionDO transition = objectStatusTransitionMapper ObjectStatusTransitionDO transition = objectStatusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(ProjectExecutionConstants.OBJECT_TYPE, fromStatusCode, actionCode); .selectByObjectTypeAndFromStatusAndAction(ProjectExecutionConstants.OBJECT_TYPE, fromStatusCode, actionCode);
if (transition == null) { 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)) { 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; return transition;
} }
@@ -859,6 +951,13 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return statusCodes == null ? Collections.emptyList() : statusCodes; return statusCodes == null ? Collections.emptyList() : statusCodes;
} }
/** dueRange 终态排除用:执行对象域的终态码(动态查,不硬编码)。 */
private List<String> loadExecutionTerminalStatusCodes() {
List<String> statusCodes = objectStatusModelMapper
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE);
return statusCodes == null ? Collections.emptyList() : statusCodes;
}
/** /**
* 执行详情完成态:判断"参与聚合的根任务"是否全部为 completed。 * 执行详情完成态:判断"参与聚合的根任务"是否全部为 completed。
* 筛选口径与 loadExecutionProgress 同源;空集(无参与聚合的根任务)返回 false禁止下发 complete。 * 筛选口径与 loadExecutionProgress 同源;空集(无参与聚合的根任务)返回 false禁止下发 complete。
@@ -1010,7 +1109,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
if (!Objects.equals(loginUserId, execution.getOwnerId())) { if (!Objects.equals(loginUserId, execution.getOwnerId())) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_OWNER_ONLY, throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_OWNER_ONLY,
resolveActionDisplayName(actionCode)); statusActionTextResolver.actionName(ProjectExecutionConstants.OBJECT_TYPE, actionCode));
} }
} }
@@ -1021,16 +1120,6 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|| "resume".equals(actionCode); || "resume".equals(actionCode);
} }
private String resolveActionDisplayName(String actionCode) {
return switch (actionCode) {
case "complete" -> "完成";
case "cancel" -> "取消";
case "pause" -> "暂停";
case "resume" -> "恢复";
default -> actionCode;
};
}
/** /**
* 完成执行前置校验执行下所有任务必须已经进入终态completed / cancelled * 完成执行前置校验执行下所有任务必须已经进入终态completed / cancelled
*/ */

View File

@@ -1,31 +0,0 @@
package com.njcn.rdms.module.project.service.project.permission;
import java.util.Set;
/**
* 数据可见性 scope由 VisibilityScopeResolver 计算得出。
* - seesAll=true项目经理等"看全部"角色,分页/计数 SQL 跳过任何 ID 过滤
* - seesAll=false仅命中 executionIds / taskIds 的数据可见;集合为空 = 完全不可见
*
* 实例不可变;空集合用 Set.of() 表达,调用方不得修改。
*/
public record VisibilityScope(
boolean seesAll,
Set<Long> executionIds,
Set<Long> taskIds
) {
public static VisibilityScope all() {
return new VisibilityScope(true, Set.of(), Set.of());
}
public static VisibilityScope of(Set<Long> executionIds, Set<Long> taskIds) {
return new VisibilityScope(false,
executionIds == null ? Set.of() : Set.copyOf(executionIds),
taskIds == null ? Set.of() : Set.copyOf(taskIds));
}
public static VisibilityScope empty() {
return new VisibilityScope(false, Set.of(), Set.of());
}
}

View File

@@ -1,28 +0,0 @@
package com.njcn.rdms.module.project.service.project.permission;
/**
* 计算当前登录用户在某项目 / 某执行下的数据可见性 scope。
*
* 规则:
* - 项目经理project.manager_user_id == userId→ seesAll=true
* - 非项目经理 → 取以下 4 项的并集,构成 (executionIds, taskIds)
* 1. 我作为 execution.owner_id 的执行 ID
* 2. 我作为 execution_assignee 活跃协办的执行 IDremoved_at IS NULL
* 3. 我作为 task.owner_id 的任务 ID 及其全部子孙 ID递归 CTE 一次展开)
* 4. 我作为 task_assignee 活跃协办的任务 IDremoved_at IS NULL
*
* 任务参与者集合 ⊆ 执行参与者集合(业务约束:任务负责人/协办人必须从执行团队挑选)。
*/
public interface VisibilityScopeResolver {
/**
* 项目维度 scope用于执行分页 / 执行看板)。
*/
VisibilityScope resolveForProject(Long projectId, Long userId);
/**
* 执行维度 scope用于任务分页 / 任务看板 / 任务详情)。
* 调用方需先保证 executionId 属于 projectId由 URL 路径约束)。
*/
VisibilityScope resolveForExecution(Long projectId, Long executionId, Long userId);
}

View File

@@ -1,76 +0,0 @@
package com.njcn.rdms.module.project.service.project.permission;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper;
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.project.task.TaskAssigneeMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
/**
* VisibilityScopeResolver 实现。
*
* 短路project.manager_user_id == userId → seesAll=true跳过任何 ID 过滤。
* 非项目经理:并集 4 个 Mapper 来源得到可见的 executionIds / taskIds。
*
* 任务的"执行 owner 看执行下所有任务"短路不在此处实现,
* 由 ProjectTaskServiceImpl / ProjectStatusBoardServiceImpl 在调用本 Resolver 前自行判定。
* 本 Resolver 仅负责"参与者 → 可见 ID 集合"的纯查询。
*/
@Service
public class VisibilityScopeResolverImpl implements VisibilityScopeResolver {
@Resource
private ProjectMapper projectMapper;
@Resource
private ProjectExecutionMapper projectExecutionMapper;
@Resource
private ExecutionAssigneeMapper executionAssigneeMapper;
@Resource
private ProjectTaskMapper projectTaskMapper;
@Resource
private TaskAssigneeMapper taskAssigneeMapper;
@Override
public VisibilityScope resolveForProject(Long projectId, Long userId) {
if (isProjectManager(projectId, userId)) {
return VisibilityScope.all();
}
Set<Long> executionIds = new LinkedHashSet<>();
executionIds.addAll(projectExecutionMapper.selectIdsByProjectIdAndOwnerId(projectId, userId));
executionIds.addAll(executionAssigneeMapper.selectActiveExecutionIdsByProjectIdAndUserId(projectId, userId));
Set<Long> taskIds = new LinkedHashSet<>();
taskIds.addAll(projectTaskMapper.selectOwnedTaskAndDescendantIdsByProjectIdAndUserId(projectId, userId));
taskIds.addAll(taskAssigneeMapper.selectActiveTaskIdsByProjectIdAndUserId(projectId, userId));
return VisibilityScope.of(executionIds, taskIds);
}
@Override
public VisibilityScope resolveForExecution(Long projectId, Long executionId, Long userId) {
if (isProjectManager(projectId, userId)) {
return VisibilityScope.all();
}
// executionIds 在执行维度无用,统一传空集;调用方靠 taskIds 过滤分页/计数。
Set<Long> taskIds = new LinkedHashSet<>();
taskIds.addAll(projectTaskMapper.selectOwnedTaskAndDescendantIdsByExecutionIdAndUserId(projectId, executionId, userId));
taskIds.addAll(taskAssigneeMapper.selectActiveTaskIdsByProjectIdAndExecutionIdAndUserId(projectId, executionId, userId));
return VisibilityScope.of(Set.of(), taskIds);
}
private boolean isProjectManager(Long projectId, Long userId) {
if (projectId == null || userId == null) {
return false;
}
ProjectDO project = projectMapper.selectById(projectId);
return project != null && Objects.equals(project.getManagerUserId(), userId);
}
}

View File

@@ -0,0 +1,32 @@
package com.njcn.rdms.module.project.service.project.task;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskAggregateBoardPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskAggregatePageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskAggregateStatusBoardReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskSummaryReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskSummaryRespVO;
/**
* 项目级跨执行任务查询 Service与执行级 {@link ProjectTaskService} 互补)。
* 服务于"我的任务"、"项目全部任务"两种跨执行视角。
*/
public interface ProjectTaskAggregateService {
PageResult<ProjectTaskRespVO> getAggregateTaskPage(Long projectId, ProjectTaskAggregatePageReqVO reqVO);
ProjectTaskStatusBoardRespVO getAggregateTaskStatusBoard(Long projectId, ProjectTaskAggregateStatusBoardReqVO reqVO);
ProjectTaskBoardPageRespVO getAggregateTaskBoardPage(Long projectId, ProjectTaskAggregateBoardPageReqVO reqVO);
/**
* 跨执行任务今日小条:
* 入参 involveUserId 为 null → 项目内全部任务;
* involveUserId 不为 null → 限定 owner 或活跃协办为该用户。
* 由 @CheckObjectPermission(project:task:query) 守门,无权限直接 403。
*/
ProjectTaskSummaryRespVO getAggregateTaskSummary(Long projectId, ProjectTaskSummaryReqVO reqVO);
}

View File

@@ -0,0 +1,268 @@
package com.njcn.rdms.module.project.service.project.task;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.rdms.framework.common.pojo.PageResult;
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.task.vo.ProjectTaskBoardPageRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskAggregateBoardPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskAggregatePageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskAggregateStatusBoardReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskSummaryReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.aggregate.ProjectTaskSummaryRespVO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
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.project.framework.security.annotation.CheckObjectPermission;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.TemporalAdjusters;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Slf4j
@Service
public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateService {
private static final ZoneId SERVER_ZONE = ZoneId.of("Asia/Shanghai");
@Resource
private ProjectTaskMapper projectTaskMapper;
@Resource
private ProjectTaskService projectTaskService;
@Resource
private ObjectStatusModelMapper objectStatusModelMapper;
// ========= 公共 helper =========
private LocalDate today() {
return LocalDate.now(SERVER_ZONE);
}
private LocalDate weekStart(LocalDate today) {
return today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
}
private LocalDate weekEnd(LocalDate today) {
return today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY));
}
private List<String> loadTerminalStatusCodes() {
return objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
}
// ========= page =========
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public PageResult<ProjectTaskRespVO> getAggregateTaskPage(Long projectId, ProjectTaskAggregatePageReqVO reqVO) {
// 空数组语义短路:前端明确"按 0 个执行状态/执行 id 过滤" → 返空集合,不让 MyBatis 退化成"不过滤"
if ((reqVO.getExecutionStatusCodes() != null && reqVO.getExecutionStatusCodes().isEmpty())
|| (reqVO.getExecutionIds() != null && reqVO.getExecutionIds().isEmpty())) {
return PageResult.empty();
}
LocalDate today = today();
LocalDate weekStart = weekStart(today);
LocalDate weekEnd = weekEnd(today);
List<String> terminalStatusCodes = loadTerminalStatusCodes();
Page<ProjectTaskDO> page = new Page<>(reqVO.getPageNo(), reqVO.getPageSize());
IPage<ProjectTaskDO> ipage = projectTaskMapper.selectAggregatePageByProjectId(
projectId, reqVO, terminalStatusCodes, weekStart, weekEnd, today, page);
PageResult<ProjectTaskDO> doPage = new PageResult<>(ipage.getRecords(), ipage.getTotal());
return projectTaskService.assembleTaskRespVOPageCrossExecution(projectId, doPage);
}
// ========= status-board =========
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public ProjectTaskStatusBoardRespVO getAggregateTaskStatusBoard(Long projectId, ProjectTaskAggregateStatusBoardReqVO reqVO) {
// 空数组语义短路:跳过 SQL,但保留状态列骨架(前端看板依赖全列表渲染),count 全部 0
if ((reqVO.getExecutionStatusCodes() != null && reqVO.getExecutionStatusCodes().isEmpty())
|| (reqVO.getExecutionIds() != null && reqVO.getExecutionIds().isEmpty())) {
List<ObjectStatusModelDO> emptyStatusModels =
objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
return buildStatusBoardResponse(emptyStatusModels, Collections.emptyMap());
}
LocalDate today = today();
LocalDate weekStart = weekStart(today);
LocalDate weekEnd = weekEnd(today);
List<String> terminalStatusCodes = loadTerminalStatusCodes();
List<ObjectStatusModelDO> statusModels =
objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
List<ProjectTaskMapper.StatusCountRow> rows = projectTaskMapper.selectAggregateStatusCount(
projectId, reqVO, terminalStatusCodes, weekStart, weekEnd, today);
Map<String, Long> countMap = rows.stream()
.collect(Collectors.toMap(
ProjectTaskMapper.StatusCountRow::getStatusCode,
ProjectTaskMapper.StatusCountRow::getCount));
return buildStatusBoardResponse(statusModels, countMap);
}
/**
* 按状态模型全量列出count=0 的状态也输出(前端看板始终显示所有列)。
*/
private ProjectTaskStatusBoardRespVO buildStatusBoardResponse(
List<ObjectStatusModelDO> statusModels, Map<String, Long> countMap) {
List<ProjectTaskStatusBoardRespVO.ProjectStatusBoardItemVO> items = statusModels.stream()
.map(sm -> {
ProjectTaskStatusBoardRespVO.ProjectStatusBoardItemVO item =
new ProjectTaskStatusBoardRespVO.ProjectStatusBoardItemVO();
item.setStatusCode(sm.getStatusCode());
item.setStatusName(sm.getStatusName());
item.setCount(countMap.getOrDefault(sm.getStatusCode(), 0L));
item.setSort(sm.getSort());
item.setTerminal(sm.getTerminalFlag());
return item;
})
.collect(Collectors.toList());
long total = items.stream().mapToLong(i -> i.getCount() == null ? 0L : i.getCount()).sum();
ProjectTaskStatusBoardRespVO resp = new ProjectTaskStatusBoardRespVO();
resp.setTotal(total);
resp.setItems(items);
return resp;
}
// ========= board-page =========
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public ProjectTaskBoardPageRespVO getAggregateTaskBoardPage(Long projectId, ProjectTaskAggregateBoardPageReqVO reqVO) {
// 空数组语义短路:executionStatusCodes 或 executionIds 为空数组 → 该范围明确为空,跳过 SQL,保留列骨架(每列 list=[] / total=0)
boolean emptyExecScope = (reqVO.getExecutionStatusCodes() != null && reqVO.getExecutionStatusCodes().isEmpty())
|| (reqVO.getExecutionIds() != null && reqVO.getExecutionIds().isEmpty());
List<ObjectStatusModelDO> allStatusModels =
objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
List<ObjectStatusModelDO> targetStatusModels;
if (reqVO.getStatusCodes() == null || reqVO.getStatusCodes().isEmpty()) {
targetStatusModels = allStatusModels;
} else {
Set<String> filter = new HashSet<>(reqVO.getStatusCodes());
targetStatusModels = allStatusModels.stream()
.filter(sm -> filter.contains(sm.getStatusCode()))
.collect(Collectors.toList());
}
if (emptyExecScope) {
List<ProjectTaskBoardPageRespVO.ColumnItemVO> emptyItems = targetStatusModels.stream()
.map(sm -> buildColumnItemVO(sm, PageResult.empty()))
.collect(Collectors.toList());
ProjectTaskBoardPageRespVO emptyResp = new ProjectTaskBoardPageRespVO();
emptyResp.setItems(emptyItems);
return emptyResp;
}
LocalDate today = today();
LocalDate weekStart = weekStart(today);
LocalDate weekEnd = weekEnd(today);
List<String> terminalStatusCodes = loadTerminalStatusCodes();
List<ProjectTaskBoardPageRespVO.ColumnItemVO> items = targetStatusModels.stream()
.map(sm -> buildAggregateBoardColumn(projectId, reqVO, sm,
terminalStatusCodes, today, weekStart, weekEnd))
.collect(Collectors.toList());
ProjectTaskBoardPageRespVO resp = new ProjectTaskBoardPageRespVO();
resp.setItems(items);
return resp;
}
private ProjectTaskBoardPageRespVO.ColumnItemVO buildAggregateBoardColumn(
Long projectId,
ProjectTaskAggregateBoardPageReqVO reqVO, ObjectStatusModelDO sm,
List<String> terminalStatusCodes, LocalDate today, LocalDate weekStart, LocalDate weekEnd) {
// 复用 selectAggregatePageByProjectId构造单列过滤的 innerReq
ProjectTaskAggregatePageReqVO innerReq = new ProjectTaskAggregatePageReqVO();
innerReq.setPageNo(reqVO.getPageNo());
innerReq.setPageSize(reqVO.getPageSize());
innerReq.setKeyword(reqVO.getKeyword());
innerReq.setExecutionIds(reqVO.getExecutionIds());
innerReq.setExecutionStatusCodes(reqVO.getExecutionStatusCodes());
innerReq.setInvolveUserId(reqVO.getInvolveUserId());
innerReq.setExecutionInvolveUserId(reqVO.getExecutionInvolveUserId());
innerReq.setOwnerId(reqVO.getOwnerId());
innerReq.setStatusCodes(Collections.singletonList(sm.getStatusCode()));
innerReq.setPriority(reqVO.getPriority());
innerReq.setParentTaskId(reqVO.getParentTaskId());
innerReq.setDueRange(reqVO.getDueRange());
innerReq.setUpdateTime(reqVO.getUpdateTime());
// board-page 不传 sortBy / sortOrder用 page 查询的默认排序plannedEndDate ASC
Page<ProjectTaskDO> page = new Page<>(innerReq.getPageNo(), innerReq.getPageSize());
IPage<ProjectTaskDO> ipage = projectTaskMapper.selectAggregatePageByProjectId(
projectId, innerReq, terminalStatusCodes, weekStart, weekEnd, today, page);
PageResult<ProjectTaskDO> doPage = new PageResult<>(ipage.getRecords(), ipage.getTotal());
PageResult<ProjectTaskRespVO> voPage =
projectTaskService.assembleTaskRespVOPageCrossExecution(projectId, doPage);
return buildColumnItemVO(sm, voPage);
}
private ProjectTaskBoardPageRespVO.ColumnItemVO buildColumnItemVO(
ObjectStatusModelDO sm, PageResult<ProjectTaskRespVO> voPage) {
ProjectTaskBoardPageRespVO.ColumnItemVO col = new ProjectTaskBoardPageRespVO.ColumnItemVO();
col.setStatusCode(sm.getStatusCode());
col.setStatusName(sm.getStatusName());
col.setSort(sm.getSort());
col.setTerminal(sm.getTerminalFlag());
col.setList(voPage.getList());
col.setTotal(voPage.getTotal());
return col;
}
// ========= summary =========
@Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public ProjectTaskSummaryRespVO getAggregateTaskSummary(Long projectId, ProjectTaskSummaryReqVO reqVO) {
LocalDate today = today();
LocalDate weekStart = weekStart(today);
LocalDate weekEnd = weekEnd(today);
List<String> terminalStatusCodes = loadTerminalStatusCodes();
Map<String, Long> counts = projectTaskMapper.selectAggregateSummaryCounts(
projectId,
reqVO.getInvolveUserId(),
terminalStatusCodes, ProjectTaskConstants.STATUS_COMPLETED,
today, weekStart, weekEnd);
ProjectTaskSummaryRespVO resp = new ProjectTaskSummaryRespVO();
resp.setOverdue(zeroIfNull(counts.get("overdue")));
resp.setDueToday(zeroIfNull(counts.get("dueToday")));
resp.setDueThisWeek(zeroIfNull(counts.get("dueThisWeek")));
resp.setDoneThisWeek(zeroIfNull(counts.get("doneThisWeek")));
resp.setToday(today);
resp.setWeekStart(weekStart);
resp.setWeekEnd(weekEnd);
return resp;
}
private Long zeroIfNull(Long v) {
return v == null ? 0L : v;
}
}

View File

@@ -40,6 +40,18 @@ public interface ProjectTaskService {
PageResult<ProjectTaskRespVO> assembleTaskRespVOPage(Long projectId, Long executionId, PageResult<ProjectTaskRespVO> assembleTaskRespVOPage(Long projectId, Long executionId,
PageResult<ProjectTaskDO> doPage); PageResult<ProjectTaskDO> doPage);
/**
* 跨执行装配 ProjectTaskRespVO 分页结果。
*
* 与 {@link #assembleTaskRespVOPage(Long, Long, PageResult)} 区别:
* 本方法不绑单个 executionId,允许 page 内任务来自项目下任意多个执行;
* 装配时按 task.executionId 分组批量回填 executionName / executionStatusCode
* (走 enrichExecutionInfo helper)。
* <p><b>不填充字段</b>:executionOwnerId / projectRequirementName / projectRequirementStatusCode
* (跨多 execution 场景无法共享单 execution 上下文)。前端跨执行视图按需处理。</p>
*/
PageResult<ProjectTaskRespVO> assembleTaskRespVOPageCrossExecution(Long projectId, PageResult<ProjectTaskDO> doPage);
void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO); void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO);
/** /**

View File

@@ -1,5 +1,6 @@
package com.njcn.rdms.module.project.service.project.task; package com.njcn.rdms.module.project.service.project.task;
import cn.hutool.core.collection.CollUtil;
import com.njcn.rdms.framework.common.pojo.CommonResult; import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageResult; import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.json.JsonUtils; import com.njcn.rdms.framework.common.util.json.JsonUtils;
@@ -44,10 +45,9 @@ import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPer
import com.njcn.rdms.module.project.framework.security.service.ProjectObjectAuthorizationService; import com.njcn.rdms.module.project.framework.security.service.ProjectObjectAuthorizationService;
import com.njcn.rdms.module.project.service.project.ProjectService; 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.execution.ProjectExecutionService;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeService; 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.project.task.worklog.TaskWorklogService;
import com.njcn.rdms.module.project.service.status.StatusActionTextResolver;
import com.google.common.annotations.VisibleForTesting; import com.google.common.annotations.VisibleForTesting;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants; import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
import com.njcn.rdms.module.system.api.dict.DictDataApi; import com.njcn.rdms.module.system.api.dict.DictDataApi;
@@ -62,6 +62,7 @@ import org.springframework.util.StringUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
@@ -85,6 +86,8 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
@Slf4j @Slf4j
public class ProjectTaskServiceImpl implements ProjectTaskService { public class ProjectTaskServiceImpl implements ProjectTaskService {
private static final ZoneId SERVER_ZONE = ZoneId.of("Asia/Shanghai");
@Resource @Resource
private ProjectMapper projectMapper; private ProjectMapper projectMapper;
@Resource @Resource
@@ -124,9 +127,9 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Resource @Resource
private ProjectObjectAuthorizationService projectObjectAuthorizationService; private ProjectObjectAuthorizationService projectObjectAuthorizationService;
@Resource @Resource
private VisibilityScopeResolver visibilityScopeResolver;
@Resource
private DictDataApi dictDataApi; private DictDataApi dictDataApi;
@Resource
private StatusActionTextResolver statusActionTextResolver;
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@@ -173,6 +176,9 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
if (task.getParentTaskId() != null) { if (task.getParentTaskId() != null) {
recalcParentProgressFrom(task.getParentTaskId()); recalcParentProgressFrom(task.getParentTaskId());
} }
// 项目进度推算:无论新建的是根任务(进度=0 进入项目均值)还是子任务(冒泡后根任务进度可能变化),
// 项目均值都可能发生变化,统一触发一次项目层重算(覆盖根任务边界)
projectService.recalcProgress(projectId);
writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_CREATE, null, task.getStatusCode(), writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_CREATE, null, task.getStatusCode(),
buildTaskFieldChanges(null, task), null); buildTaskFieldChanges(null, task), null);
@@ -238,6 +244,9 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
parentsToRecalc.add(newParentId); parentsToRecalc.add(newParentId);
} }
parentsToRecalc.forEach(this::recalcParentProgressFrom); parentsToRecalc.forEach(this::recalcParentProgressFrom);
// 项目进度推算updateTask 可能改变 parent_task_id使 task 进入/离开"根任务集合"
// 项目均值的分量构成可能变化;无论 progressRate 是否变化都需触发刷新
projectService.recalcProgress(projectId);
writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_UPDATE, before.getStatusCode(), writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_UPDATE, before.getStatusCode(),
task.getStatusCode(), buildTaskFieldChanges(before, task), null); task.getStatusCode(), buildTaskFieldChanges(before, task), null);
@@ -281,6 +290,9 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
if (parentTaskId != null) { if (parentTaskId != null) {
recalcParentProgressFrom(parentTaskId); recalcParentProgressFrom(parentTaskId);
} }
// 项目进度推算:无论删除的是根任务(项目分量减一)还是子任务(父链冒泡到根后根任务进度变化),
// 项目均值都可能发生变化,统一触发一次项目层重算(覆盖根任务边界)
projectService.recalcProgress(task.getProjectId());
writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_DELETE, fromStatus, null, null, reason); writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_DELETE, fromStatus, null, null, reason);
} }
@@ -361,6 +373,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
if (task.getParentTaskId() != null) { if (task.getParentTaskId() != null) {
recalcParentProgressFrom(task.getParentTaskId()); recalcParentProgressFrom(task.getParentTaskId());
} }
// 项目进度推算worklog 驱动的任务进度变化(叶子任务),无论叶子任务是否同时是根任务都需触发
projectService.recalcProgress(task.getProjectId());
} }
@Override @Override
@@ -417,44 +431,23 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
} }
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public PageResult<ProjectTaskDO> getTaskPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) { public PageResult<ProjectTaskDO> getTaskPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) {
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); validateExecutionExists(projectId, executionId);
VisibilityScope scope = computeTaskScope(projectId, executionId, execution); // 进得来 = 看执行下全部任务,"我参与 / 所有"由前端发不发 reqVO.ownerId / involveUserId 决定。
return projectTaskMapper.selectPageByExecutionId(projectId, executionId, scope, reqVO); // 注getTaskRespVOPage 内部 this.getTaskPage() 自调用不触发 AOP但外层注解已守门
} // 此处独立挂注解是为了堵跨 service 直调 ProjectTaskService.getTaskPage 的鉴权后门。
return projectTaskMapper.selectPageByExecutionId(projectId, executionId, reqVO);
/**
* 任务可见性计算:
* - 项目经理 → seesAll由 Resolver 内置判定)
* - 执行负责人 = 当前用户 → seesAll看本执行下全部任务
* - 否则 → resolveForExecution 求并集(我 owner 的任务及子孙 我活跃协办的任务)
*/
private VisibilityScope computeTaskScope(Long projectId, Long executionId, ProjectExecutionDO execution) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
VisibilityScope scope = visibilityScopeResolver.resolveForExecution(projectId, executionId, userId);
if (scope.seesAll()) {
return scope;
}
if (execution != null && Objects.equals(execution.getOwnerId(), userId)) {
return VisibilityScope.all();
}
return scope;
} }
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public ProjectTaskRespVO getTaskRespVO(Long projectId, Long executionId, Long taskId) { public ProjectTaskRespVO getTaskRespVO(Long projectId, Long executionId, Long taskId) {
// 内联 validate便于接住 execution 供前端 executionOwnerId 字段使用 // 内联 validate便于接住 execution 供前端 executionOwnerId 字段使用
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId); ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
// 可见性卡断:执行 owner / 项目经理直接放行;否则 taskId 必须在 scope.taskIds 中。
// 未命中按"任务不存在"语义返回,不暴露存在性。
Long userId = SecurityFrameworkUtils.getLoginUserId();
if (!Objects.equals(execution.getOwnerId(), userId)) {
VisibilityScope scope = visibilityScopeResolver.resolveForExecution(projectId, executionId, userId);
if (!scope.seesAll() && !scope.taskIds().contains(taskId)) {
throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_EXISTS);
}
}
ProjectTaskRespVO respVO = BeanUtils.toBean(task, ProjectTaskRespVO.class); ProjectTaskRespVO respVO = BeanUtils.toBean(task, ProjectTaskRespVO.class);
applyLifecycle(respVO); applyLifecycle(respVO);
respVO.setOwnerNickname(loadOwnerNickname(task.getOwnerId())); respVO.setOwnerNickname(loadOwnerNickname(task.getOwnerId()));
@@ -462,6 +455,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
.loadActiveAssigneesGroupedByTaskId(List.of(task.getId())).getOrDefault(task.getId(), List.of()))); .loadActiveAssigneesGroupedByTaskId(List.of(task.getId())).getOrDefault(task.getId(), List.of())));
respVO.setTotalSpentHours(taskWorklogService.sumDurationByTaskId(task.getId())); respVO.setTotalSpentHours(taskWorklogService.sumDurationByTaskId(task.getId()));
respVO.setExecutionOwnerId(execution.getOwnerId()); respVO.setExecutionOwnerId(execution.getOwnerId());
respVO.setExecutionName(execution.getExecutionName());
respVO.setExecutionStatusCode(execution.getStatusCode());
if (task.getParentTaskId() != null) { if (task.getParentTaskId() != null) {
ProjectTaskDO parentTask = projectTaskMapper.selectById(task.getParentTaskId()); ProjectTaskDO parentTask = projectTaskMapper.selectById(task.getParentTaskId());
respVO.setParentTaskOwnerId(parentTask == null ? null : parentTask.getOwnerId()); respVO.setParentTaskOwnerId(parentTask == null ? null : parentTask.getOwnerId());
@@ -471,6 +466,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
} }
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public PageResult<ProjectTaskRespVO> getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) { public PageResult<ProjectTaskRespVO> getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) {
PageResult<ProjectTaskDO> pageResult = getTaskPage(projectId, executionId, reqVO); PageResult<ProjectTaskDO> pageResult = getTaskPage(projectId, executionId, reqVO);
return assembleTaskRespVOPage(projectId, executionId, pageResult); return assembleTaskRespVOPage(projectId, executionId, pageResult);
@@ -489,40 +486,15 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
if (list == null || list.isEmpty()) { if (list == null || list.isEmpty()) {
return voPageResult; return voPageResult;
} }
// 批量装配 ownerNickname + assignees统一收集所有需要的 userId 一次性查 nickname避免 N+1 List<ProjectTaskDO> taskList = pageResult.getList();
Set<Long> taskIds = list.stream().map(ProjectTaskRespVO::getId)
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, List<TaskAssigneeDO>> assigneeMap = taskAssigneeService
.loadActiveAssigneesGroupedByTaskId(taskIds);
Map<Long, BigDecimal> spentHoursMap = taskWorklogService.sumDurationGroupedByTaskIds(taskIds);
Set<Long> userIdsToResolve = list.stream()
.map(ProjectTaskRespVO::getOwnerId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
assigneeMap.values().forEach(items -> items.forEach(a -> userIdsToResolve.add(a.getUserId())));
Map<Long, String> nicknameMap = loadOwnerNicknameMap(userIdsToResolve);
// 批量查父任务 owner避免按 list 循环 N+1
Set<Long> parentTaskIds = list.stream().map(ProjectTaskRespVO::getParentTaskId)
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, Long> parentTaskOwnerMap = parentTaskIds.isEmpty()
? Map.of()
: projectTaskMapper.selectBatchIds(parentTaskIds).stream()
.collect(Collectors.toMap(ProjectTaskDO::getId, ProjectTaskDO::getOwnerId));
// 执行 owner 单条查询整页共享URL 路径定 executionId // 执行 owner 单条查询整页共享URL 路径定 executionId
ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId); ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
Long executionOwnerId = execution == null ? null : execution.getOwnerId(); Long executionOwnerId = execution == null ? null : execution.getOwnerId();
fillProjectRequirementInfo(list, execution); fillProjectRequirementInfo(list, execution);
// 批量回填 ownerNickname / assignees / totalSpentHours / parentTaskOwnerId与 executionId 无关)
enrichTaskRelations(taskList, list);
list.forEach(vo -> { list.forEach(vo -> {
vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId()));
List<TaskAssigneeDO> activeList = assigneeMap.getOrDefault(vo.getId(), List.of());
vo.setAssignees(activeList.stream()
.map(a -> toAssigneeView(a, nicknameMap.get(a.getUserId())))
.collect(Collectors.toList()));
vo.setTotalSpentHours(spentHoursMap.getOrDefault(vo.getId(), BigDecimal.ZERO));
vo.setExecutionOwnerId(executionOwnerId); vo.setExecutionOwnerId(executionOwnerId);
if (vo.getParentTaskId() != null) {
vo.setParentTaskOwnerId(parentTaskOwnerMap.get(vo.getParentTaskId()));
}
// 列表行 cancel/pause/resume/complete 按钮依赖 availableActions与详情同款装配 lifecycle。 // 列表行 cancel/pause/resume/complete 按钮依赖 availableActions与详情同款装配 lifecycle。
// 单行装配失败做兜底降级status_model 缺失等脏数据),避免影响整页返回。 // 单行装配失败做兜底降级status_model 缺失等脏数据),避免影响整页返回。
try { try {
@@ -532,9 +504,112 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
vo.getId(), vo.getStatusCode(), e.getMessage()); vo.getId(), vo.getStatusCode(), e.getMessage());
} }
}); });
enrichExecutionInfo(voPageResult.getList());
return voPageResult; return voPageResult;
} }
/**
* 批量回填与 executionId 无关的任务关联字段:ownerNickname / assignees / totalSpentHours / parentTaskOwnerId。
* <p>由 {@link #assembleTaskRespVOPage} 与 {@link #assembleTaskRespVOPageCrossExecution} 共享,
* 抽取后两条装配路径保证字段口径一致,避免演进漂移。
*
* @param taskList 原始 DO 列表(用于 enrichTaskRelations 内部如有需要直接访问 DO 字段)
* @param voList 已 BeanUtils.toBean 转换好的 VO 列表,本方法在其上 set 字段
*/
private void enrichTaskRelations(List<ProjectTaskDO> taskList, List<ProjectTaskRespVO> voList) {
if (CollUtil.isEmpty(voList)) {
return;
}
// 批量装配 ownerNickname + assignees统一收集所有需要的 userId 一次性查 nickname避免 N+1
Set<Long> taskIds = voList.stream().map(ProjectTaskRespVO::getId)
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, List<TaskAssigneeDO>> assigneeMap = taskAssigneeService
.loadActiveAssigneesGroupedByTaskId(taskIds);
Map<Long, BigDecimal> spentHoursMap = taskWorklogService.sumDurationGroupedByTaskIds(taskIds);
Set<Long> userIdsToResolve = voList.stream()
.map(ProjectTaskRespVO::getOwnerId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
assigneeMap.values().forEach(items -> items.forEach(a -> userIdsToResolve.add(a.getUserId())));
Map<Long, String> nicknameMap = loadOwnerNicknameMap(userIdsToResolve);
// 批量查父任务 owner避免按 list 循环 N+1
Set<Long> parentTaskIds = voList.stream().map(ProjectTaskRespVO::getParentTaskId)
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, Long> parentTaskOwnerMap;
if (parentTaskIds.isEmpty()) {
parentTaskOwnerMap = Map.of();
} else {
parentTaskOwnerMap = new HashMap<>();
for (ProjectTaskDO p : projectTaskMapper.selectBatchIds(parentTaskIds)) {
parentTaskOwnerMap.put(p.getId(), p.getOwnerId());
}
}
voList.forEach(vo -> {
vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId()));
List<TaskAssigneeDO> activeList = assigneeMap.getOrDefault(vo.getId(), List.of());
vo.setAssignees(activeList.stream()
.map(a -> toAssigneeView(a, nicknameMap.get(a.getUserId())))
.collect(Collectors.toList()));
vo.setTotalSpentHours(spentHoursMap.getOrDefault(vo.getId(), BigDecimal.ZERO));
if (vo.getParentTaskId() != null) {
vo.setParentTaskOwnerId(parentTaskOwnerMap.get(vo.getParentTaskId()));
}
});
}
@Override
public PageResult<ProjectTaskRespVO> assembleTaskRespVOPageCrossExecution(Long projectId, PageResult<ProjectTaskDO> doPage) {
if (doPage == null || CollUtil.isEmpty(doPage.getList())) {
return new PageResult<>(Collections.emptyList(), doPage == null ? 0L : doPage.getTotal());
}
List<ProjectTaskDO> taskList = doPage.getList();
List<ProjectTaskRespVO> voList = BeanUtils.toBean(taskList, ProjectTaskRespVO.class);
// 批量回填 ownerNickname / assignees / totalSpentHours / parentTaskOwnerId与 executionId 无关)
enrichTaskRelations(taskList, voList);
voList.forEach(vo -> {
// executionOwnerId 在跨执行场景不设置(涉及多个 execution无"该执行的 ownerId"概念)
try {
applyLifecycle(vo);
} catch (Exception e) {
log.warn("[assembleTaskRespVOPageCrossExecution] applyLifecycle 装配失败 taskId={}", vo.getId(), e);
}
});
// 按 task.executionId 分组批量回填 executionName / executionStatusCode
enrichExecutionInfo(voList);
return new PageResult<>(voList, doPage.getTotal());
}
/**
* 给一批 ProjectTaskRespVO 批量回填 executionName / executionStatusCode。
* 一次查询所有涉及的 executionId,避免 N+1。
*/
private void enrichExecutionInfo(List<ProjectTaskRespVO> list) {
if (CollUtil.isEmpty(list)) {
return;
}
Set<Long> executionIds = list.stream()
.map(ProjectTaskRespVO::getExecutionId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (executionIds.isEmpty()) {
return;
}
List<ProjectExecutionDO> executions = projectExecutionMapper.selectBatchIds(executionIds);
Map<Long, ProjectExecutionDO> executionMap = executions.stream()
.collect(Collectors.toMap(ProjectExecutionDO::getId, e -> e, (a, b) -> a));
list.forEach(vo -> {
ProjectExecutionDO exec = executionMap.get(vo.getExecutionId());
if (exec != null) {
vo.setExecutionName(exec.getExecutionName());
vo.setExecutionStatusCode(exec.getStatusCode());
}
});
}
/** /**
* 把任务 RespVO 上的项目需求信息TD-013回填。 * 把任务 RespVO 上的项目需求信息TD-013回填。
* <p>本服务的 task 查询入口均以 executionId 为收口(详情 / page / board-page 共用 assembleTaskRespVOPage * <p>本服务的 task 查询入口均以 executionId 为收口(详情 / page / board-page 共用 assembleTaskRespVOPage
@@ -601,7 +676,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
if (!Objects.equals(loginUserId, task.getOwnerId())) { if (!Objects.equals(loginUserId, task.getOwnerId())) {
throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_OWNER_ONLY, throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_OWNER_ONLY,
resolveActionDisplayName(actionCode)); statusActionTextResolver.actionName(ProjectTaskConstants.OBJECT_TYPE, actionCode));
} }
} }
@@ -612,23 +687,13 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|| "resume".equals(actionCode); || "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 * - 首次离开初始态fromStatus.initialFlag=true且未填写时写入 actualStartDate
* - 进入终态toStatus.terminalFlag=true且未填写时写入 actualEndDate * - 进入终态toStatus.terminalFlag=true且未填写时写入 actualEndDate
*/ */
private void maybeFillActualDates(ProjectTaskDO task, String fromStatus, String toStatus) { private void maybeFillActualDates(ProjectTaskDO task, String fromStatus, String toStatus) {
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now(SERVER_ZONE);
LocalDate newActualStart = null; LocalDate newActualStart = null;
LocalDate newActualEnd = null; LocalDate newActualEnd = null;
if (task.getActualStartDate() == null) { if (task.getActualStartDate() == null) {
@@ -756,10 +821,12 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
ObjectStatusTransitionDO transition = objectStatusTransitionMapper ObjectStatusTransitionDO transition = objectStatusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(ProjectTaskConstants.OBJECT_TYPE, fromStatusCode, actionCode); .selectByObjectTypeAndFromStatusAndAction(ProjectTaskConstants.OBJECT_TYPE, fromStatusCode, actionCode);
if (transition == null) { 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)) { 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; return transition;
} }
@@ -810,12 +877,17 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
writeTaskAuditLog(task, actionCode, fromStatus, toStatus, null, reason); writeTaskAuditLog(task, actionCode, fromStatus, toStatus, null, reason);
maybeFillActualDates(task, fromStatus, toStatus); maybeFillActualDates(task, fromStatus, toStatus);
// 完成动作:兜底把任务进度刷到 100%,并触发父任务 AVG 重算 // 完成动作:兜底把任务进度刷到 100%,并触发父任务 AVG 重算forceCompleteProgress 内部触发项目刷新)
if ("complete".equals(actionCode)) { if ("complete".equals(actionCode)) {
forceCompleteProgress(task); forceCompleteProgress(task);
} else if (task.getParentTaskId() != null) { } else {
if (task.getParentTaskId() != null) {
recalcParentProgressFrom(task.getParentTaskId()); recalcParentProgressFrom(task.getParentTaskId());
} }
// 项目进度推算非完成状态变更cancel/pause/resume。cancel 进 progress_excluded 影响项目均值;
// pause/resume 不影响 progress_excluded但根任务的 pause/resume 不改 progressRate 时 recalc 也是幂等的
projectService.recalcProgress(task.getProjectId());
}
// 取消 / 暂停 / 恢复:触发子任务级联(每个子任务的内部链路自身会再级联自己的子,链式实现整棵子树) // 取消 / 暂停 / 恢复:触发子任务级联(每个子任务的内部链路自身会再级联自己的子,链式实现整棵子树)
cascadeIfNeeded(task, actionCode, reason); cascadeIfNeeded(task, actionCode, reason);
@@ -834,6 +906,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
private void forceCompleteProgress(ProjectTaskDO task) { private void forceCompleteProgress(ProjectTaskDO task) {
BigDecimal full = BigDecimal.valueOf(100); BigDecimal full = BigDecimal.valueOf(100);
if (progressNumericallyEquals(task.getProgressRate(), full)) { if (progressNumericallyEquals(task.getProgressRate(), full)) {
// 进度已经是 100completed 不在 progress_excluded 集合内,对项目均值无影响,跳过刷新
return; return;
} }
projectTaskMapper.updateProgressRateById(task.getId(), full); projectTaskMapper.updateProgressRateById(task.getId(), full);
@@ -841,6 +914,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
if (task.getParentTaskId() != null) { if (task.getParentTaskId() != null) {
recalcParentProgressFrom(task.getParentTaskId()); recalcParentProgressFrom(task.getParentTaskId());
} }
// 项目进度推算:根任务 complete 时父链不冒泡,但任务自身 progressRate=100 已变化,必须触发刷新
projectService.recalcProgress(task.getProjectId());
} }
/** /**

View File

@@ -69,6 +69,8 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
private AdminUserApi adminUserApi; private AdminUserApi adminUserApi;
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public List<TaskAssigneeRespVO> getAssigneeList(Long projectId, Long executionId, Long taskId) { public List<TaskAssigneeRespVO> getAssigneeList(Long projectId, Long executionId, Long taskId) {
validateExecutionAndTaskExists(projectId, executionId, taskId); validateExecutionAndTaskExists(projectId, executionId, taskId);
List<TaskAssigneeDO> activeList = taskAssigneeMapper.selectActiveListByTaskId(taskId); List<TaskAssigneeDO> activeList = taskAssigneeMapper.selectActiveListByTaskId(taskId);
@@ -121,6 +123,8 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
} }
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public PageResult<TaskAssigneeLogRespVO> getAssigneeLogPage(Long projectId, Long executionId, Long taskId, public PageResult<TaskAssigneeLogRespVO> getAssigneeLogPage(Long projectId, Long executionId, Long taskId,
TaskAssigneeLogPageReqVO reqVO) { TaskAssigneeLogPageReqVO reqVO) {
validateExecutionAndTaskExists(projectId, executionId, taskId); validateExecutionAndTaskExists(projectId, executionId, taskId);

View File

@@ -79,6 +79,8 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
private ProjectTaskService projectTaskService; private ProjectTaskService projectTaskService;
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY)
public PageResult<TaskWorklogRespVO> getWorklogPage(Long projectId, Long executionId, Long taskId, public PageResult<TaskWorklogRespVO> getWorklogPage(Long projectId, Long executionId, Long taskId,
TaskWorklogPageReqVO reqVO) { TaskWorklogPageReqVO reqVO) {
validateExecutionAndTaskExists(projectId, executionId, taskId); validateExecutionAndTaskExists(projectId, executionId, taskId);

View File

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

View File

@@ -0,0 +1,38 @@
package com.njcn.rdms.module.project.util;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.TemporalAdjusters;
/**
* dueRange 截止时间范围筛选的日期边界 helper。
*
* <p>口径统一:服务器时区 {@code Asia/Shanghai},本周按周一~周日。
* 供执行分页查询与执行状态看板计数共用,避免在多个 service 里重复同一段日期计算。</p>
*
* <p>终态排除不在此处:终态码由各对象域自行通过
* {@code ObjectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(objectType)} 动态查。</p>
*/
public final class DueRangeSupport {
private static final ZoneId SERVER_ZONE = ZoneId.of("Asia/Shanghai");
private DueRangeSupport() {
}
/** 服务器当天。 */
public static LocalDate today() {
return LocalDate.now(SERVER_ZONE);
}
/** 本周一(含当天)。 */
public static LocalDate weekStart(LocalDate today) {
return today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
}
/** 本周日(含当天)。 */
public static LocalDate weekEnd(LocalDate today) {
return today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY));
}
}

View File

@@ -56,7 +56,7 @@ spring:
primary: master primary: master
datasource: datasource:
master: master:
url: jdbc:mysql://192.168.1.22:13306/rdms_v3?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例 url: jdbc:mysql://192.168.1.22:13306/rdms_view?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
username: root username: root
password: njcnpqs password: njcnpqs

View File

@@ -55,7 +55,7 @@ spring:
primary: master primary: master
datasource: datasource:
master: master:
url: jdbc:mysql://192.168.1.22:13306/rdms_v3?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例 url: jdbc:mysql://192.168.1.22:13306/rdms_view?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
username: root username: root
password: njcnpqs password: njcnpqs

View File

@@ -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
* <p>
* 验证执行协办人、任务协办人、任务工时这 5 个读接口都已挂
* {@link com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission},且:
* <ul>
* <li>{@code objectId="#projectId"} 解析为 projectId而非 executionId/taskId</li>
* <li>permission 为同层 QUERY 码(执行域 project:execution:query、任务域 project:task:query</li>
* <li>无权checkPermission 抛异常)时方法体被拦、不执行。</li>
* </ul>
* 沿用 {@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));
}
}

View File

@@ -8,6 +8,10 @@ import org.mockito.Mock;
import org.springframework.aop.aspectj.annotation.AspectJProxyFactory; import org.springframework.aop.aspectj.annotation.AspectJProxyFactory;
import static org.junit.jupiter.api.Assertions.assertEquals; 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.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -43,6 +47,19 @@ class ObjectPermissionAspectTest extends BaseMockitoUnitTest {
.checkPermission(1002L, "", true); .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() { private DemoService createProxy() {
AspectJProxyFactory proxyFactory = new AspectJProxyFactory(new DemoService()); AspectJProxyFactory proxyFactory = new AspectJProxyFactory(new DemoService());
proxyFactory.addAspect(new ObjectPermissionAspect(java.util.List.of(objectPermissionService))); proxyFactory.addAspect(new ObjectPermissionAspect(java.util.List.of(objectPermissionService)));
@@ -61,6 +78,11 @@ class ObjectPermissionAspectTest extends BaseMockitoUnitTest {
return "context"; return "context";
} }
@CheckObjectPermission(objectType = "product", objectId = "#productId", accessible = true)
public String getProductAccessible(Long productId) {
return "accessible";
}
} }
static class DemoReqVO { static class DemoReqVO {

View File

@@ -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.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; 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.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.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.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.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.enums.permission.PermissionScopeTypeEnum; import com.njcn.rdms.module.system.enums.permission.PermissionScopeTypeEnum;
import org.junit.jupiter.api.Test; 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 com.njcn.rdms.framework.common.pojo.CommonResult.success;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals; 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.assertThrows;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoInteractions;
@@ -33,6 +38,10 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
private UserObjectRoleMapper userObjectRoleMapper; private UserObjectRoleMapper userObjectRoleMapper;
@Mock @Mock
private ObjectPermissionApi objectPermissionApi; private ObjectPermissionApi objectPermissionApi;
@Mock
private ProductMapper productMapper;
@Mock
private ObjectDataScopeService objectDataScopeService;
@Test @Test
void checkPermission_whenMemberOnlyAndCurrentUserIsMember_shouldPass() { void checkPermission_whenMemberOnlyAndCurrentUserIsMember_shouldPass() {
@@ -77,6 +86,8 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
ServiceException ex = assertThrows(ServiceException.class, ServiceException ex = assertThrows(ServiceException.class,
() -> permissionService.checkPermission(productId, "project:product:delete", false)); () -> permissionService.checkPermission(productId, "project:product:delete", false));
assertEquals(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode()); 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, ServiceException ex = assertThrows(ServiceException.class,
() -> permissionService.checkPermission(productId, "project:product:query", false)); () -> permissionService.checkPermission(productId, "project:product:query", false));
assertEquals(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode()); assertEquals(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode());
assertFalse(ex.getMessage().contains(":"), "message 不应外泄权限码");
} }
verifyNoInteractions(objectPermissionApi); verifyNoInteractions(objectPermissionApi);
} }
@Test
void checkPermission_whenMemberOnlyAndNotMember_shouldThrowWithoutLeak() {
Long productId = 1005L;
Long loginUserId = 2005L;
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
.thenReturn(Collections.emptyList());
try (MockedStatic<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> 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) { private UserObjectRoleDO createMember(Long productId, Long loginUserId, Long roleId) {
UserObjectRoleDO member = new UserObjectRoleDO(); UserObjectRoleDO member = new UserObjectRoleDO();
member.setId(9001L); member.setId(9001L);

View File

@@ -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.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; 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.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.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.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.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.enums.permission.PermissionScopeTypeEnum; import com.njcn.rdms.module.system.enums.permission.PermissionScopeTypeEnum;
import org.junit.jupiter.api.Test; 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 com.njcn.rdms.framework.common.pojo.CommonResult.success;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals; 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.assertThrows;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoInteractions;
@@ -33,6 +38,10 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
private UserObjectRoleMapper userObjectRoleMapper; private UserObjectRoleMapper userObjectRoleMapper;
@Mock @Mock
private ObjectPermissionApi objectPermissionApi; private ObjectPermissionApi objectPermissionApi;
@Mock
private ProjectMapper projectMapper;
@Mock
private ObjectDataScopeService objectDataScopeService;
@Test @Test
void checkPermission_whenMemberOnlyAndActiveMember_shouldPass() { void checkPermission_whenMemberOnlyAndActiveMember_shouldPass() {
@@ -59,6 +68,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
ServiceException ex = assertThrows(ServiceException.class, ServiceException ex = assertThrows(ServiceException.class,
() -> permissionService.checkPermission(projectId, "project:project:update", false)); () -> permissionService.checkPermission(projectId, "project:project:update", false));
assertEquals(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode()); 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, ServiceException ex = assertThrows(ServiceException.class,
() -> permissionService.checkPermission(projectId, "project:project:delete", false)); () -> permissionService.checkPermission(projectId, "project:project:delete", false));
assertEquals(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode()); 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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> 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) { private UserObjectRoleDO createMember(Long projectId, Long loginUserId, Long roleId) {
UserObjectRoleDO member = new UserObjectRoleDO(); UserObjectRoleDO member = new UserObjectRoleDO();
member.setId(9001L); member.setId(9001L);

View File

@@ -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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> 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<SecurityFrameworkUtils> mockLoginUser(Long loginUserId) {
MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class);
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
return mocked;
}
}

View File

@@ -51,6 +51,8 @@ class ProductRequirementServiceImplTest extends BaseMockitoUnitTest {
private ObjectStatusTransitionMapper statusTransitionMapper; private ObjectStatusTransitionMapper statusTransitionMapper;
@Mock @Mock
private ObjectStatusModelMapper statusModelMapper; private ObjectStatusModelMapper statusModelMapper;
@Mock
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
// ========== 创建需求测试 ========== // ========== 创建需求测试 ==========

View File

@@ -84,6 +84,8 @@ class ProductServiceImplTest extends BaseMockitoUnitTest {
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService; private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
@Mock @Mock
private ObjectDataScopeService objectDataScopeService; private ObjectDataScopeService objectDataScopeService;
@Mock
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
@Test @Test
void createProduct_shouldCreateDefaultRequirementModule() { void createProduct_shouldCreateDefaultRequirementModule() {

View File

@@ -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<SecurityFrameworkUtils> mock = mockStatic(SecurityFrameworkUtils.class)) {
mock.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(100L);
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId(
eq(ProjectObjectConstants.OBJECT_TYPE), eq(100L))).thenReturn(emptyList());
PageResult<MyProjectParticipatedRespVO> result = myProjectService.getMyParticipatedPage(allPageReq());
assertEquals(0L, result.getTotal());
assertEquals(0, result.getList().size());
}
}
@Test
void testParticipated_assemblesRoleProgressAndTaskCount() {
try (MockedStatic<SecurityFrameworkUtils> 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.<Map<String, Object>>of(Map.of(
"projectId", 2001L, "totalCount", 8L, "pendingCount", 3L)));
PageResult<MyProjectParticipatedRespVO> 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<SecurityFrameworkUtils> mock = mockStatic(SecurityFrameworkUtils.class)) {
mock.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(100L);
// 我在项目 2001 只有一个隐式角色 roleId=90visible=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<MyProjectParticipatedRespVO> result = myProjectService.getMyParticipatedPage(allPageReq());
// 仅隐式角色 → 该项目整项剔除
assertEquals(0L, result.getTotal());
assertEquals(0, result.getList().size());
}
}
@Test
void testOwned_emptyWhenNoManagedProjects() {
try (MockedStatic<SecurityFrameworkUtils> 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<com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectOwnedRespVO>
result = myProjectService.getMyOwnedPage(allPageReq());
assertEquals(0L, result.getTotal());
assertEquals(0, result.getList().size());
}
}
@Test
void testOwned_assemblesCountsAndMembers() {
try (MockedStatic<SecurityFrameworkUtils> 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.<Map<String, Object>>of(Map.of(
"projectId", 2001L, "taskCount", 24L, "overdueCount", 2L)));
when(projectExecutionMapper.selectExecutionCountGroupByProjectIds(anyCollection(), anyCollection()))
.thenReturn(List.<Map<String, Object>>of(Map.of("projectId", 2001L, "executionCount", 6L)));
when(projectTaskMapper.selectActiveTaskCountGroupByProjectIdAndOwner(anyCollection(), anyCollection()))
.thenReturn(List.<Map<String, Object>>of(
Map.of("projectId", 2001L, "ownerId", 101L, "activeTaskCount", 6L),
Map.of("projectId", 2001L, "ownerId", 102L, "activeTaskCount", 3L)));
// 成员清单101 / 102102 出现两行多角色,去重后仍一个成员)
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<com.njcn.rdms.module.project.controller.admin.project.project.vo.myproject.MyProjectOwnedRespVO>
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
}
}
}

View File

@@ -58,6 +58,8 @@ class ProjectRequirementServiceImplTest extends BaseMockitoUnitTest {
private AttachmentFileIdResolver attachmentFileIdResolver; private AttachmentFileIdResolver attachmentFileIdResolver;
@Mock @Mock
private ProjectExecutionMapper projectExecutionMapper; private ProjectExecutionMapper projectExecutionMapper;
@Mock
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
@Test @Test
void validateUsableForExecution_whenRequirementIdIsNull_shouldDoNothing() { void validateUsableForExecution_whenRequirementIdIsNull_shouldDoNothing() {

View File

@@ -89,6 +89,8 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
private DictDataApi dictDataApi; private DictDataApi dictDataApi;
@Mock @Mock
private ObjectDataScopeService objectDataScopeService; private ObjectDataScopeService objectDataScopeService;
@Mock
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
@Test @Test
void getProjectDetail_shouldFillProductNameAndManagerNickname() { void getProjectDetail_shouldFillProductNameAndManagerNickname() {

View File

@@ -9,9 +9,6 @@ import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper; 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.project.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper; import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
@@ -24,7 +21,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest { class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
@@ -37,17 +33,6 @@ class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
private ProjectExecutionMapper projectExecutionMapper; private ProjectExecutionMapper projectExecutionMapper;
@Mock @Mock
private ProjectTaskMapper projectTaskMapper; private ProjectTaskMapper projectTaskMapper;
@Mock
private VisibilityScopeResolver visibilityScopeResolver;
/**
* 默认让 VisibilityScopeResolver 放行seesAll=true既有看板用例不关心 scope。
*/
@BeforeEach
void setupVisibilityScopeAll() {
lenient().when(visibilityScopeResolver.resolveForProject(any(), any())).thenReturn(VisibilityScope.all());
lenient().when(visibilityScopeResolver.resolveForExecution(any(), any(), any())).thenReturn(VisibilityScope.all());
}
@Test @Test
void getExecutionStatusBoard_shouldReturnEnabledStatusesInSortOrderAndSumCounts() { void getExecutionStatusBoard_shouldReturnEnabledStatusesInSortOrderAndSumCounts() {
@@ -59,16 +44,21 @@ class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
createStatus("cancelled", "已取消", 50, true), createStatus("cancelled", "已取消", 50, true),
createStatus("disabled", "已停用", 60, false, 1) createStatus("disabled", "已停用", 60, false, 1)
)); ));
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class), when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L),
any(ProjectExecutionStatusBoardReqVO.class), eq("pending"))).thenReturn(3); any(ProjectExecutionStatusBoardReqVO.class), eq("pending"),
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class), any(), any(), any(), any())).thenReturn(3);
any(ProjectExecutionStatusBoardReqVO.class), eq("active"))).thenReturn(8); when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L),
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class), any(ProjectExecutionStatusBoardReqVO.class), eq("active"),
any(ProjectExecutionStatusBoardReqVO.class), eq("paused"))).thenReturn(2); any(), any(), any(), any())).thenReturn(8);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class), when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L),
any(ProjectExecutionStatusBoardReqVO.class), eq("completed"))).thenReturn(4); any(ProjectExecutionStatusBoardReqVO.class), eq("paused"),
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class), any(), any(), any(), any())).thenReturn(2);
any(ProjectExecutionStatusBoardReqVO.class), eq("cancelled"))).thenReturn(1); when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L),
any(ProjectExecutionStatusBoardReqVO.class), eq("completed"),
any(), any(), any(), any())).thenReturn(4);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L),
any(ProjectExecutionStatusBoardReqVO.class), eq("cancelled"),
any(), any(), any(), any())).thenReturn(1);
ProjectExecutionStatusBoardReqVO reqVO = new ProjectExecutionStatusBoardReqVO(); ProjectExecutionStatusBoardReqVO reqVO = new ProjectExecutionStatusBoardReqVO();
reqVO.setKeyword("接口"); reqVO.setKeyword("接口");
@@ -100,15 +90,15 @@ class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
createStatus("cancelled", "已取消", 50, true) createStatus("cancelled", "已取消", 50, true)
)); ));
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L), when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("pending"))).thenReturn(5); any(ProjectTaskStatusBoardReqVO.class), eq("pending"))).thenReturn(5);
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L), when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("active"))).thenReturn(12); any(ProjectTaskStatusBoardReqVO.class), eq("active"))).thenReturn(12);
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L), when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("paused"))).thenReturn(2); any(ProjectTaskStatusBoardReqVO.class), eq("paused"))).thenReturn(2);
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L), when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("completed"))).thenReturn(4); any(ProjectTaskStatusBoardReqVO.class), eq("completed"))).thenReturn(4);
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L), when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("cancelled"))).thenReturn(1); any(ProjectTaskStatusBoardReqVO.class), eq("cancelled"))).thenReturn(1);
ProjectTaskStatusBoardReqVO reqVO = new ProjectTaskStatusBoardReqVO(); ProjectTaskStatusBoardReqVO reqVO = new ProjectTaskStatusBoardReqVO();
reqVO.setKeyword("任务"); reqVO.setKeyword("任务");

View File

@@ -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.common.pojo.PageResult;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest; 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.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.ProjectExecutionPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO;
@@ -98,23 +100,18 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
@Mock @Mock
private ProjectExecutionAssigneeService projectExecutionAssigneeService; private ProjectExecutionAssigneeService projectExecutionAssigneeService;
@Mock @Mock
private com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver visibilityScopeResolver;
@Mock
private ProjectRequirementService projectRequirementService; private ProjectRequirementService projectRequirementService;
@Mock @Mock
private ProjectRequirementMapper projectRequirementMapper; private ProjectRequirementMapper projectRequirementMapper;
@Mock
private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
/** /**
* 默认让 VisibilityScopeResolver 放行seesAll=true既有测试无需关心 scope。
* 默认让 dictDataApi.validateDictDataList 对 REQ_PRIORITY 返回 true既有测试不因 priority 校验失败。 * 默认让 dictDataApi.validateDictDataList 对 REQ_PRIORITY 返回 true既有测试不因 priority 校验失败。
* 真正需要测试 scope 行为的用例可在方法内显式覆盖 * 读路径鉴权由 @CheckObjectPermission 的 AOP 处理,单测 @InjectMocks 不走 AOP无须在此 mock
*/ */
@BeforeEach @BeforeEach
void setupVisibilityScopeAll() { void setupDefaultPriorityValidation() {
lenient().when(visibilityScopeResolver.resolveForProject(any(), any()))
.thenReturn(com.njcn.rdms.module.project.service.project.permission.VisibilityScope.all());
lenient().when(visibilityScopeResolver.resolveForExecution(any(), any(), any()))
.thenReturn(com.njcn.rdms.module.project.service.project.permission.VisibilityScope.all());
lenient().when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any())) lenient().when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any()))
.thenReturn(success(true)); .thenReturn(success(true));
} }
@@ -428,11 +425,16 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution); when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "active", "complete")) when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "active", "complete"))
.thenReturn(null); .thenReturn(null);
when(statusActionTextResolver.statusName("execution", "active")).thenReturn("进行中");
when(statusActionTextResolver.actionName("execution", "complete")).thenReturn("完成");
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3001L)) { try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3001L)) {
ServiceException ex = assertThrows(ServiceException.class, ServiceException ex = assertThrows(ServiceException.class,
() -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO)); () -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED.getCode(), ex.getCode()); 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"));
} }
} }
@@ -557,7 +559,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
second.setProgressRate(new BigDecimal("100.00")); second.setProgressRate(new BigDecimal("100.00"));
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO))) when(projectExecutionMapper.selectPageByProjectId(eq(projectId), eq(reqVO), any(), any(), any(), any()))
.thenReturn(new PageResult<>(List.of(first, second), 2L)); .thenReturn(new PageResult<>(List.of(first, second), 2L));
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task")) when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled")); .thenReturn(List.of("cancelled"));
@@ -593,7 +595,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
rows.add(Map.of("executionId", 5002L, "progressRate", 10)); rows.add(Map.of("executionId", 5002L, "progressRate", 10));
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO))) when(projectExecutionMapper.selectPageByProjectId(eq(projectId), eq(reqVO), any(), any(), any(), any()))
.thenReturn(new PageResult<>(List.of(first, second), 2L)); .thenReturn(new PageResult<>(List.of(first, second), 2L));
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task")) when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
.thenReturn(List.of("cancelled")); .thenReturn(List.of("cancelled"));
@@ -647,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=1005004 缺失 → 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<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
PageResult<MyProjectExecutionRespVO> 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<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
PageResult<MyProjectExecutionRespVO> result = projectExecutionService.getMyExecutionPage(reqVO);
assertEquals(0L, result.getTotal());
assertEquals(0, result.getList().size());
}
}
@Test @Test
void getExecutionPage_shouldDelegateMapper() { void getExecutionPage_shouldDelegateMapper() {
Long projectId = 2001L; Long projectId = 2001L;
@@ -654,7 +738,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
reqVO.setPageNo(1); reqVO.setPageNo(1);
reqVO.setPageSize(20); reqVO.setPageSize(20);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO))) when(projectExecutionMapper.selectPageByProjectId(eq(projectId), eq(reqVO), any(), any(), any(), any()))
.thenReturn(new PageResult<>(List.of(), 0L)); .thenReturn(new PageResult<>(List.of(), 0L));
PageResult<ProjectExecutionDO> result = projectExecutionService.getExecutionPage(projectId, reqVO); PageResult<ProjectExecutionDO> result = projectExecutionService.getExecutionPage(projectId, reqVO);

View File

@@ -1,126 +0,0 @@
package com.njcn.rdms.module.project.service.project.permission;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper;
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.project.task.TaskAssigneeMapper;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
/**
* VisibilityScopeResolverImpl 单元测试。覆盖角色矩阵:
* - 项目经理 → seesAll
* - 非项目经理 → 4 源并集
* - 非项目经理且无任何参与 → 空集合
* - 执行维度同上
*/
class VisibilityScopeResolverImplTest extends BaseMockitoUnitTest {
@InjectMocks
private VisibilityScopeResolverImpl resolver;
@Mock private ProjectMapper projectMapper;
@Mock private ProjectExecutionMapper projectExecutionMapper;
@Mock private ExecutionAssigneeMapper executionAssigneeMapper;
@Mock private ProjectTaskMapper projectTaskMapper;
@Mock private TaskAssigneeMapper taskAssigneeMapper;
@Test
void resolveForProject_managerShouldSeeAll() {
Long projectId = 2001L, userId = 3001L;
ProjectDO project = new ProjectDO();
project.setId(projectId);
project.setManagerUserId(userId);
when(projectMapper.selectById(projectId)).thenReturn(project);
VisibilityScope scope = resolver.resolveForProject(projectId, userId);
assertTrue(scope.seesAll());
}
@Test
void resolveForProject_nonManagerUnionsFourSources() {
Long projectId = 2001L, userId = 3002L;
ProjectDO project = new ProjectDO();
project.setId(projectId);
project.setManagerUserId(9999L);
when(projectMapper.selectById(projectId)).thenReturn(project);
when(projectExecutionMapper.selectIdsByProjectIdAndOwnerId(projectId, userId))
.thenReturn(List.of(5001L));
when(executionAssigneeMapper.selectActiveExecutionIdsByProjectIdAndUserId(projectId, userId))
.thenReturn(List.of(5002L));
when(projectTaskMapper.selectOwnedTaskAndDescendantIdsByProjectIdAndUserId(projectId, userId))
.thenReturn(List.of(9001L, 9002L));
when(taskAssigneeMapper.selectActiveTaskIdsByProjectIdAndUserId(projectId, userId))
.thenReturn(List.of(9003L));
VisibilityScope scope = resolver.resolveForProject(projectId, userId);
assertFalse(scope.seesAll());
assertEquals(Set.of(5001L, 5002L), scope.executionIds());
assertEquals(Set.of(9001L, 9002L, 9003L), scope.taskIds());
}
@Test
void resolveForProject_nonParticipantReturnsEmpty() {
Long projectId = 2001L, userId = 3099L;
ProjectDO project = new ProjectDO();
project.setId(projectId);
project.setManagerUserId(9999L);
when(projectMapper.selectById(projectId)).thenReturn(project);
when(projectExecutionMapper.selectIdsByProjectIdAndOwnerId(projectId, userId)).thenReturn(List.of());
when(executionAssigneeMapper.selectActiveExecutionIdsByProjectIdAndUserId(projectId, userId)).thenReturn(List.of());
when(projectTaskMapper.selectOwnedTaskAndDescendantIdsByProjectIdAndUserId(projectId, userId)).thenReturn(List.of());
when(taskAssigneeMapper.selectActiveTaskIdsByProjectIdAndUserId(projectId, userId)).thenReturn(List.of());
VisibilityScope scope = resolver.resolveForProject(projectId, userId);
assertFalse(scope.seesAll());
assertTrue(scope.executionIds().isEmpty());
assertTrue(scope.taskIds().isEmpty());
}
@Test
void resolveForExecution_managerShouldSeeAll() {
Long projectId = 2001L, executionId = 5001L, userId = 3001L;
ProjectDO project = new ProjectDO();
project.setManagerUserId(userId);
when(projectMapper.selectById(projectId)).thenReturn(project);
VisibilityScope scope = resolver.resolveForExecution(projectId, executionId, userId);
assertTrue(scope.seesAll());
}
@Test
void resolveForExecution_nonManagerScopedToThatExecution() {
Long projectId = 2001L, executionId = 5001L, userId = 3002L;
ProjectDO project = new ProjectDO();
project.setManagerUserId(9999L);
when(projectMapper.selectById(projectId)).thenReturn(project);
when(projectTaskMapper.selectOwnedTaskAndDescendantIdsByExecutionIdAndUserId(projectId, executionId, userId))
.thenReturn(List.of(9001L));
when(taskAssigneeMapper.selectActiveTaskIdsByProjectIdAndExecutionIdAndUserId(projectId, executionId, userId))
.thenReturn(List.of(9002L));
VisibilityScope scope = resolver.resolveForExecution(projectId, executionId, userId);
assertFalse(scope.seesAll());
assertTrue(scope.executionIds().isEmpty());
assertEquals(Set.of(9001L, 9002L), scope.taskIds());
}
}

View File

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

View File

@@ -4,6 +4,7 @@ import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.map.MapUtil; import cn.hutool.core.map.MapUtil;
import com.njcn.rdms.framework.common.pojo.CommonResult; import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.util.collection.CollectionUtils; import com.njcn.rdms.framework.common.util.collection.CollectionUtils;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import com.njcn.rdms.module.system.api.user.dto.UserManagementRelationRespDTO; import com.njcn.rdms.module.system.api.user.dto.UserManagementRelationRespDTO;
import com.njcn.rdms.module.system.enums.ApiConstants; import com.njcn.rdms.module.system.enums.ApiConstants;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -33,6 +34,11 @@ public interface UserManagementRelationApi {
@Parameter(name = "subordinateUserId", description = "被管理者用户ID", example = "2", required = true) @Parameter(name = "subordinateUserId", description = "被管理者用户ID", example = "2", required = true)
CommonResult<List<UserManagementRelationRespDTO>> getRelationListBySubordinateUserId(@RequestParam("subordinateUserId") Long subordinateUserId); CommonResult<List<UserManagementRelationRespDTO>> getRelationListBySubordinateUserId(@RequestParam("subordinateUserId") Long subordinateUserId);
@GetMapping(PREFIX + "/direct-manager")
@Operation(summary = "根据用户ID获得当前生效的直属上级")
@Parameter(name = "userId", description = "用户ID", example = "2", required = true)
CommonResult<AdminUserRespDTO> getDirectManager(@RequestParam("userId") Long userId);
@GetMapping(PREFIX + "/list") @GetMapping(PREFIX + "/list")
@Operation(summary = "获得管理链路列表") @Operation(summary = "获得管理链路列表")
@Parameter(name = "ids", description = "关系编号数组", example = "1,2", required = true) @Parameter(name = "ids", description = "关系编号数组", example = "1,2", required = true)

View File

@@ -32,4 +32,14 @@ public interface DictTypeConstants {
*/ */
String RDMS_TASK_ITEM_TYPE="rdms_task_item_type"; String RDMS_TASK_ITEM_TYPE="rdms_task_item_type";
/**
* 加班申请审批状态字典。
*/
String RDMS_OVERTIME_APPLICATION_STATUS = "rdms_overtime_application_status";
/**
* 加班申请时长快捷选项字典。
*/
String RDMS_OVERTIME_DURATION = "rdms_overtime_duration";
} }

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