This commit is contained in:
dk
2026-05-13 20:56:58 +08:00
98 changed files with 4138 additions and 1362 deletions

View File

@@ -25,7 +25,18 @@
"PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot -am test 2>&1 | Select-Object -Last 80)", "PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot -am test 2>&1 | Select-Object -Last 80)",
"PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot \"-Dtest=TaskAssigneeServiceImplTest,ProjectTaskServiceImplTest,ProjectTaskStatusViewServiceTest,ProjectExecutionServiceImplTest\" \"-Dsurefire.failIfNoSpecifiedTests=false\" test 2>&1 | Select-Object -Last 30)", "PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot \"-Dtest=TaskAssigneeServiceImplTest,ProjectTaskServiceImplTest,ProjectTaskStatusViewServiceTest,ProjectExecutionServiceImplTest\" \"-Dsurefire.failIfNoSpecifiedTests=false\" test 2>&1 | Select-Object -Last 30)",
"PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot -am \"-Dtest=TaskAssigneeServiceImplTest,ProjectTaskServiceImplTest,ProjectTaskStatusViewServiceTest,ProjectExecutionServiceImplTest\" \"-Dsurefire.failIfNoSpecifiedTests=false\" test 2>&1 | Select-Object -Last 30)", "PowerShell($env:JAVA_HOME = \"C:\\\\Program Files\\\\Java\\\\jdk-17\"; & \"C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd\" -pl rdms-project/rdms-project-boot -am \"-Dtest=TaskAssigneeServiceImplTest,ProjectTaskServiceImplTest,ProjectTaskStatusViewServiceTest,ProjectExecutionServiceImplTest\" \"-Dsurefire.failIfNoSpecifiedTests=false\" test 2>&1 | Select-Object -Last 30)",
"Bash(grep -rn \"INSERT INTO \\\\`system_menu\\\\`\\\\|INSERT INTO system_menu\" --include=\"*.sql\" rdms-project rdms-system)" "Bash(grep -rn \"INSERT INTO \\\\`system_menu\\\\`\\\\|INSERT INTO system_menu\" --include=\"*.sql\" rdms-project rdms-system)",
"Bash(findstr /i \"plugin\")",
"Bash(findstr *)",
"Bash(awk END{print NR} *)",
"PowerShell(Move-Item *)",
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am test '-Dtest=VisibilityScopeResolverImplTest' -q 2>&1 | Select-Object -Last 80)",
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am test '-Dtest=VisibilityScopeResolverImplTest' '-Dsurefire.failIfNoSpecifiedTests=false' -q 2>&1 | Select-Object -Last 60)",
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am test '-Dtest=VisibilityScopeResolverImplTest' '-Dsurefire.failIfNoSpecifiedTests=false' -q | Select-Object -Last 60)",
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am test '-Dtest=VisibilityScopeResolverImplTest' '-Dsurefire.failIfNoSpecifiedTests=false' | Select-String -Pattern 'Tests run|BUILD|ERROR|FAIL' | Select-Object -Last 40)",
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am test '-Dsurefire.failIfNoSpecifiedTests=false' | Select-String -Pattern 'Tests run|BUILD|ERROR|FAILED|FAIL' | Select-Object -Last 100)",
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am test-compile '-Dsurefire.failIfNoSpecifiedTests=false' | Select-String -Pattern 'ERROR|BUILD|FAIL' | Select-Object -Last 40)",
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot test '-Dsurefire.failIfNoSpecifiedTests=false' | Select-String -Pattern 'Tests run|BUILD|FAILED|ERROR' | Select-Object -Last 80)"
] ]
} }
} }

View File

@@ -8,6 +8,8 @@
默认回答保持精简,优先给结论、改动点和必要风险,不做过多展开;如果存在你关心但未展开的细节,由你继续追问后再补充。 默认回答保持精简,优先给结论、改动点和必要风险,不做过多展开;如果存在你关心但未展开的细节,由你继续追问后再补充。
回答问题时不要过多代码层面的描述:默认用自然语言给结论、判断、影响面;除非用户明确要看实现细节,不要大段贴代码片段、不要逐行解读、不要把分析写成"先看 xxx.java 第 N 行"的形式。涉及代码定位时用 `file_path:line_number` 引用即可。
## 交互原则 ## 交互原则
- 默认先给执行方案,说明目标、涉及模块、预计改动点和验证方式。 - 默认先给执行方案,说明目标、涉及模块、预计改动点和验证方式。

View File

@@ -9,6 +9,7 @@
- 描述仓库现状以**当前**代码、配置、文档可验证的事实为准;不要拿历史实现、过渡方案或已废弃模型解释当前状态。 - 描述仓库现状以**当前**代码、配置、文档可验证的事实为准;不要拿历史实现、过渡方案或已废弃模型解释当前状态。
- 回答保持精简,先给结论、改动点、必要风险;细节等用户追问。 - 回答保持精简,先给结论、改动点、必要风险;细节等用户追问。
- **不要废话**:默认极简输出,不展开背景、不复述需求、不堆叠章节标题;能用一两句讲清就别写成清单;用户主动追问再展开。 - **不要废话**:默认极简输出,不展开背景、不复述需求、不堆叠章节标题;能用一两句讲清就别写成清单;用户主动追问再展开。
- **回答问题时不要过多代码层面的描述**:默认用自然语言给结论、判断、影响面;除非用户明确要看实现细节,不要大段贴代码片段、不要逐行解读、不要把分析写成"先看 xxx.java 第 N 行"的形式。涉及代码定位时用 `file_path:line_number` 引用即可。
## 本机环境 ## 本机环境
@@ -64,9 +65,42 @@
## 认证与跨模块调用 ## 认证与跨模块调用
- 默认沿用 OAuth2 / Token / `LoginUser` / `login-user` 透传主链。**不要**另造 ThreadLocal / Session / 自定义 header。 - 默认沿用 OAuth2 / Token / `LoginUser` / `login-user` 透传主链。**不要**另造 ThreadLocal / Session / 自定义 header。
- 接口级权限走 `@PreAuthorize("@ss.hasPermission(...)")`,不要绕开。
- 跨模块/跨服务必须通过 `*-api` 模块定义契约;不要直接依赖别人的 `*-boot` - 跨模块/跨服务必须通过 `*-api` 模块定义契约;不要直接依赖别人的 `*-boot`
### 鉴权:必须按"全域 / 对象域"分通道挂
系统有**两条互不交叉**的权限通道,挂错通道 = 永远 403。新增/修改接口前必须先判断它属于哪一域:
| 通道 | 适用场景 | 注解 / 实现 | 角色与菜单 |
|---|---|---|---|
| **全域 global** | 传统 RBAC 顶层菜单与"项目管理界面"——选择对象**之前**的所有动作(建项目、列项目、菜单/角色/用户管理等) | Controller 上 `@PreAuthorize("@ss.hasPermission('xxx')")`,由 `PermissionServiceImpl.hasAnyPermissions` 处理 | `system_role.scope_type='global'` + `system_menu.scope_type='global'` |
| **对象域 object** | 用户**已选择某个对象**(如某个项目/产品)后,对象内部的一切操作(任务、执行、工时、协办人、需求、成员维护等) | Service 上 `@CheckObjectPermission(objectType=..., objectId="#xxxId", permission="...")`,由 `ObjectPermissionAspect``ObjectPermissionService.checkPermission` 处理 | `system_role.scope_type='object'` + `system_menu.scope_type='object'` + `object_type` |
红线:
- **对象内接口绝不能挂 `@PreAuthorize("@ss.hasPermission(...)")`**。该注解走的链路在 `PermissionServiceImpl` 里强制按 GLOBAL 取角色line 343-347+ 强制按 GLOBAL 查菜单line 92-94对象域角色与对象域菜单都进不来即使授权配置完全正确也必然 403。
- **对象域权限校验必须落在 Service 层 `@CheckObjectPermission`**,原因:路径里 `objectId` 通常以 `#projectId`/`#productId` 等 SpEL 解析Controller 的参数校验前置阶段不便复用;与同模块(`ProjectMemberServiceImpl` / `ProjectExecutionServiceImpl` / `ProjectExecutionAssigneeServiceImpl` / `ProjectTaskServiceImpl`)保持一致。
- **同一接口不要两条通道叠加**。要么全域,要么对象域;叠加只会让对象域用户被全域那条卡死。
- 列表/详情这类对象内**读路径**目前未挂 `@CheckObjectPermission`(属已识别负债,台账 TD-001新增读接口暂沿用现状即可不要顺手改造等独立立项。
判定口诀:**URL 里有 `{projectId}` / `{productId}` 等对象 ID → 对象域;没有 → 全域**。
## 接口语义(HTTP 动词)
本仓库 update 类接口默认按 RESTful 标准用 HTTP 动词区分语义,前后端必须按下表对齐,避免"前端没传字段"和"前端想清空"在后端无法区分的歧义。
| 动词 | 语义 | 字段处理规则 |
|---|---|---|
| **PUT** | 全资源替换 | 前端必须把表单完整状态回传(读到的非必填字段也要原样回传)。后端按字段值落库:**有值=更新,`null`=清空**。DO 字段加 `@TableField(updateStrategy = FieldStrategy.ALWAYS)` 跳过全局 `NOT_NULL``null` 真的写库 |
| **PATCH** | 部分字段更新 | **本仓库暂不引入 PATCH 接口**。如果有"只改一两个字段"的需求,用专门的子动作接口(参考 `assignees/{id}/inactive``status` 这种语义化路径),不要在 update 接口里靠旁路标记(如 `clearXxx: true`)模拟 PATCH |
| **DELETE** | 资源删除 | 软删走全局 `deleted` 列,不需要参数 body |
红线:
- **不要在 update 类接口的 ReqVO 里加 `clearXxx: Boolean` 这种旁路标记**来模拟 PATCH —— 等于承认接口是"伪 PATCH",会让所有非必填字段都需要类似标记,长期污染 API 设计。需要部分更新就拆子动作接口。
- 新增 update 接口时,必须在 API 文档对应章节明示"PUT 全字段回传"约定;DO 上对允许 null 的字段补 `FieldStrategy.ALWAYS` 注解,并加注释说明语义来源(指向本节)。
- 历史接口若是稀疏 PATCH 风格(传 null = 不动),保留现状但不要拓展;遇到清空诉求时按 PUT 方向重构。
## 数据与 SQL ## 数据与 SQL
- 新表 DO 复用现有 `BaseDO` / 审计字段风格,不要再引一套审计基类(除非该表本身明确不需要逻辑删除)。 - 新表 DO 复用现有 `BaseDO` / 审计字段风格,不要再引一套审计基类(除非该表本身明确不需要逻辑删除)。

View File

@@ -27,9 +27,8 @@ public class PageParam implements Serializable {
@Min(value = 1, message = "页码最小值为 1") @Min(value = 1, message = "页码最小值为 1")
private Integer pageNo = PAGE_NO; private Integer pageNo = PAGE_NO;
@Schema(description = "每页条数,最大值为 200", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") @Schema(description = "每页条数,最大值为 200;传 -1 表示不分页(查询全部)", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
@NotNull(message = "每页条数不能为空") @NotNull(message = "每页条数不能为空")
@Min(value = 1, message = "每页条数最小值为 1")
@Max(value = 200, message = "每页条数最大值为 200") @Max(value = 200, message = "每页条数最大值为 200")
private Integer pageSize = PAGE_SIZE; private Integer pageSize = PAGE_SIZE;

View File

@@ -33,6 +33,10 @@ public interface ErrorCodeConstants {
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, "产品状态定义不存在或已停用");
// 产品创建原子接口(/create-with-team专用初始团队相关校验
ErrorCode PRODUCT_INITIAL_TEAM_MANAGER_REQUIRED = new ErrorCode(1_008_001_024, "初始团队必须包含产品经理");
ErrorCode PRODUCT_INITIAL_TEAM_MEMBER_DUPLICATE = new ErrorCode(1_008_001_025, "初始团队成员存在重复");
ErrorCode PRODUCT_INITIAL_TEAM_ROLE_INVALID = new ErrorCode(1_008_001_026, "初始团队中存在非法角色");
// ========== 产品需求 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, "产品需求不存在");
@@ -89,15 +93,20 @@ public interface ErrorCodeConstants {
ErrorCode PROJECT_MANAGER_TRANSFER_ROLE_INVALID = new ErrorCode(1_008_002_026, "原项目经理交接后的角色不能仍为项目经理"); ErrorCode PROJECT_MANAGER_TRANSFER_ROLE_INVALID = new ErrorCode(1_008_002_026, "原项目经理交接后的角色不能仍为项目经理");
ErrorCode PROJECT_MANAGER_MEMBER_NOT_ALLOW_DOWNGRADE = new ErrorCode(1_008_002_027, "当前项目经理不能直接调整为非经理角色,请先完成经理转交"); ErrorCode PROJECT_MANAGER_MEMBER_NOT_ALLOW_DOWNGRADE = new ErrorCode(1_008_002_027, "当前项目经理不能直接调整为非经理角色,请先完成经理转交");
ErrorCode PROJECT_MAINLINE_DUPLICATE = new ErrorCode(1_008_002_028, "当前产品下已存在未作废的主线项目"); ErrorCode PROJECT_MAINLINE_DUPLICATE = new ErrorCode(1_008_002_028, "当前产品下已存在未作废的主线项目");
// 项目创建原子接口(/create-with-team专用初始团队 + 方向一致性校验
ErrorCode PROJECT_INITIAL_TEAM_MANAGER_REQUIRED = new ErrorCode(1_008_002_029, "初始团队必须包含项目经理");
ErrorCode PROJECT_INITIAL_TEAM_MEMBER_DUPLICATE = new ErrorCode(1_008_002_030, "初始团队成员存在重复");
ErrorCode PROJECT_INITIAL_TEAM_ROLE_INVALID = new ErrorCode(1_008_002_031, "初始团队中存在非法角色");
ErrorCode PROJECT_DIRECTION_NOT_MATCH_PRODUCT = new ErrorCode(1_008_002_032, "项目方向与所属产品方向不一致");
// ========== 执行管理 1-008-003-000 ========== // ========== 执行管理 1-008-003-000 ==========
ErrorCode PROJECT_EXECUTION_NOT_EXISTS = new ErrorCode(1_008_003_000, "执行不存在"); ErrorCode PROJECT_EXECUTION_NOT_EXISTS = new ErrorCode(1_008_003_000, "执行不存在");
ErrorCode PROJECT_EXECUTION_NAME_DUPLICATE = new ErrorCode(1_008_003_001, "当前项目下已经存在名称为【{}】的执行"); ErrorCode PROJECT_EXECUTION_NAME_DUPLICATE = new ErrorCode(1_008_003_001, "当前项目下已经存在名称为【{}】的执行");
ErrorCode PROJECT_EXECUTION_OWNER_INVALID = new ErrorCode(1_008_003_002, "执行负责人必须是当前项目的有效成员"); ErrorCode PROJECT_EXECUTION_OWNER_INVALID = new ErrorCode(1_008_003_002, "执行负责人必须是当前项目的有效成员");
ErrorCode PROJECT_EXECUTION_MEMBER_INVALID = new ErrorCode(1_008_003_003, "执行成员必须是当前项目的有效成员"); ErrorCode PROJECT_EXECUTION_ASSIGNEE_INVALID = new ErrorCode(1_008_003_003, "执行协办人必须是当前项目的有效成员");
ErrorCode PROJECT_EXECUTION_MEMBER_ALREADY_EXISTS = new ErrorCode(1_008_003_004, "该用户已是当前执行的有效成员"); ErrorCode PROJECT_EXECUTION_ASSIGNEE_ALREADY_EXISTS = new ErrorCode(1_008_003_004, "该用户已是当前执行的有效协办人");
ErrorCode PROJECT_EXECUTION_MEMBER_NOT_EXISTS = new ErrorCode(1_008_003_005, "执行成员不存在"); ErrorCode PROJECT_EXECUTION_ASSIGNEE_NOT_EXISTS = new ErrorCode(1_008_003_005, "执行协办人不存在");
ErrorCode PROJECT_EXECUTION_MEMBER_NOT_ACTIVE = new ErrorCode(1_008_003_006, "当前执行成员已失效"); ErrorCode PROJECT_EXECUTION_ASSIGNEE_NOT_ACTIVE = new ErrorCode(1_008_003_006, "当前执行协办人已失效");
ErrorCode PROJECT_EXECUTION_REQUIREMENT_NOT_READY = new ErrorCode(1_008_003_007, "当前阶段不支持给执行绑定项目需求"); ErrorCode PROJECT_EXECUTION_REQUIREMENT_NOT_READY = new ErrorCode(1_008_003_007, "当前阶段不支持给执行绑定项目需求");
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, "该项目成员仍担任未终态执行负责人,请先完成执行负责人交接");
@@ -107,7 +116,12 @@ public interface ErrorCodeConstants {
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_MEMBER_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_COMPLETE_TASKS_REQUIRED = new ErrorCode(1_008_003_018, "完成执行前,执行下所有任务必须全部完成或取消");
ErrorCode PROJECT_EXECUTION_NOT_ALLOW_DELETE = new ErrorCode(1_008_003_019, "仅初始态(待开始)的执行允许删除");
ErrorCode PROJECT_EXECUTION_DELETE_NAME_MISMATCH = new ErrorCode(1_008_003_020, "确认执行名称与实际不一致");
ErrorCode PROJECT_EXECUTION_DELETE_CONFIRM_TEXT_INVALID = new ErrorCode(1_008_003_021, "删除确认口令必须为 DELETE 或 删除");
// ========== 任务管理 1-008-004-000 ========== // ========== 任务管理 1-008-004-000 ==========
ErrorCode PROJECT_TASK_NOT_EXISTS = new ErrorCode(1_008_004_000, "任务不存在"); ErrorCode PROJECT_TASK_NOT_EXISTS = new ErrorCode(1_008_004_000, "任务不存在");
@@ -120,7 +134,10 @@ public interface ErrorCodeConstants {
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_PROGRESS_PARENT_NOT_EDITABLE = new ErrorCode(1_008_004_011, "父任务进度由子任务自动汇总,不允许手工修改"); ErrorCode PROJECT_TASK_STATUS_OWNER_ONLY = new ErrorCode(1_008_004_011, "只有任务负责人才能执行【{}】动作");
ErrorCode PROJECT_TASK_NOT_ALLOW_DELETE = new ErrorCode(1_008_004_012, "仅初始态(待开始)的任务允许删除");
ErrorCode PROJECT_TASK_DELETE_NAME_MISMATCH = new ErrorCode(1_008_004_013, "确认任务名称与实际不一致");
ErrorCode PROJECT_TASK_DELETE_CONFIRM_TEXT_INVALID = new ErrorCode(1_008_004_014, "删除确认口令必须为 DELETE 或 删除");
ErrorCode PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_PROGRESS = new ErrorCode(1_008_004_012, "拆子任务前请先将父任务进度清零"); ErrorCode PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_PROGRESS = new ErrorCode(1_008_004_012, "拆子任务前请先将父任务进度清零");
ErrorCode PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_WORKLOG = new ErrorCode(1_008_004_013, "拆子任务前请先删除父任务下已填的工时记录"); ErrorCode PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_WORKLOG = new ErrorCode(1_008_004_013, "拆子任务前请先删除父任务下已填的工时记录");
@@ -135,10 +152,20 @@ public interface ErrorCodeConstants {
// ========== 任务工时 1_008_006_xxx ========== // ========== 任务工时 1_008_006_xxx ==========
ErrorCode PROJECT_TASK_WORKLOG_NOT_EXISTS = new ErrorCode(1_008_006_001, "任务工时记录不存在"); ErrorCode PROJECT_TASK_WORKLOG_NOT_EXISTS = new ErrorCode(1_008_006_001, "任务工时记录不存在");
ErrorCode PROJECT_TASK_WORKLOG_NOT_LEAF_TASK = new ErrorCode(1_008_006_002, "父任务不允许填报工时,请到具体子任务填报"); ErrorCode PROJECT_TASK_WORKLOG_NOT_LEAF_TASK = new ErrorCode(1_008_006_002, "父任务不允许填报工时,请到具体子任务填报");
ErrorCode PROJECT_TASK_WORKLOG_DURATION_INVALID = new ErrorCode(1_008_006_003, "工时时长必须大于 0 且为 30 分钟的整数倍"); ErrorCode PROJECT_TASK_WORKLOG_DURATION_INVALID = new ErrorCode(1_008_006_003, "工时小时数必须大于 0 且为 0.5 的整数倍");
ErrorCode PROJECT_TASK_WORKLOG_NOT_OWNER_OR_ASSIGNEE = new ErrorCode(1_008_006_004, "仅任务负责人或在岗协办人可填报工时"); ErrorCode PROJECT_TASK_WORKLOG_NOT_OWNER_OR_ASSIGNEE = new ErrorCode(1_008_006_004, "仅任务负责人或在岗协办人可填报工时");
ErrorCode PROJECT_TASK_WORKLOG_EDIT_NOT_OWN = new ErrorCode(1_008_006_005, "只能修改自己填报的工时记录"); ErrorCode PROJECT_TASK_WORKLOG_EDIT_NOT_OWN = new ErrorCode(1_008_006_005, "只能修改自己填报的工时记录");
ErrorCode PROJECT_TASK_WORKLOG_DELETE_FORBIDDEN = new ErrorCode(1_008_006_006, "仅记录填报人或任务负责人可删除该工时记录"); ErrorCode PROJECT_TASK_WORKLOG_DELETE_FORBIDDEN = new ErrorCode(1_008_006_006, "仅记录填报人或任务负责人可删除该工时记录");
ErrorCode PROJECT_TASK_WORKLOG_DATE_RANGE_INVALID = new ErrorCode(1_008_006_007, "段起始日期不能晚于段结束日期");
ErrorCode PROJECT_TASK_WORKLOG_DATE_OVERLAP = new ErrorCode(1_008_006_008, "日期范围与该任务下您已有的工时记录重叠");
ErrorCode PROJECT_TASK_WORKLOG_PROGRESS_NOT_MONOTONIC = new ErrorCode(1_008_006_010, "工时进度与日期顺序不一致:早段进度不得高于晚段、晚段进度不得低于早段");
// ========== 任务 / 工时附件 1_008_007_xxx ==========
ErrorCode PROJECT_TASK_ATTACHMENT_TOO_MANY = new ErrorCode(1_008_007_001, "附件数量不能超过 {} 个");
ErrorCode PROJECT_TASK_ATTACHMENT_URL_INVALID = new ErrorCode(1_008_007_002, "附件地址非法,必须为 http/https URL 且长度不超过 1024");
ErrorCode PROJECT_TASK_ATTACHMENT_NAME_INVALID = new ErrorCode(1_008_007_003, "附件文件名不合法(必填且长度不超过 255");
ErrorCode PROJECT_TASK_ATTACHMENT_TYPE_NOT_ALLOWED = new ErrorCode(1_008_007_004, "附件扩展名【{}】不在允许列表内");
ErrorCode PROJECT_TASK_ATTACHMENT_TYPE_BLOCKED = new ErrorCode(1_008_007_005, "附件类型【{}】被禁止上传");
// ========== 项目需求 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, "项目需求不存在");

View File

@@ -44,6 +44,22 @@ public final class ObjectActivityConstants {
public static final String PROJECT_TRIGGER_CREATE_EXECUTION = "create_execution"; public static final String PROJECT_TRIGGER_CREATE_EXECUTION = "create_execution";
public static final String PROJECT_TRIGGER_CREATE_TASK = "create_task"; public static final String PROJECT_TRIGGER_CREATE_TASK = "create_task";
public static final String PROJECT_TRIGGER_SCHEDULE_REQUIREMENT = "schedule_requirement"; public static final String PROJECT_TRIGGER_SCHEDULE_REQUIREMENT = "schedule_requirement";
/**
* 项目自动开始触发动作 —— 由执行 auto_start 反向触发。
* 与 {@link #EXECUTION_TRIGGER_AUTO_START} 同语义;项目侧用此名走 AUTO_START_TRIGGERS 白名单。
*/
public static final String PROJECT_TRIGGER_EXECUTION_AUTO_START = "execution_auto_start";
// ========== 任务自动推进触发动作 ==========
/**
* 任务自动开始动作编码 —— owner 或协办人填报工时时由后端自动触发的状态流转动作。
* 对应 rdms_object_status_transition (object_type=task, action_code=auto_start,
* from=pending → to=active)。
* 修改 DB 该条记录的 action_code 时必须同步修改本常量;本常量被
* ProjectTaskServiceImpl#internalAutoStartByWorklog 引用。
* 后续若再增加"任务自动完成 / 执行自动开始"等同类语义,按 *_ACTION_AUTO_* 命名加在本分组。
*/
public static final String TASK_ACTION_AUTO_START = "auto_start";
// ========== 状态动作 ========== // ========== 状态动作 ==========
public static final String STATUS_ACTION_PAUSE = "pause"; public static final String STATUS_ACTION_PAUSE = "pause";
@@ -57,21 +73,23 @@ public final class ObjectActivityConstants {
public static final String MEMBER_ACTION_REMOVE = "remove_member"; public static final String MEMBER_ACTION_REMOVE = "remove_member";
public static final String EXECUTION_ACTION_CREATE = "create_execution_entity"; public static final String EXECUTION_ACTION_CREATE = "create_execution_entity";
public static final String EXECUTION_ACTION_UPDATE = "update_execution_entity"; public static final String EXECUTION_ACTION_UPDATE = "update_execution_entity";
public static final String EXECUTION_ACTION_DELETE = "delete_execution_entity";
public static final String EXECUTION_ACTION_CHANGE_OWNER = "change_execution_owner"; public static final String EXECUTION_ACTION_CHANGE_OWNER = "change_execution_owner";
public static final String EXECUTION_MEMBER_ACTION_ADD = "add_execution_member"; public static final String EXECUTION_ASSIGNEE_ACTION_ADD = "add_execution_assignee";
public static final String EXECUTION_MEMBER_ACTION_REMOVE = "remove_execution_member"; public static final String EXECUTION_ASSIGNEE_ACTION_REMOVE = "remove_execution_assignee";
public static final String TASK_ACTION_CREATE = "create_task_entity"; public static final String TASK_ACTION_CREATE = "create_task_entity";
public static final String TASK_ACTION_UPDATE = "update_task_entity"; public static final String TASK_ACTION_UPDATE = "update_task_entity";
public static final String TASK_ACTION_DELETE = "delete_task_entity";
// ========== 任务协办人事件类型B 模型 - 多行周期记录) ========== // ========== 任务协办人事件类型B 模型 - 多行周期记录) ==========
public static final String TASK_ASSIGNEE_ACTION_JOIN = "join"; public static final String TASK_ASSIGNEE_ACTION_JOIN = "join";
public static final String TASK_ASSIGNEE_ACTION_INACTIVE = "inactive"; public static final String TASK_ASSIGNEE_ACTION_INACTIVE = "inactive";
// ========== 执行成员事件类型B 模型 - 多行周期记录) ========== // ========== 执行协办人事件类型B 模型 - 多行周期记录) ==========
public static final String EXECUTION_MEMBER_LOG_ACTION_JOIN = "join"; public static final String EXECUTION_ASSIGNEE_LOG_ACTION_JOIN = "join";
public static final String EXECUTION_MEMBER_LOG_ACTION_INACTIVE = "inactive"; public static final String EXECUTION_ASSIGNEE_LOG_ACTION_INACTIVE = "inactive";
public static final String EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_IN = "owner_transfer_in"; public static final String EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN = "owner_transfer_in";
public static final String EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_OUT = "owner_transfer_out"; public static final String EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_OUT = "owner_transfer_out";
public static final List<String> STATUS_ACTION_TYPES = List.of( public static final List<String> STATUS_ACTION_TYPES = List.of(
STATUS_ACTION_PAUSE, STATUS_ACTION_RESUME, STATUS_ACTION_ARCHIVE, STATUS_ACTION_ABANDON); STATUS_ACTION_PAUSE, STATUS_ACTION_RESUME, STATUS_ACTION_ARCHIVE, STATUS_ACTION_ABANDON);
@@ -97,21 +115,24 @@ public final class ObjectActivityConstants {
case PRODUCT_ACTION_CREATE -> "创建"; case PRODUCT_ACTION_CREATE -> "创建";
case PRODUCT_ACTION_UPDATE -> "更新"; case PRODUCT_ACTION_UPDATE -> "更新";
case PRODUCT_ACTION_DELETE -> "删除"; case PRODUCT_ACTION_DELETE -> "删除";
case PROJECT_ACTION_AUTO_START -> "自动进入进行中"; case PROJECT_ACTION_AUTO_START -> "开始推进";
case PROJECT_TRIGGER_CREATE_EXECUTION -> "创建执行"; case PROJECT_TRIGGER_CREATE_EXECUTION -> "创建执行";
case PROJECT_TRIGGER_CREATE_TASK -> "创建任务"; case PROJECT_TRIGGER_CREATE_TASK -> "创建任务";
case PROJECT_TRIGGER_SCHEDULE_REQUIREMENT -> "项目需求排期"; case PROJECT_TRIGGER_SCHEDULE_REQUIREMENT -> "项目需求排期";
case PROJECT_TRIGGER_EXECUTION_AUTO_START -> "执行自动开始";
case EXECUTION_ACTION_CREATE -> "创建执行"; case EXECUTION_ACTION_CREATE -> "创建执行";
case EXECUTION_ACTION_UPDATE -> "更新执行"; case EXECUTION_ACTION_UPDATE -> "更新执行";
case EXECUTION_ACTION_DELETE -> "删除执行";
case EXECUTION_ACTION_CHANGE_OWNER -> "变更执行负责人"; case EXECUTION_ACTION_CHANGE_OWNER -> "变更执行负责人";
case EXECUTION_MEMBER_ACTION_ADD -> "新增执行成员"; case EXECUTION_ASSIGNEE_ACTION_ADD -> "新增执行协办人";
case EXECUTION_MEMBER_ACTION_REMOVE -> "移出执行成员"; case EXECUTION_ASSIGNEE_ACTION_REMOVE -> "移出执行协办人";
case TASK_ACTION_CREATE -> "创建任务"; case TASK_ACTION_CREATE -> "创建任务";
case TASK_ACTION_UPDATE -> "更新任务"; case TASK_ACTION_UPDATE -> "更新任务";
case TASK_ACTION_DELETE -> "删除任务";
case TASK_ASSIGNEE_ACTION_JOIN -> "加入"; case TASK_ASSIGNEE_ACTION_JOIN -> "加入";
case TASK_ASSIGNEE_ACTION_INACTIVE -> "退出"; case TASK_ASSIGNEE_ACTION_INACTIVE -> "退出";
case EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_IN -> "转入负责人"; case EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN -> "转入负责人";
case EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_OUT -> "转出负责人"; case EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_OUT -> "转出负责人";
case "start" -> "开始"; case "start" -> "开始";
case "block" -> "阻塞"; case "block" -> "阻塞";
case "complete" -> "完成"; case "complete" -> "完成";

View File

@@ -1,5 +1,7 @@
package com.njcn.rdms.module.project.constant; package com.njcn.rdms.module.project.constant;
import java.util.Set;
/** /**
* 执行对象常量。 * 执行对象常量。
*/ */
@@ -34,13 +36,25 @@ public final class ProjectExecutionConstants {
public static final String PERMISSION_OWNER = "project:execution:owner"; public static final String PERMISSION_OWNER = "project:execution:owner";
/** /**
* 执行成员治理权限码。 * 执行协办人治理权限码。
*/ */
public static final String PERMISSION_MEMBER = "project:execution:member"; public static final String PERMISSION_ASSIGNEE = "project:execution:assignee";
/** /**
* 执行状态动作权限码。 * 执行状态动作权限码。
*/ */
public static final String PERMISSION_STATUS = "project:execution:status"; public static final String PERMISSION_STATUS = "project:execution:status";
/**
* 删除执行权限码。
* 推荐挂"项目负责人"角色(参见 docs/项目/2026-05-11-执行按钮可见度对齐设计.md §6.4)。
*/
public static final String PERMISSION_DELETE = "project:execution:delete";
/**
* 删除确认口令合法值集合;兼容大写英文 "DELETE" 与中文 "删除",前端可纯中文文案。
* 校验时精确匹配trim 后比对)。
*/
public static final Set<String> DELETE_CONFIRM_TEXTS = Set.of("DELETE", "删除");
} }

View File

@@ -76,6 +76,7 @@ public final class ProjectObjectConstants {
public static final Set<String> AUTO_START_TRIGGERS = Set.of( public static final Set<String> AUTO_START_TRIGGERS = Set.of(
ObjectActivityConstants.PROJECT_TRIGGER_CREATE_EXECUTION, ObjectActivityConstants.PROJECT_TRIGGER_CREATE_EXECUTION,
ObjectActivityConstants.PROJECT_TRIGGER_CREATE_TASK, ObjectActivityConstants.PROJECT_TRIGGER_CREATE_TASK,
ObjectActivityConstants.PROJECT_TRIGGER_SCHEDULE_REQUIREMENT); ObjectActivityConstants.PROJECT_TRIGGER_SCHEDULE_REQUIREMENT,
ObjectActivityConstants.PROJECT_TRIGGER_EXECUTION_AUTO_START);
} }

View File

@@ -1,5 +1,7 @@
package com.njcn.rdms.module.project.constant; package com.njcn.rdms.module.project.constant;
import java.util.Set;
/** /**
* 任务对象常量。 * 任务对象常量。
*/ */
@@ -43,4 +45,18 @@ public final class ProjectTaskConstants {
*/ */
public static final String PERMISSION_WORKLOG = "project:task:worklog"; public static final String PERMISSION_WORKLOG = "project:task:worklog";
/**
* 删除任务权限码。
* 推荐挂"项目负责人"角色(参见 docs/项目/2026-05-11-执行按钮可见度对齐设计.md §6.4)。
* 实际拦截service 内按 task.parentTaskId 分流走 checkOwnerOrProjectPermission
* (一级任务 → execution.ownerId 字段身份;子任务 → parentTask.ownerId 字段身份;不命中字段时回落本权限码)。
*/
public static final String PERMISSION_DELETE = "project:task:delete";
/**
* 删除确认口令合法值集合;兼容大写英文 "DELETE" 与中文 "删除",前端可纯中文文案。
* 校验时精确匹配trim 后比对)。
*/
public static final Set<String> DELETE_CONFIRM_TEXTS = Set.of("DELETE", "删除");
} }

View File

@@ -4,6 +4,7 @@ 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.object.BeanUtils; import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductCreateWithTeamReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductOverviewSummaryRespVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductOverviewSummaryRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
@@ -39,6 +40,13 @@ public class ProductController {
return success(productService.createProduct(createReqVO)); return success(productService.createProduct(createReqVO));
} }
@PostMapping("/create-with-team")
@Operation(summary = "创建产品并初始化团队(原子接口)")
@PreAuthorize("@ss.hasPermission('project:product:create')")
public CommonResult<Long> createProductWithTeam(@Valid @RequestBody ProductCreateWithTeamReqVO reqVO) {
return success(productService.createProductWithTeam(reqVO));
}
@PutMapping("/update") @PutMapping("/update")
@Operation(summary = "更新产品") @Operation(summary = "更新产品")
public CommonResult<Boolean> updateProduct(@Valid @RequestBody ProductSaveReqVO updateReqVO) { public CommonResult<Boolean> updateProduct(@Valid @RequestBody ProductSaveReqVO updateReqVO) {

View File

@@ -0,0 +1,42 @@
package com.njcn.rdms.module.project.controller.admin.product.vo.product;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberSaveReqVO;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
* 产品创建原子接口请求 VOPOST /project/product/create-with-team
*
* <p>由前端"产品两步向导"一次性提交:产品基础资料 + 初始团队成员。
* 后端必须在同一事务内完成全部写入,任一步失败整体回滚。
*/
@Schema(description = "管理后台 - 产品创建含初始团队Request VO")
@Data
public class ProductCreateWithTeamReqVO {
/**
* 产品基础资料。沿用 {@link ProductSaveReqVO} 字段约束(同 POST /create
*/
@Schema(description = "产品基础资料", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "产品基础资料不能为空")
@Valid
private ProductSaveReqVO product;
/**
* 初始团队成员列表。
*
* <p>必须包含一条 {@code userId == product.managerUserId} 的产品经理成员,
* 由前端在打开第 2 步时按 role code = {@code product_manager} 反查 roleId 后聚合提交。
* 后端不再根据 {@code product.managerUserId} 自动追加经理成员。
*/
@Schema(description = "初始团队成员(含产品经理本人)", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty(message = "初始团队成员不能为空")
@Valid
private List<ProductMemberSaveReqVO> members;
}

View File

@@ -4,6 +4,7 @@ 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.object.BeanUtils; import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO; import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectCreateWithTeamReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectDeleteReqVO; import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO; import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
@@ -41,6 +42,13 @@ public class ProjectController {
return success(projectService.createProject(createReqVO)); return success(projectService.createProject(createReqVO));
} }
@PostMapping("/create-with-team")
@Operation(summary = "创建项目并初始化团队(原子接口)")
@PreAuthorize("@ss.hasPermission('project:project:create')")
public CommonResult<Long> createProjectWithTeam(@Valid @RequestBody ProjectCreateWithTeamReqVO reqVO) {
return success(projectService.createProjectWithTeam(reqVO));
}
@PutMapping("/update") @PutMapping("/update")
@Operation(summary = "更新项目") @Operation(summary = "更新项目")
public CommonResult<Boolean> updateProject(@Valid @RequestBody ProjectSaveReqVO updateReqVO) { public CommonResult<Boolean> updateProject(@Valid @RequestBody ProjectSaveReqVO updateReqVO) {

View File

@@ -0,0 +1,70 @@
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.PageResult;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeSaveReqVO;
import com.njcn.rdms.module.project.service.project.execution.ProjectExecutionAssigneeService;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - 执行协办人")
@RestController
@RequestMapping("/project/project/{projectId}/executions/{executionId}")
@Validated
public class ProjectExecutionAssigneeController {
@Resource
private ProjectExecutionAssigneeService projectExecutionAssigneeService;
@GetMapping("/assignees")
@Operation(summary = "获取执行协办人列表(仅当前活跃)")
public CommonResult<List<ExecutionAssigneeRespVO>> getExecutionAssigneeList(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId) {
return success(projectExecutionAssigneeService.getExecutionAssigneeList(projectId, executionId));
}
@PostMapping("/assignees")
@Operation(summary = "新增执行协办人B 模型 - 每次新增一段)")
public CommonResult<Long> createExecutionAssignee(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@Valid @RequestBody ExecutionAssigneeSaveReqVO reqVO) {
return success(projectExecutionAssigneeService.createExecutionAssignee(projectId, executionId, reqVO));
}
@PostMapping("/assignees/{assigneeId}/inactive")
@Operation(summary = "失效执行协办人(永久保留 removedReason")
public CommonResult<Boolean> inactiveExecutionAssignee(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@PathVariable("assigneeId") Long assigneeId,
@Valid @RequestBody ExecutionAssigneeInactiveReqVO reqVO) {
projectExecutionAssigneeService.inactiveExecutionAssignee(projectId, executionId, assigneeId, reqVO);
return success(true);
}
@GetMapping("/assignee-logs")
@Operation(summary = "获取执行协办人变更历史(分页)")
public CommonResult<PageResult<ExecutionAssigneeLogRespVO>> getExecutionAssigneeLogPage(
@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@Valid ExecutionAssigneeLogPageReqVO reqVO) {
return success(projectExecutionAssigneeService.getExecutionAssigneeLogPage(projectId, executionId, reqVO));
}
}

View File

@@ -7,7 +7,9 @@ import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execut
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.execution.vo.execution.ProjectExecutionRespVO; 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.ProjectExecutionSaveReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionCreateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO;
import com.njcn.rdms.module.project.service.project.ProjectStatusBoardService; import com.njcn.rdms.module.project.service.project.ProjectStatusBoardService;
import com.njcn.rdms.module.project.service.project.execution.ProjectExecutionService; import com.njcn.rdms.module.project.service.project.execution.ProjectExecutionService;
@@ -34,7 +36,7 @@ public class ProjectExecutionController {
@PostMapping @PostMapping
@Operation(summary = "创建执行") @Operation(summary = "创建执行")
public CommonResult<Long> createExecution(@PathVariable("projectId") Long projectId, public CommonResult<Long> createExecution(@PathVariable("projectId") Long projectId,
@Valid @RequestBody ProjectExecutionSaveReqVO reqVO) { @Valid @RequestBody ProjectExecutionCreateReqVO reqVO) {
return success(projectExecutionService.createExecution(projectId, reqVO)); return success(projectExecutionService.createExecution(projectId, reqVO));
} }
@@ -42,7 +44,7 @@ public class ProjectExecutionController {
@Operation(summary = "编辑执行") @Operation(summary = "编辑执行")
public CommonResult<Boolean> updateExecution(@PathVariable("projectId") Long projectId, public CommonResult<Boolean> updateExecution(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId, @PathVariable("executionId") Long executionId,
@Valid @RequestBody ProjectExecutionSaveReqVO reqVO) { @Valid @RequestBody ProjectExecutionUpdateReqVO reqVO) {
reqVO.setId(executionId); reqVO.setId(executionId);
projectExecutionService.updateExecution(projectId, reqVO); projectExecutionService.updateExecution(projectId, reqVO);
return success(true); return success(true);
@@ -87,4 +89,13 @@ public class ProjectExecutionController {
return success(true); return success(true);
} }
@DeleteMapping("/{executionId}")
@Operation(summary = "删除执行(仅初始态可删,三重确认)")
public CommonResult<Boolean> deleteExecution(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@Valid @RequestBody ProjectExecutionDeleteReqVO reqVO) {
projectExecutionService.deleteExecution(projectId, executionId, reqVO);
return success(true);
}
} }

View File

@@ -1,70 +0,0 @@
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.PageResult;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberLogPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberLogRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberSaveReqVO;
import com.njcn.rdms.module.project.service.project.execution.ProjectExecutionMemberService;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - 执行成员")
@RestController
@RequestMapping("/project/project/{projectId}/executions/{executionId}")
@Validated
public class ProjectExecutionMemberController {
@Resource
private ProjectExecutionMemberService projectExecutionMemberService;
@GetMapping("/members")
@Operation(summary = "获取执行成员列表(仅当前活跃)")
public CommonResult<List<ExecutionMemberRespVO>> getExecutionMemberList(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId) {
return success(projectExecutionMemberService.getExecutionMemberList(projectId, executionId));
}
@PostMapping("/members")
@Operation(summary = "新增执行成员B 模型 - 每次新增一段)")
public CommonResult<Long> createExecutionMember(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@Valid @RequestBody ExecutionMemberSaveReqVO reqVO) {
return success(projectExecutionMemberService.createExecutionMember(projectId, executionId, reqVO));
}
@PostMapping("/members/{memberId}/inactive")
@Operation(summary = "失效执行成员(永久保留 removedReason")
public CommonResult<Boolean> inactiveExecutionMember(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@PathVariable("memberId") Long memberId,
@Valid @RequestBody ExecutionMemberInactiveReqVO reqVO) {
projectExecutionMemberService.inactiveExecutionMember(projectId, executionId, memberId, reqVO);
return success(true);
}
@GetMapping("/member-logs")
@Operation(summary = "获取执行成员变更历史(分页)")
public CommonResult<PageResult<ExecutionMemberLogRespVO>> getExecutionMemberLogPage(
@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@Valid ExecutionMemberLogPageReqVO reqVO) {
return success(projectExecutionMemberService.getExecutionMemberLogPage(projectId, executionId, reqVO));
}
}

View File

@@ -1,13 +1,13 @@
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.member; package com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.Data; import lombok.Data;
@Schema(description = "管理后台 - 执行成员失效 Request VO") @Schema(description = "管理后台 - 执行协办人失效 Request VO")
@Data @Data
public class ExecutionMemberInactiveReqVO { public class ExecutionAssigneeInactiveReqVO {
@Schema(description = "失效原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "阶段性退出") @Schema(description = "失效原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "阶段性退出")
@NotBlank(message = "失效原因不能为空") @NotBlank(message = "失效原因不能为空")

View File

@@ -1,4 +1,4 @@
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.member; package com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee;
import com.njcn.rdms.framework.common.pojo.PageParam; import com.njcn.rdms.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
@@ -8,16 +8,16 @@ import lombok.EqualsAndHashCode;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@Schema(description = "管理后台 - 执行成员变更历史分页 Request VO") @Schema(description = "管理后台 - 执行协办人变更历史分页 Request VO")
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class ExecutionMemberLogPageReqVO extends PageParam { public class ExecutionAssigneeLogPageReqVO extends PageParam {
@Schema(description = "事件类型多选;不传表示全部", @Schema(description = "事件类型多选;不传表示全部",
example = "[\"join\",\"inactive\",\"owner_transfer_in\",\"owner_transfer_out\"]") example = "[\"join\",\"inactive\",\"owner_transfer_in\",\"owner_transfer_out\"]")
private List<String> actionTypes; private List<String> actionTypes;
@Schema(description = "成员用户编号;不传表示全部") @Schema(description = "协办人用户编号;不传表示全部")
private Long userId; private Long userId;
@Schema(description = "起始时间(含),按 actionTime 比较") @Schema(description = "起始时间(含),按 actionTime 比较")

View File

@@ -1,13 +1,13 @@
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.member; package com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Schema(description = "管理后台 - 执行成员变更历史 Response VO") @Schema(description = "管理后台 - 执行协办人变更历史 Response VO")
@Data @Data
public class ExecutionMemberLogRespVO { public class ExecutionAssigneeLogRespVO {
@Schema(description = "日志编号", example = "12001") @Schema(description = "日志编号", example = "12001")
private Long id; private Long id;

View File

@@ -1,21 +1,21 @@
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.member; package com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Schema(description = "管理后台 - 执行成员 Response VO") @Schema(description = "管理后台 - 执行协办人 Response VO")
@Data @Data
public class ExecutionMemberRespVO { public class ExecutionAssigneeRespVO {
@Schema(description = "成员关系编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "7001") @Schema(description = "协办人关系编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "7001")
private Long id; private Long id;
@Schema(description = "所属执行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001") @Schema(description = "所属执行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001")
private Long executionId; private Long executionId;
@Schema(description = "成员用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3002") @Schema(description = "协办人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3002")
private Long userId; private Long userId;
@Schema(description = "成员用户昵称") @Schema(description = "协办人用户昵称")
private String userNickname; private String userNickname;
@Schema(description = "加入时间", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "加入时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime joinedAt; private LocalDateTime joinedAt;

View File

@@ -0,0 +1,15 @@
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Schema(description = "管理后台 - 执行协办人新增 Request VO")
@Data
public class ExecutionAssigneeSaveReqVO {
@Schema(description = "协办人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3002")
@NotNull(message = "协办人用户不能为空")
private Long userId;
}

View File

@@ -9,12 +9,15 @@ import lombok.Data;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
@Schema(description = "管理后台 - 执行保存 Request VO") /**
* 创建执行 Request VO
* <p>
* ownerId必填+ assigneeUserIds创建时同步装配协办人
* 后续编辑主数据走 PUT + {@link ProjectExecutionUpdateReqVO}不含 ownerId / 协办人字段避免越权裂缝
*/
@Schema(description = "管理后台 - 执行创建 Request VO")
@Data @Data
public class ProjectExecutionSaveReqVO { public class ProjectExecutionCreateReqVO {
@Schema(description = "执行编号", example = "5001")
private Long id;
@Schema(description = "执行名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "后端接口联调") @Schema(description = "执行名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "后端接口联调")
@NotBlank(message = "执行名称不能为空") @NotBlank(message = "执行名称不能为空")
@@ -44,7 +47,7 @@ public class ProjectExecutionSaveReqVO {
@Size(max = 200000, message = "执行说明长度不能超过200000个字符") @Size(max = 200000, message = "执行说明长度不能超过200000个字符")
private String executionDesc; private String executionDesc;
@Schema(description = "创建执行时同步写入的成员用户编号列表;编辑执行主数据时不维护成员", example = "[3002,3003]") @Schema(description = "创建执行时同步写入的协办人用户编号列表", example = "[3002,3003]")
private List<Long> memberUserIds; private List<Long> assigneeUserIds;
} }

View File

@@ -0,0 +1,31 @@
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 删除执行 Request VO。与项目侧 ProjectDeleteReqVO 同款三重确认confirmText / executionName / reason
* 详见 docs/项目/2026-05-11-执行按钮可见度对齐设计.md §6.2.1。
*/
@Schema(description = "管理后台 - 执行删除 Request VO")
@Data
public class ProjectExecutionDeleteReqVO {
@Schema(description = "确认输入的执行名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "后端接口联调")
@NotBlank(message = "确认执行名称不能为空")
@Size(max = 200, message = "确认执行名称长度不能超过200个字符")
private String executionName;
@Schema(description = "删除确认口令,当前固定输入 DELETE", requiredMode = Schema.RequiredMode.REQUIRED, example = "DELETE")
@NotBlank(message = "删除确认口令不能为空")
@Size(max = 32, message = "删除确认口令长度不能超过32个字符")
private String confirmText;
@Schema(description = "删除原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "执行录入错误")
@NotBlank(message = "删除原因不能为空")
@Size(max = 500, message = "删除原因长度不能超过500个字符")
private String reason;
}

View File

@@ -44,7 +44,7 @@ public class ProjectExecutionRespVO {
private LocalDate actualStartDate; private LocalDate actualStartDate;
@Schema(description = "实际结束日期") @Schema(description = "实际结束日期")
private LocalDate actualEndDate; private LocalDate actualEndDate;
@Schema(description = "执行进度缓存值") @Schema(description = "执行进度")
private BigDecimal progressRate; private BigDecimal progressRate;
@Schema(description = "执行说明") @Schema(description = "执行说明")
private String executionDesc; private String executionDesc;

View File

@@ -0,0 +1,47 @@
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDate;
/**
* 编辑执行 Request VOPUT 全字段语义)。
* <p>
* 不含 ownerId / assigneeUserIds换负责人走 /change-owner 独立端点,协办人维护走 /assignees 独立端点。
* 详见 docs/项目/2026-05-11-执行按钮可见度对齐设计.md §6.3。
*/
@Schema(description = "管理后台 - 执行编辑 Request VO")
@Data
public class ProjectExecutionUpdateReqVO {
@Schema(description = "执行编号", example = "5001")
private Long id;
@Schema(description = "执行名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "后端接口联调")
@NotBlank(message = "执行名称不能为空")
@Size(max = 200, message = "执行名称长度不能超过200个字符")
private String executionName;
@Schema(description = "执行类型,取值来自字典 rdms_project_execution_type", requiredMode = Schema.RequiredMode.REQUIRED, example = "feature")
@NotBlank(message = "执行类型不能为空")
@Size(max = 32, message = "执行类型长度不能超过32个字符")
private String executionType;
@Schema(description = "关联项目需求编号,第一阶段只接受空值", example = "9001")
private Long projectRequirementId;
@Schema(description = "计划开始日期")
private LocalDate plannedStartDate;
@Schema(description = "计划结束日期")
private LocalDate plannedEndDate;
@Schema(description = "执行说明(接受 HTML 富文本,图片走 URL 引用;后端经全局 XSS Safelist 自动净化)",
example = "接口联调与问题跟踪")
@Size(max = 200000, message = "执行说明长度不能超过200000个字符")
private String executionDesc;
}

View File

@@ -1,15 +0,0 @@
package com.njcn.rdms.module.project.controller.admin.project.execution.vo.member;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Schema(description = "管理后台 - 执行成员新增 Request VO")
@Data
public class ExecutionMemberSaveReqVO {
@Schema(description = "成员用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3002")
@NotNull(message = "成员用户不能为空")
private Long userId;
}

View File

@@ -2,6 +2,7 @@ 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.CommonResult;
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.task.vo.ProjectTaskDeleteReqVO;
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.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;
@@ -83,4 +84,14 @@ public class ProjectTaskController {
return success(true); return success(true);
} }
@DeleteMapping("/{taskId}")
@Operation(summary = "删除任务(仅初始态可删,三重确认 + 执行 owner 硬卡)")
public CommonResult<Boolean> deleteTask(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@PathVariable("taskId") Long taskId,
@Valid @RequestBody ProjectTaskDeleteReqVO reqVO) {
projectTaskService.deleteTask(projectId, executionId, taskId, reqVO);
return success(true);
}
} }

View File

@@ -12,7 +12,6 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -44,7 +43,6 @@ public class TaskAssigneeController {
@PostMapping("/assignees") @PostMapping("/assignees")
@Operation(summary = "加入任务协办人") @Operation(summary = "加入任务协办人")
@PreAuthorize("@ss.hasPermission('project:task:assignee')")
public CommonResult<Long> createAssignee(@PathVariable("projectId") Long projectId, public CommonResult<Long> createAssignee(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId, @PathVariable("executionId") Long executionId,
@PathVariable("taskId") Long taskId, @PathVariable("taskId") Long taskId,
@@ -54,7 +52,6 @@ public class TaskAssigneeController {
@PostMapping("/assignees/{assigneeId}/inactive") @PostMapping("/assignees/{assigneeId}/inactive")
@Operation(summary = "退出任务协办人") @Operation(summary = "退出任务协办人")
@PreAuthorize("@ss.hasPermission('project:task:assignee')")
public CommonResult<Boolean> inactiveAssignee(@PathVariable("projectId") Long projectId, public CommonResult<Boolean> inactiveAssignee(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId, @PathVariable("executionId") Long executionId,
@PathVariable("taskId") Long taskId, @PathVariable("taskId") Long taskId,

View File

@@ -10,7 +10,6 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -43,7 +42,6 @@ public class TaskWorklogController {
@PostMapping("/worklogs") @PostMapping("/worklogs")
@Operation(summary = "新增任务工时") @Operation(summary = "新增任务工时")
@PreAuthorize("@ss.hasPermission('project:task:worklog')")
public CommonResult<Long> createWorklog(@PathVariable("projectId") Long projectId, public CommonResult<Long> createWorklog(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId, @PathVariable("executionId") Long executionId,
@PathVariable("taskId") Long taskId, @PathVariable("taskId") Long taskId,
@@ -53,7 +51,6 @@ public class TaskWorklogController {
@PutMapping("/worklogs/{worklogId}") @PutMapping("/worklogs/{worklogId}")
@Operation(summary = "修改任务工时(仅自己)") @Operation(summary = "修改任务工时(仅自己)")
@PreAuthorize("@ss.hasPermission('project:task:worklog')")
public CommonResult<Boolean> updateWorklog(@PathVariable("projectId") Long projectId, public CommonResult<Boolean> updateWorklog(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId, @PathVariable("executionId") Long executionId,
@PathVariable("taskId") Long taskId, @PathVariable("taskId") Long taskId,
@@ -65,7 +62,6 @@ public class TaskWorklogController {
@DeleteMapping("/worklogs/{worklogId}") @DeleteMapping("/worklogs/{worklogId}")
@Operation(summary = "删除任务工时(自己或任务负责人)") @Operation(summary = "删除任务工时(自己或任务负责人)")
@PreAuthorize("@ss.hasPermission('project:task:worklog')")
public CommonResult<Boolean> deleteWorklog(@PathVariable("projectId") Long projectId, public CommonResult<Boolean> deleteWorklog(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId, @PathVariable("executionId") Long executionId,
@PathVariable("taskId") Long taskId, @PathVariable("taskId") Long taskId,

View File

@@ -0,0 +1,31 @@
package com.njcn.rdms.module.project.controller.admin.project.task.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 删除任务 Request VO。与执行侧 / 项目侧同款三重确认confirmText / taskName / reason
* 详见 docs/项目/2026-05-11-执行按钮可见度对齐设计.md §6.2.2。
*/
@Schema(description = "管理后台 - 任务删除 Request VO")
@Data
public class ProjectTaskDeleteReqVO {
@Schema(description = "确认输入的任务名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "接口契约对齐")
@NotBlank(message = "确认任务名称不能为空")
@Size(max = 200, message = "确认任务名称长度不能超过200个字符")
private String taskName;
@Schema(description = "删除确认口令,当前固定输入 DELETE", requiredMode = Schema.RequiredMode.REQUIRED, example = "DELETE")
@NotBlank(message = "删除确认口令不能为空")
@Size(max = 32, message = "删除确认口令长度不能超过32个字符")
private String confirmText;
@Schema(description = "删除原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "任务录入错误")
@NotBlank(message = "删除原因不能为空")
@Size(max = 500, message = "删除原因长度不能超过500个字符")
private String reason;
}

View File

@@ -1,5 +1,6 @@
package com.njcn.rdms.module.project.controller.admin.project.task.vo; package com.njcn.rdms.module.project.controller.admin.project.task.vo;
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -8,6 +9,8 @@ import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@Schema(description = "管理后台 - 任务 Response VO") @Schema(description = "管理后台 - 任务 Response VO")
@Data @Data
public class ProjectTaskRespVO { public class ProjectTaskRespVO {
@@ -20,6 +23,12 @@ public class ProjectTaskRespVO {
private Long executionId; private Long executionId;
@Schema(description = "父任务编号") @Schema(description = "父任务编号")
private Long parentTaskId; private Long parentTaskId;
@Schema(description = "父任务负责人用户编号;一级任务为 null子任务用于前端判断"
+ "新增子任务/编辑/删除按钮显隐(详见前端联调清单 §4.3", example = "3002")
private Long parentTaskOwnerId;
@Schema(description = "所属执行的负责人用户编号;前端判断一级任务"
+ "新增/编辑/删除按钮显隐(一级任务执行负责人可自行调整)", example = "3001")
private Long executionOwnerId;
@Schema(description = "任务标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "接口联调任务") @Schema(description = "任务标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "接口联调任务")
private String taskTitle; private String taskTitle;
@Schema(description = "任务负责人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3002") @Schema(description = "任务负责人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3002")
@@ -52,9 +61,11 @@ public class ProjectTaskRespVO {
private String lastStatusReason; private String lastStatusReason;
@Schema(description = "当前活跃协办人列表;详细变更历史见 assignee-logs 接口") @Schema(description = "当前活跃协办人列表;详细变更历史见 assignee-logs 接口")
private List<TaskAssigneeView> assignees; private List<TaskAssigneeView> assignees;
@Schema(description = "已填报工时合计(分钟);逻辑删除的工时记录不计入。无记录默认为 0", @Schema(description = "已填报工时合计(小时0.5 颗粒);逻辑删除的工时记录不计入。无记录默认为 0",
example = "300") example = "8.0")
private Long totalSpentMinutes; private BigDecimal totalSpentHours;
@Schema(description = "附件列表")
private List<AttachmentItem> attachments;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime; private LocalDateTime createTime;
@Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED)

View File

@@ -1,13 +1,12 @@
package com.njcn.rdms.module.project.controller.admin.project.task.vo; package com.njcn.rdms.module.project.controller.admin.project.task.vo;
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMax; import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.Data; import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
@@ -29,11 +28,6 @@ public class ProjectTaskSaveReqVO {
@Schema(description = "任务负责人用户编号;子任务不传时继承父任务负责人", example = "3002") @Schema(description = "任务负责人用户编号;子任务不传时继承父任务负责人", example = "3002")
private Long ownerId; private Long ownerId;
@Schema(description = "任务进度", example = "0.00")
@DecimalMin(value = "0.00", message = "任务进度不能小于0")
@DecimalMax(value = "100.00", message = "任务进度不能大于100")
private BigDecimal progressRate;
@Schema(description = "计划开始日期") @Schema(description = "计划开始日期")
private LocalDate plannedStartDate; private LocalDate plannedStartDate;
@@ -49,5 +43,9 @@ public class ProjectTaskSaveReqVO {
+ "协办人通过独立接口管理,详见 /tasks/{id}/assignees") + "协办人通过独立接口管理,详见 /tasks/{id}/assignees")
private List<Long> assigneeUserIds; private List<Long> assigneeUserIds;
@Schema(description = "附件列表;规则与限制详见 AttachmentValidator数量上限、扩展名白/黑名单、URL 协议)")
@Valid
private List<AttachmentItem> attachments;
} }

View File

@@ -9,7 +9,7 @@ import lombok.Data;
@Data @Data
public class ProjectTaskStatusActionReqVO { public class ProjectTaskStatusActionReqVO {
@Schema(description = "动作编码,如 start、block、resume、complete、cancel", requiredMode = Schema.RequiredMode.REQUIRED, example = "complete") @Schema(description = "动作编码,如 auto_start、pause、resume、complete、cancel", requiredMode = Schema.RequiredMode.REQUIRED, example = "complete")
@NotBlank(message = "动作编码不能为空") @NotBlank(message = "动作编码不能为空")
@Size(max = 32, message = "动作编码长度不能超过32个字符") @Size(max = 32, message = "动作编码长度不能超过32个字符")
private String actionCode; private String actionCode;

View File

@@ -15,10 +15,10 @@ public class TaskWorklogPageReqVO extends PageParam {
@Schema(description = "填报人用户编号;不传表示全部") @Schema(description = "填报人用户编号;不传表示全部")
private Long userId; private Long userId;
@Schema(description = "起始日期(含),按 workDate 比较") @Schema(description = "查询区间起始日期(含),按段相交过滤record.endDate >= startDate")
private LocalDate startDate; private LocalDate startDate;
@Schema(description = "截止日期(含),按 workDate 比较") @Schema(description = "查询区间截止日期(含),按段相交过滤record.startDate <= endDate")
private LocalDate endDate; private LocalDate endDate;
} }

View File

@@ -1,10 +1,13 @@
package com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog; package com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog;
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - 任务工时 Response VO") @Schema(description = "管理后台 - 任务工时 Response VO")
@Data @Data
@@ -22,15 +25,24 @@ public class TaskWorklogRespVO {
@Schema(description = "填报人昵称", example = "张三") @Schema(description = "填报人昵称", example = "张三")
private String userNickname; private String userNickname;
@Schema(description = "工作日期", example = "2026-05-08") @Schema(description = "段起始日期(含),单天=与 endDate 相等", example = "2026-05-04")
private LocalDate workDate; private LocalDate startDate;
@Schema(description = "时长(分钟)", example = "150") @Schema(description = "段结束日期(含),单天=与 startDate 相等", example = "2026-05-08")
private Integer durationMinutes; private LocalDate endDate;
@Schema(description = "本次填报小时数0.5 颗粒)", example = "8.0")
private BigDecimal durationHours;
@Schema(description = "本次填报进度0~100", example = "60.00")
private BigDecimal progressRate;
@Schema(description = "工作内容描述") @Schema(description = "工作内容描述")
private String workContent; private String workContent;
@Schema(description = "附件列表")
private List<AttachmentItem> attachments;
@Schema(description = "创建时间") @Schema(description = "创建时间")
private LocalDateTime createTime; private LocalDateTime createTime;

View File

@@ -1,33 +1,57 @@
package com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog; package com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog;
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min; import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.Data; import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List;
/** /**
* 任务工时新增/更新请求。同表共用updateWorklog 不接受 taskId / userId 切换,前端无需也无法传。 * 任务工时新增/更新请求。同表共用updateWorklog 不接受 taskId / userId 切换,前端无需也无法传。
* 时长颗粒30 分钟整数倍)由 Service 层校验。 * 段语义startDate / endDate 必填,单天 = 二者相等;同人同任务下日期范围禁止重叠(Service 层校验
* 颗粒durationHours 必须 > 0 且为 0.5 的整数倍Service 层校验)。
*/ */
@Schema(description = "管理后台 - 任务工时 Save Request VO") @Schema(description = "管理后台 - 任务工时 Save Request VO")
@Data @Data
public class TaskWorklogSaveReqVO { public class TaskWorklogSaveReqVO {
@Schema(description = "工作日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-05-08") @Schema(description = "段起始日期(含),单天=与 endDate 相等",
@NotNull(message = "工作日期不能为空") requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-05-04")
private LocalDate workDate; @NotNull(message = "段起始日期不能为空")
private LocalDate startDate;
@Schema(description = "时长(分钟),> 0 且必须为 30 的整数倍", @Schema(description = "段结束日期(含),单天=与 startDate 相等",
requiredMode = Schema.RequiredMode.REQUIRED, example = "150") requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-05-08")
@NotNull(message = "工时时长不能为空") @NotNull(message = "段结束日期不能为空")
@Min(value = 30, message = "工时时长必须大于 0 且为 30 分钟的整数倍") private LocalDate endDate;
private Integer durationMinutes;
@Schema(description = "本次填报小时数,> 0 且必须为 0.5 的整数倍",
requiredMode = Schema.RequiredMode.REQUIRED, example = "8.0")
@NotNull(message = "工时小时数不能为空")
@DecimalMin(value = "0.5", message = "工时小时数必须大于 0 且为 0.5 的整数倍")
private BigDecimal durationHours;
@Schema(description = "本次填报进度0~100必填。owner 填报:以 owner 本人最新一条工时(按 end_date 排序)"
+ "为准同步任务进度;协作人填报:仅作为本人自评个人完成度,不影响任务/父任务进度",
requiredMode = Schema.RequiredMode.REQUIRED, example = "60.00")
@NotNull(message = "本次填报进度不能为空")
@DecimalMin(value = "0.00", message = "本次填报进度不能小于 0")
@DecimalMax(value = "100.00", message = "本次填报进度不能大于 100")
private BigDecimal progressRate;
@Schema(description = "工作内容描述", example = "完成接口联调与冒烟测试") @Schema(description = "工作内容描述", example = "完成接口联调与冒烟测试")
@Size(max = 2000, message = "工作内容长度不能超过 2000 个字符") @Size(max = 2000, message = "工作内容长度不能超过 2000 个字符")
private String workContent; private String workContent;
@Schema(description = "附件列表;规则与限制详见 AttachmentValidator数量上限、扩展名白/黑名单、URL 协议)")
@Valid
private List<AttachmentItem> attachments;
} }

View File

@@ -0,0 +1,43 @@
package com.njcn.rdms.module.project.controller.admin.project.vo.project;
import com.njcn.rdms.module.project.controller.admin.project.vo.member.ProjectMemberSaveReqVO;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
* 项目创建原子接口请求 VOPOST /project/project/create-with-team
*
* <p>由前端"项目两步向导"一次性提交:项目基础资料 + 初始团队成员。
* 后端必须在同一事务内完成全部写入,任一步失败整体回滚。
*/
@Schema(description = "管理后台 - 项目创建含初始团队Request VO")
@Data
public class ProjectCreateWithTeamReqVO {
/**
* 项目基础资料。沿用 {@link ProjectSaveReqVO} 字段约束(同 POST /create
* 新增场景前端不传 {@code actualStartDate / actualEndDate}(实际日期由项目执行阶段才有值)。
*/
@Schema(description = "项目基础资料", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "项目基础资料不能为空")
@Valid
private ProjectSaveReqVO project;
/**
* 初始团队成员列表。
*
* <p>必须包含一条 {@code userId == project.managerUserId} 的项目经理成员,
* 由前端在打开第 2 步时按 role code = {@code project_manager} 反查 roleId 后聚合提交。
* 后端不再根据 {@code project.managerUserId} 自动追加经理成员。
*/
@Schema(description = "初始团队成员(含项目经理本人)", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty(message = "初始团队成员不能为空")
@Valid
private List<ProjectMemberSaveReqVO> members;
}

View File

@@ -0,0 +1,37 @@
package com.njcn.rdms.module.project.dal.dataobject.attachment;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 附件项。
* <p>
* 既作为业务表 {@code attachments} JSON 列的元素类型,也直接用于 ReqVO/RespVO 透传。
* 文件本身由 rdms-system 的 file 模块上传到对象存储,本对象仅记录元数据。
*/
@Schema(description = "附件项")
@Data
public class AttachmentItem {
@Schema(description = "文件编号,来自 rdms-system 文件上传返回的 data.id。Long 以字符串保存,避免前端精度丢失",
example = "10001")
private String id;
@Schema(description = "文件访问 URLhttp/https长度 ≤ 1024",
requiredMode = Schema.RequiredMode.REQUIRED,
example = "http://oss.example.com/task/2026/05/abc.docx")
private String url;
@Schema(description = "原文件名(含扩展名,长度 ≤ 255",
requiredMode = Schema.RequiredMode.REQUIRED,
example = "需求评审记录.docx")
private String name;
@Schema(description = "文件大小(字节)", example = "245760")
private Long size;
@Schema(description = "MIME Content-Type建议长度 ≤ 128",
example = "application/vnd.openxmlformats-officedocument.wordprocessingml.document")
private String contentType;
}

View File

@@ -9,12 +9,12 @@ import lombok.EqualsAndHashCode;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* 执行成员关系表 * 执行协办人关系表
*/ */
@TableName("rdms_execution_member") @TableName("rdms_execution_assignee")
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class ExecutionMemberDO extends BaseDO { public class ExecutionAssigneeDO extends BaseDO {
/** /**
* 主键编号 * 主键编号
@@ -26,7 +26,7 @@ public class ExecutionMemberDO extends BaseDO {
*/ */
private Long executionId; private Long executionId;
/** /**
* 成员用户编号 * 协办人用户编号
*/ */
private Long userId; private Long userId;
/** /**

View File

@@ -9,13 +9,13 @@ import lombok.EqualsAndHashCode;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* 执行成员变更历史日志B 模型 - 全量事件记录 * 执行协办人变更历史日志B 模型 - 全量事件记录
* 每次 join / inactive / owner_transfer_in / owner_transfer_out 独立成一条记录昵称展示由查询阶段按用户编号回填 * 每次 join / inactive / owner_transfer_in / owner_transfer_out 独立成一条记录昵称展示由查询阶段按用户编号回填
*/ */
@TableName("rdms_execution_member_log") @TableName("rdms_execution_assignee_log")
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class ExecutionMemberLogDO extends BaseDO { public class ExecutionAssigneeLogDO extends BaseDO {
/** /**
* 主键 ID * 主键 ID
@@ -27,7 +27,7 @@ public class ExecutionMemberLogDO extends BaseDO {
*/ */
private Long executionId; private Long executionId;
/** /**
* 被操作的成员用户编号 * 被操作的协办人用户编号
*/ */
private Long userId; private Long userId;
/** /**

View File

@@ -1,18 +1,22 @@
package com.njcn.rdms.module.project.dal.dataobject.project.task; package com.njcn.rdms.module.project.dal.dataobject.project.task;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List;
/** /**
* 项目任务主表。 * 项目任务主表。
*/ */
@TableName("rdms_task") @TableName(value = "rdms_task", autoResultMap = true)
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class ProjectTaskDO extends BaseDO { public class ProjectTaskDO extends BaseDO {
@@ -74,5 +78,11 @@ public class ProjectTaskDO extends BaseDO {
* 最近一次状态动作原因 * 最近一次状态动作原因
*/ */
private String lastStatusReason; private String lastStatusReason;
/**
* 附件列表JSON。元素 {@link AttachmentItem}id / url / name / size / contentType。
* 校验由 {@code AttachmentValidator} 在 Service 入口完成。
*/
@TableField(typeHandler = JacksonTypeHandler.class)
private List<AttachmentItem> attachments;
} }

View File

@@ -1,18 +1,25 @@
package com.njcn.rdms.module.project.dal.dataobject.project.task; package com.njcn.rdms.module.project.dal.dataobject.project.task;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO; import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List;
/** /**
* 任务工时记录表。仅挂在叶子任务上;同一 user × task × work_date 允许多条 * 任务工时记录表。仅挂在叶子任务上;按段记录start_date/end_date 必填,单天=二者相等)
* 时长按分钟存duration_minutes 必须 > 0 且为 30 的整数倍),前端展示为小时 * 同人同任务下日期范围禁止重叠
* 颗粒duration_hours 必须 > 0 且为 0.5 的整数倍。
*/ */
@TableName("rdms_task_worklog") @TableName(value = "rdms_task_worklog", autoResultMap = true)
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class TaskWorklogDO extends BaseDO { public class TaskWorklogDO extends BaseDO {
@@ -31,16 +38,37 @@ public class TaskWorklogDO extends BaseDO {
*/ */
private Long userId; private Long userId;
/** /**
* 工作日期 * 段起始日期(含),单天 = 与 endDate 相等
*/ */
private LocalDate workDate; private LocalDate startDate;
/** /**
* 时长(分钟为单位),必须 > 0 且为 30 的整数倍 * 段结束日期(含),单天 = 与 startDate 相等
*/ */
private Integer durationMinutes; private LocalDate endDate;
/** /**
* 工作内容描述 * 本次填报小时数,必须 > 0 且为 0.5 的整数倍
*/ */
private BigDecimal durationHours;
/**
* 本次填报进度0~100必填
* <ul>
* <li>owner 填报:以 owner 本人最新一条工时(按 end_date desc, create_time desc, id desc为准
* 同步写入 {@code rdms_task.progress_rate} 并触发父任务 AVG 重算。</li>
* <li>协作人填报:仅作为本人自评个人完成度,不影响任务/父任务进度。</li>
* </ul>
*/
private BigDecimal progressRate;
/**
* 工作内容描述。允许在 update 时传 null 清空updateStrategy=ALWAYS 跳过全局 NOT_NULL 策略,
* 始终参与 SQL 拼接,包括 null。调用方约定update 必须全字段回传,不能用 null 表示"未改动"。
*/
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String workContent; private String workContent;
/**
* 附件列表JSON。元素 {@link AttachmentItem}id / url / name / size / contentType。
* 校验由 {@code AttachmentValidator} 在 Service 入口完成。
*/
@TableField(typeHandler = JacksonTypeHandler.class)
private List<AttachmentItem> attachments;
} }

View File

@@ -0,0 +1,30 @@
package com.njcn.rdms.module.project.dal.mysql.project.execution;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogPageReqVO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeLogDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ExecutionAssigneeLogMapper extends BaseMapperX<ExecutionAssigneeLogDO> {
/**
* 分页查询执行协办人变更历史,按 actionTime DESC, id DESC 排序。
* 支持按 actionType[] / userId / 时间范围筛选。
*/
default PageResult<ExecutionAssigneeLogDO> selectPageByExecutionId(Long executionId,
ExecutionAssigneeLogPageReqVO reqVO) {
LambdaQueryWrapperX<ExecutionAssigneeLogDO> queryWrapper = new LambdaQueryWrapperX<ExecutionAssigneeLogDO>()
.eq(ExecutionAssigneeLogDO::getExecutionId, executionId)
.inIfPresent(ExecutionAssigneeLogDO::getActionType, reqVO.getActionTypes())
.eqIfPresent(ExecutionAssigneeLogDO::getUserId, reqVO.getUserId())
.geIfPresent(ExecutionAssigneeLogDO::getActionTime, reqVO.getStartTime())
.leIfPresent(ExecutionAssigneeLogDO::getActionTime, reqVO.getEndTime())
.orderByDesc(ExecutionAssigneeLogDO::getActionTime)
.orderByDesc(ExecutionAssigneeLogDO::getId);
return selectPage(reqVO, queryWrapper);
}
}

View File

@@ -0,0 +1,70 @@
package com.njcn.rdms.module.project.dal.mysql.project.execution;
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.project.execution.ExecutionAssigneeDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ExecutionAssigneeMapper extends BaseMapperX<ExecutionAssigneeDO> {
default List<ExecutionAssigneeDO> selectListByExecutionId(Long executionId) {
return selectList(new LambdaQueryWrapperX<ExecutionAssigneeDO>()
.eq(ExecutionAssigneeDO::getExecutionId, executionId)
.orderByAsc(ExecutionAssigneeDO::getRemovedAt)
.orderByAsc(ExecutionAssigneeDO::getJoinedAt)
.orderByAsc(ExecutionAssigneeDO::getId));
}
/**
* 仅返当前活跃协办人removed_at IS NULL。B 模型下同 userId 至多一段未失效。
*/
default List<ExecutionAssigneeDO> selectActiveListByExecutionId(Long executionId) {
return selectList(new LambdaQueryWrapperX<ExecutionAssigneeDO>()
.eq(ExecutionAssigneeDO::getExecutionId, executionId)
.isNull(ExecutionAssigneeDO::getRemovedAt)
.orderByAsc(ExecutionAssigneeDO::getJoinedAt)
.orderByAsc(ExecutionAssigneeDO::getId));
}
default ExecutionAssigneeDO selectByExecutionIdAndUserId(Long executionId, Long userId) {
return selectOne(new LambdaQueryWrapperX<ExecutionAssigneeDO>()
.eq(ExecutionAssigneeDO::getExecutionId, executionId)
.eq(ExecutionAssigneeDO::getUserId, userId));
}
default ExecutionAssigneeDO selectByIdAndExecutionId(Long id, Long executionId) {
return selectOne(new LambdaQueryWrapperX<ExecutionAssigneeDO>()
.eq(ExecutionAssigneeDO::getId, id)
.eq(ExecutionAssigneeDO::getExecutionId, executionId));
}
default ExecutionAssigneeDO selectActiveByExecutionIdAndUserId(Long executionId, Long userId) {
return selectOne(new LambdaQueryWrapperX<ExecutionAssigneeDO>()
.eq(ExecutionAssigneeDO::getExecutionId, executionId)
.eq(ExecutionAssigneeDO::getUserId, userId)
.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);
}

View File

@@ -1,30 +0,0 @@
package com.njcn.rdms.module.project.dal.mysql.project.execution;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberLogPageReqVO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberLogDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ExecutionMemberLogMapper extends BaseMapperX<ExecutionMemberLogDO> {
/**
* 分页查询执行成员变更历史,按 actionTime DESC, id DESC 排序。
* 支持按 actionType[] / userId / 时间范围筛选。
*/
default PageResult<ExecutionMemberLogDO> selectPageByExecutionId(Long executionId,
ExecutionMemberLogPageReqVO reqVO) {
LambdaQueryWrapperX<ExecutionMemberLogDO> queryWrapper = new LambdaQueryWrapperX<ExecutionMemberLogDO>()
.eq(ExecutionMemberLogDO::getExecutionId, executionId)
.inIfPresent(ExecutionMemberLogDO::getActionType, reqVO.getActionTypes())
.eqIfPresent(ExecutionMemberLogDO::getUserId, reqVO.getUserId())
.geIfPresent(ExecutionMemberLogDO::getActionTime, reqVO.getStartTime())
.leIfPresent(ExecutionMemberLogDO::getActionTime, reqVO.getEndTime())
.orderByDesc(ExecutionMemberLogDO::getActionTime)
.orderByDesc(ExecutionMemberLogDO::getId);
return selectPage(reqVO, queryWrapper);
}
}

View File

@@ -1,51 +0,0 @@
package com.njcn.rdms.module.project.dal.mysql.project.execution;
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.project.execution.ExecutionMemberDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface ExecutionMemberMapper extends BaseMapperX<ExecutionMemberDO> {
default List<ExecutionMemberDO> selectListByExecutionId(Long executionId) {
return selectList(new LambdaQueryWrapperX<ExecutionMemberDO>()
.eq(ExecutionMemberDO::getExecutionId, executionId)
.orderByAsc(ExecutionMemberDO::getRemovedAt)
.orderByAsc(ExecutionMemberDO::getJoinedAt)
.orderByAsc(ExecutionMemberDO::getId));
}
/**
* 仅返当前活跃成员removed_at IS NULL。B 模型下同 userId 至多一段未失效。
*/
default List<ExecutionMemberDO> selectActiveListByExecutionId(Long executionId) {
return selectList(new LambdaQueryWrapperX<ExecutionMemberDO>()
.eq(ExecutionMemberDO::getExecutionId, executionId)
.isNull(ExecutionMemberDO::getRemovedAt)
.orderByAsc(ExecutionMemberDO::getJoinedAt)
.orderByAsc(ExecutionMemberDO::getId));
}
default ExecutionMemberDO selectByExecutionIdAndUserId(Long executionId, Long userId) {
return selectOne(new LambdaQueryWrapperX<ExecutionMemberDO>()
.eq(ExecutionMemberDO::getExecutionId, executionId)
.eq(ExecutionMemberDO::getUserId, userId));
}
default ExecutionMemberDO selectByIdAndExecutionId(Long id, Long executionId) {
return selectOne(new LambdaQueryWrapperX<ExecutionMemberDO>()
.eq(ExecutionMemberDO::getId, id)
.eq(ExecutionMemberDO::getExecutionId, executionId));
}
default ExecutionMemberDO selectActiveByExecutionIdAndUserId(Long executionId, Long userId) {
return selectOne(new LambdaQueryWrapperX<ExecutionMemberDO>()
.eq(ExecutionMemberDO::getExecutionId, executionId)
.eq(ExecutionMemberDO::getUserId, userId)
.isNull(ExecutionMemberDO::getRemovedAt));
}
}

View File

@@ -7,6 +7,7 @@ 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.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -27,21 +28,44 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
.eq(ProjectExecutionDO::getExecutionName, executionName)); .eq(ProjectExecutionDO::getExecutionName, executionName));
} }
default PageResult<ProjectExecutionDO> selectPageByProjectId(Long projectId, ProjectExecutionPageReqVO reqVO) { default PageResult<ProjectExecutionDO> selectPageByProjectId(Long projectId,
VisibilityScope scope,
ProjectExecutionPageReqVO reqVO) {
// 可见性短路:非 seesAll 且无任何可见执行 → 空页,避免后续 IN () SQL
if (!scope.seesAll() && scope.executionIds().isEmpty()) {
return PageResult.empty();
}
LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<ProjectExecutionDO>() LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<ProjectExecutionDO>()
.eq(ProjectExecutionDO::getProjectId, projectId) .eq(ProjectExecutionDO::getProjectId, projectId)
.eqIfPresent(ProjectExecutionDO::getExecutionType, reqVO.getExecutionType()) .eqIfPresent(ProjectExecutionDO::getExecutionType, reqVO.getExecutionType())
.eqIfPresent(ProjectExecutionDO::getOwnerId, reqVO.getOwnerId()) .eqIfPresent(ProjectExecutionDO::getOwnerId, reqVO.getOwnerId())
.eqIfPresent(ProjectExecutionDO::getStatusCode, reqVO.getStatusCode()) .eqIfPresent(ProjectExecutionDO::getStatusCode, reqVO.getStatusCode())
.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime()) .betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime())
.orderByDesc(BaseDO::getUpdateTime) .orderByDesc(BaseDO::getCreateTime)
.orderByDesc(ProjectExecutionDO::getId); .orderByDesc(ProjectExecutionDO::getId);
if (StringUtils.hasText(reqVO.getKeyword())) { if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectExecutionDO::getExecutionName, reqVO.getKeyword())); queryWrapper.and(wrapper -> wrapper.like(ProjectExecutionDO::getExecutionName, reqVO.getKeyword()));
} }
if (!scope.seesAll()) {
queryWrapper.in(ProjectExecutionDO::getId, scope.executionIds());
}
return selectPage(reqVO, queryWrapper); return selectPage(reqVO, queryWrapper);
} }
/**
* 查 userId 在指定项目下,作为 owner 的所有执行 ID。
* 用于 VisibilityScopeResolver 收集"我是执行负责人"的 scope 来源。
*/
default List<Long> selectIdsByProjectIdAndOwnerId(Long projectId, Long userId) {
return selectList(new LambdaQueryWrapperX<ProjectExecutionDO>()
.select(ProjectExecutionDO::getId)
.eq(ProjectExecutionDO::getProjectId, projectId)
.eq(ProjectExecutionDO::getOwnerId, userId))
.stream()
.map(ProjectExecutionDO::getId)
.toList();
}
default Integer countNonTerminalByProjectIdAndOwnerId(Long projectId, Long ownerId, List<String> terminalStatusCodes) { default Integer countNonTerminalByProjectIdAndOwnerId(Long projectId, Long ownerId, List<String> terminalStatusCodes) {
LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<ProjectExecutionDO>() LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<ProjectExecutionDO>()
.eq(ProjectExecutionDO::getProjectId, projectId) .eq(ProjectExecutionDO::getProjectId, projectId)
@@ -52,7 +76,14 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
return Math.toIntExact(selectCount(queryWrapper)); return Math.toIntExact(selectCount(queryWrapper));
} }
default Integer countByProjectIdAndStatusCode(Long projectId, ProjectExecutionStatusBoardReqVO reqVO, String statusCode) { default Integer countByProjectIdAndStatusCode(Long projectId,
VisibilityScope scope,
ProjectExecutionStatusBoardReqVO reqVO,
String statusCode) {
// 可见性短路:非 seesAll 且无任何可见执行 → 计数 0
if (!scope.seesAll() && scope.executionIds().isEmpty()) {
return 0;
}
LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<ProjectExecutionDO>() LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<ProjectExecutionDO>()
.eq(ProjectExecutionDO::getProjectId, projectId) .eq(ProjectExecutionDO::getProjectId, projectId)
.eq(ProjectExecutionDO::getStatusCode, statusCode) .eq(ProjectExecutionDO::getStatusCode, statusCode)
@@ -62,6 +93,9 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
if (StringUtils.hasText(reqVO.getKeyword())) { if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectExecutionDO::getExecutionName, 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)); return Math.toIntExact(selectCount(queryWrapper));
} }
@@ -74,4 +108,14 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
.eq(ProjectExecutionDO::getStatusCode, fromStatus)); .eq(ProjectExecutionDO::getStatusCode, fromStatus));
} }
/**
* 软删执行(按状态 CAS。仅在 statusCode 与传入 fromStatus 匹配时才标 deleted=1。
* 返回 1 表示成功;返回 0 视为并发修改。
*/
default int deleteByIdAndStatus(Long id, String fromStatus) {
return delete(new LambdaQueryWrapperX<ProjectExecutionDO>()
.eq(ProjectExecutionDO::getId, id)
.eq(ProjectExecutionDO::getStatusCode, fromStatus));
}
} }

View File

@@ -7,13 +7,18 @@ 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.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 org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map;
@Mapper @Mapper
public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> { public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
@@ -25,7 +30,13 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
.eq(ProjectTaskDO::getId, taskId)); .eq(ProjectTaskDO::getId, taskId));
} }
default PageResult<ProjectTaskDO> selectPageByExecutionId(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) { default PageResult<ProjectTaskDO> selectPageByExecutionId(Long projectId, Long executionId,
VisibilityScope scope,
ProjectTaskPageReqVO reqVO) {
// 可见性短路:非 seesAll 且无任何可见任务 → 空页
if (!scope.seesAll() && scope.taskIds().isEmpty()) {
return PageResult.empty();
}
LambdaQueryWrapperX<ProjectTaskDO> queryWrapper = new LambdaQueryWrapperX<>(); LambdaQueryWrapperX<ProjectTaskDO> queryWrapper = new LambdaQueryWrapperX<>();
queryWrapper.eq(ProjectTaskDO::getProjectId, projectId); queryWrapper.eq(ProjectTaskDO::getProjectId, projectId);
queryWrapper.eq(ProjectTaskDO::getExecutionId, executionId); queryWrapper.eq(ProjectTaskDO::getExecutionId, executionId);
@@ -34,11 +45,14 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
queryWrapper.eqIfPresent(ProjectTaskDO::getStatusCode, reqVO.getStatusCode()); queryWrapper.eqIfPresent(ProjectTaskDO::getStatusCode, reqVO.getStatusCode());
queryWrapper.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime()); queryWrapper.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime());
queryWrapper.orderByAsc(ProjectTaskDO::getParentTaskId); queryWrapper.orderByAsc(ProjectTaskDO::getParentTaskId);
queryWrapper.orderByDesc(BaseDO::getUpdateTime); queryWrapper.orderByDesc(BaseDO::getCreateTime);
queryWrapper.orderByDesc(ProjectTaskDO::getId); queryWrapper.orderByDesc(ProjectTaskDO::getId);
if (StringUtils.hasText(reqVO.getKeyword())) { if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectTaskDO::getTaskTitle, reqVO.getKeyword())); queryWrapper.and(wrapper -> wrapper.like(ProjectTaskDO::getTaskTitle, reqVO.getKeyword()));
} }
if (!scope.seesAll()) {
queryWrapper.in(ProjectTaskDO::getId, scope.taskIds());
}
return selectPage(reqVO, queryWrapper); return selectPage(reqVO, queryWrapper);
} }
@@ -51,6 +65,16 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
.eq(ProjectTaskDO::getStatusCode, fromStatus)); .eq(ProjectTaskDO::getStatusCode, fromStatus));
} }
/**
* 软删任务(按状态 CAS。仅在 statusCode 与传入 fromStatus 匹配时才标 deleted=1。
* 返回 1 表示成功;返回 0 视为并发修改。
*/
default int deleteByIdAndStatus(Long id, String fromStatus) {
return delete(new LambdaQueryWrapperX<ProjectTaskDO>()
.eq(ProjectTaskDO::getId, id)
.eq(ProjectTaskDO::getStatusCode, fromStatus));
}
/** /**
* 仅更新实际开始/结束日期。null 字段依据全局 FieldStrategy 不会被覆盖。 * 仅更新实际开始/结束日期。null 字段依据全局 FieldStrategy 不会被覆盖。
*/ */
@@ -74,6 +98,18 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
return Math.toIntExact(selectCount(queryWrapper)); return Math.toIntExact(selectCount(queryWrapper));
} }
/**
* 统计指定执行下处于非终态的任务数。用于执行 complete 前置校验(要求所有任务必须终态)。
*/
default Integer countByExecutionIdNotInStatus(Long executionId, List<String> terminalStatusCodes) {
LambdaQueryWrapperX<ProjectTaskDO> queryWrapper = new LambdaQueryWrapperX<ProjectTaskDO>()
.eq(ProjectTaskDO::getExecutionId, executionId);
if (terminalStatusCodes != null && !terminalStatusCodes.isEmpty()) {
queryWrapper.notIn(ProjectTaskDO::getStatusCode, terminalStatusCodes);
}
return Math.toIntExact(selectCount(queryWrapper));
}
/** /**
* 数指定父任务下的直接子任务(不区分状态)。用于"是否叶子任务"判定与进度汇总。 * 数指定父任务下的直接子任务(不区分状态)。用于"是否叶子任务"判定与进度汇总。
*/ */
@@ -98,6 +134,39 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
.eq(ProjectTaskDO::getParentTaskId, parentTaskId)); .eq(ProjectTaskDO::getParentTaskId, parentTaskId));
} }
/**
* 执行详情进度:按当前执行下一级任务 progressRate 简单平均;无一级任务时 SQL 返回 null。
*/
@Select("""
SELECT AVG(COALESCE(progress_rate, 0))
FROM rdms_task
WHERE deleted = b'0'
AND project_id = #{projectId}
AND execution_id = #{executionId}
AND parent_task_id IS NULL
""")
BigDecimal selectRootTaskAvgProgressByExecutionId(@Param("projectId") Long projectId,
@Param("executionId") Long executionId);
/**
* 执行分页进度:按当前页 executionId 批量聚合一级任务 progressRate避免列表 N+1。
*/
@Select("""
<script>
SELECT execution_id AS executionId, AVG(COALESCE(progress_rate, 0)) AS progressRate
FROM rdms_task
WHERE deleted = b'0'
AND project_id = #{projectId}
AND execution_id IN
<foreach collection="executionIds" item="id" open="(" separator="," close=")">#{id}</foreach>
AND parent_task_id IS NULL
GROUP BY execution_id
</script>
""")
List<Map<String, Object>> selectRootTaskAvgProgressGroupByExecutionIds(
@Param("projectId") Long projectId,
@Param("executionIds") Collection<Long> executionIds);
/** /**
* 仅更新单个任务的 progressRate不动其他字段避免污染 lastStatusReason 等)。 * 仅更新单个任务的 progressRate不动其他字段避免污染 lastStatusReason 等)。
*/ */
@@ -108,9 +177,62 @@ 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
if (!scope.seesAll() && scope.taskIds().isEmpty()) {
return 0;
}
LambdaQueryWrapperX<ProjectTaskDO> queryWrapper = new LambdaQueryWrapperX<ProjectTaskDO>() LambdaQueryWrapperX<ProjectTaskDO> queryWrapper = new LambdaQueryWrapperX<ProjectTaskDO>()
.eq(ProjectTaskDO::getProjectId, projectId) .eq(ProjectTaskDO::getProjectId, projectId)
.eq(ProjectTaskDO::getExecutionId, executionId) .eq(ProjectTaskDO::getExecutionId, executionId)
@@ -121,6 +243,9 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
if (StringUtils.hasText(reqVO.getKeyword())) { if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectTaskDO::getTaskTitle, 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)); return Math.toIntExact(selectCount(queryWrapper));
} }

View File

@@ -4,6 +4,8 @@ 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;
@@ -48,6 +50,41 @@ 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

@@ -9,6 +9,8 @@ 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 java.math.BigDecimal;
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;
@@ -27,35 +29,40 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
} }
/** /**
* 任务工时分页:按 workDate DESC, id DESC支持按填报人 / 日期区间筛选 * 任务工时分页:按 endDate DESC, id DESC支持按填报人 / 段相交过滤
* 段相交语义record.startDate <= filter.endDate AND record.endDate >= filter.startDate。
*/ */
default PageResult<TaskWorklogDO> selectPageByTaskId(Long taskId, TaskWorklogPageReqVO reqVO) { default PageResult<TaskWorklogDO> selectPageByTaskId(Long taskId, TaskWorklogPageReqVO reqVO) {
LambdaQueryWrapperX<TaskWorklogDO> queryWrapper = new LambdaQueryWrapperX<TaskWorklogDO>() LambdaQueryWrapperX<TaskWorklogDO> queryWrapper = new LambdaQueryWrapperX<TaskWorklogDO>()
.eq(TaskWorklogDO::getTaskId, taskId) .eq(TaskWorklogDO::getTaskId, taskId)
.eqIfPresent(TaskWorklogDO::getUserId, reqVO.getUserId()) .eqIfPresent(TaskWorklogDO::getUserId, reqVO.getUserId());
.geIfPresent(TaskWorklogDO::getWorkDate, reqVO.getStartDate()) if (reqVO.getEndDate() != null) {
.leIfPresent(TaskWorklogDO::getWorkDate, reqVO.getEndDate()) queryWrapper.le(TaskWorklogDO::getStartDate, reqVO.getEndDate());
.orderByDesc(TaskWorklogDO::getWorkDate) }
if (reqVO.getStartDate() != null) {
queryWrapper.ge(TaskWorklogDO::getEndDate, reqVO.getStartDate());
}
queryWrapper.orderByDesc(TaskWorklogDO::getEndDate)
.orderByDesc(TaskWorklogDO::getId); .orderByDesc(TaskWorklogDO::getId);
return selectPage(reqVO, queryWrapper); return selectPage(reqVO, queryWrapper);
} }
/** /**
* 单任务工时汇总(分钟)。无记录时返回 0逻辑删除的记录不参与汇总。 * 单任务工时小时数汇总。无记录时返回 0逻辑删除的记录不参与汇总。
*/ */
@Select(""" @Select("""
SELECT COALESCE(SUM(duration_minutes), 0) SELECT COALESCE(SUM(duration_hours), 0)
FROM rdms_task_worklog FROM rdms_task_worklog
WHERE deleted = b'0' AND task_id = #{taskId} WHERE deleted = b'0' AND task_id = #{taskId}
""") """)
Long sumDurationByTaskId(@Param("taskId") Long taskId); BigDecimal sumDurationByTaskId(@Param("taskId") Long taskId);
/** /**
* 批量任务工时汇总(分钟),返回 [{taskId, total}]。用于详情/分页装配避免 N+1。 * 批量任务工时小时数汇总,返回 [{taskId, total}]。用于详情/分页装配避免 N+1。
*/ */
@Select(""" @Select("""
<script> <script>
SELECT task_id AS taskId, COALESCE(SUM(duration_minutes), 0) AS total SELECT task_id AS taskId, COALESCE(SUM(duration_hours), 0) AS total
FROM rdms_task_worklog FROM rdms_task_worklog
WHERE deleted = b'0' AND task_id IN WHERE deleted = b'0' AND task_id IN
<foreach collection="taskIds" item="id" open="(" separator="," close=")">#{id}</foreach> <foreach collection="taskIds" item="id" open="(" separator="," close=")">#{id}</foreach>
@@ -75,4 +82,87 @@ public interface TaskWorklogMapper extends BaseMapperX<TaskWorklogDO> {
.eq(TaskWorklogDO::getTaskId, taskId)) > 0; .eq(TaskWorklogDO::getTaskId, taskId)) > 0;
} }
/**
* 取指定用户在该任务下最新的一条工时(按 end_date desc, create_time desc, id desc
* 用于 owner 填报后回查"本人最新一条"以同步任务进度。
* 逻辑删除的记录由 BaseMapper 自动过滤;无记录返回 null。
*/
default TaskWorklogDO selectLatestByTaskIdAndUserId(Long taskId, Long userId) {
if (taskId == null || userId == null) {
return null;
}
List<TaskWorklogDO> list = selectList(new LambdaQueryWrapperX<TaskWorklogDO>()
.eq(TaskWorklogDO::getTaskId, taskId)
.eq(TaskWorklogDO::getUserId, userId)
.orderByDesc(TaskWorklogDO::getEndDate)
.orderByDesc(TaskWorklogDO::getCreateTime)
.orderByDesc(TaskWorklogDO::getId)
.last("LIMIT 1"));
return list.isEmpty() ? null : list.get(0);
}
/**
* 取该 (taskId, userId) 下 endDate 严格早于给定值的"最近一条",按 end_date desc, id desc 取首行。
* 用于进度单调性校验(与"前一段"比较。excludeId 不为 null 时排除自身(更新场景)。
*/
default TaskWorklogDO selectPrevByEndDate(Long taskId, Long userId, LocalDate endDate, Long excludeId) {
if (taskId == null || userId == null || endDate == null) {
return null;
}
LambdaQueryWrapperX<TaskWorklogDO> queryWrapper = new LambdaQueryWrapperX<>();
queryWrapper.eq(TaskWorklogDO::getTaskId, taskId)
.eq(TaskWorklogDO::getUserId, userId)
.lt(TaskWorklogDO::getEndDate, endDate);
if (excludeId != null) {
queryWrapper.ne(TaskWorklogDO::getId, excludeId);
}
queryWrapper.orderByDesc(TaskWorklogDO::getEndDate)
.orderByDesc(TaskWorklogDO::getId)
.last("LIMIT 1");
List<TaskWorklogDO> list = selectList(queryWrapper);
return list.isEmpty() ? null : list.get(0);
}
/**
* 取该 (taskId, userId) 下 endDate 严格晚于给定值的"最近一条",按 end_date asc, id asc 取首行。
* 用于进度单调性校验(与"后一段"比较。excludeId 不为 null 时排除自身(更新场景)。
*/
default TaskWorklogDO selectNextByEndDate(Long taskId, Long userId, LocalDate endDate, Long excludeId) {
if (taskId == null || userId == null || endDate == null) {
return null;
}
LambdaQueryWrapperX<TaskWorklogDO> queryWrapper = new LambdaQueryWrapperX<>();
queryWrapper.eq(TaskWorklogDO::getTaskId, taskId)
.eq(TaskWorklogDO::getUserId, userId)
.gt(TaskWorklogDO::getEndDate, endDate);
if (excludeId != null) {
queryWrapper.ne(TaskWorklogDO::getId, excludeId);
}
queryWrapper.orderByAsc(TaskWorklogDO::getEndDate)
.orderByAsc(TaskWorklogDO::getId)
.last("LIMIT 1");
List<TaskWorklogDO> list = selectList(queryWrapper);
return list.isEmpty() ? null : list.get(0);
}
/**
* 是否存在与指定 [startDate, endDate] 区间相交的、属于该 task × user 的工时记录。
* 段相交语义record.startDate <= endDate AND record.endDate >= startDate。
* 用于禁止重叠校验excludeId 不为 null 时排除该记录自身(更新场景用)。
*/
default boolean existsOverlapping(Long taskId, Long userId, LocalDate startDate, LocalDate endDate, Long excludeId) {
if (taskId == null || userId == null || startDate == null || endDate == null) {
return false;
}
LambdaQueryWrapperX<TaskWorklogDO> queryWrapper = new LambdaQueryWrapperX<>();
queryWrapper.eq(TaskWorklogDO::getTaskId, taskId)
.eq(TaskWorklogDO::getUserId, userId)
.le(TaskWorklogDO::getStartDate, endDate)
.ge(TaskWorklogDO::getEndDate, startDate);
if (excludeId != null) {
queryWrapper.ne(TaskWorklogDO::getId, excludeId);
}
return selectCount(queryWrapper) > 0;
}
} }

View File

@@ -0,0 +1,40 @@
package com.njcn.rdms.module.project.framework.attachment;
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
import com.njcn.rdms.module.system.api.file.FileApi;
import com.njcn.rdms.module.system.api.file.dto.FileRespDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.List;
/**
* 附件文件编号补齐器。
*
* <p>业务附件 JSON 以 rdms-system 的文件记录 ID 作为下载、删除的稳定入口。
* 为兼容前端只回传 url 的场景,保存前尝试按 url 反查文件记录并补齐 id。</p>
*/
@Component
public class AttachmentFileIdResolver {
@Resource
private FileApi fileApi;
public void resolve(List<AttachmentItem> attachments) {
if (attachments == null || attachments.isEmpty()) {
return;
}
for (AttachmentItem attachment : attachments) {
if (attachment == null || StringUtils.hasText(attachment.getId())
|| !StringUtils.hasText(attachment.getUrl())) {
continue;
}
FileRespDTO file = fileApi.getFileByUrl(attachment.getUrl()).getCheckedData();
if (file != null && file.getId() != null) {
attachment.setId(String.valueOf(file.getId()));
}
}
}
}

View File

@@ -0,0 +1,98 @@
package com.njcn.rdms.module.project.framework.attachment;
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
* 附件统一校验。任务、工时(以及未来的产品需求 / 项目需求 / 工单)共用。
* <p>
* 规则:
* <ul>
* <li>列表数量 ≤ {@link #MAX_COUNT}</li>
* <li>id允许为空新上传文件建议传 rdms-system 文件上传返回的 data.id</li>
* <li>url非空、{@code http://} 或 {@code https://} 开头、长度 ≤ {@link #MAX_URL_LENGTH}</li>
* <li>name非空、长度 ≤ {@link #MAX_NAME_LENGTH}</li>
* <li>扩展名(取自 name 最末一段,小写比对)必须在 {@link #ALLOWED_EXTENSIONS},且不能在 {@link #BLOCKED_EXTENSIONS}</li>
* </ul>
* 单文件大小限制依赖 {@code /system/file/upload} 上传时拦截,本类不复核。
*/
public final class AttachmentValidator {
public static final int MAX_COUNT = 20;
public static final int MAX_URL_LENGTH = 1024;
public static final int MAX_NAME_LENGTH = 255;
/** 允许的扩展名白名单(小写,无 {@code .})。 */
public static final Set<String> ALLOWED_EXTENSIONS = Set.of(
// 文档
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md", "csv",
// 图片
"jpg", "jpeg", "png", "gif", "webp", "bmp",
// 压缩
"zip", "rar", "7z",
// 媒体
"mp4", "mp3"
);
/** 禁止的扩展名黑名单(即使在白名单也禁,兜底防可执行 / 脚本类)。 */
public static final Set<String> BLOCKED_EXTENSIONS = Set.of(
"exe", "bat", "cmd", "sh", "ps1", "msi", "dll", "jar", "war",
"php", "jsp", "asp", "aspx", "py", "rb", "pl",
"com", "scr", "vbs", "js"
);
private AttachmentValidator() {
}
public static void validate(List<AttachmentItem> attachments) {
if (attachments == null || attachments.isEmpty()) {
return;
}
if (attachments.size() > MAX_COUNT) {
throw exception(ErrorCodeConstants.PROJECT_TASK_ATTACHMENT_TOO_MANY, MAX_COUNT);
}
for (AttachmentItem item : attachments) {
validateItem(item);
}
}
private static void validateItem(AttachmentItem item) {
if (item == null) {
throw exception(ErrorCodeConstants.PROJECT_TASK_ATTACHMENT_NAME_INVALID);
}
// url协议 + 长度
String url = item.getUrl();
if (!StringUtils.hasText(url) || url.length() > MAX_URL_LENGTH
|| !(url.startsWith("http://") || url.startsWith("https://"))) {
throw exception(ErrorCodeConstants.PROJECT_TASK_ATTACHMENT_URL_INVALID);
}
// name非空 + 长度
String name = item.getName();
if (!StringUtils.hasText(name) || name.length() > MAX_NAME_LENGTH) {
throw exception(ErrorCodeConstants.PROJECT_TASK_ATTACHMENT_NAME_INVALID);
}
// 扩展名:黑名单优先,再过白名单
String ext = extractExtension(name);
if (BLOCKED_EXTENSIONS.contains(ext)) {
throw exception(ErrorCodeConstants.PROJECT_TASK_ATTACHMENT_TYPE_BLOCKED, ext);
}
if (!ALLOWED_EXTENSIONS.contains(ext)) {
throw exception(ErrorCodeConstants.PROJECT_TASK_ATTACHMENT_TYPE_NOT_ALLOWED, ext);
}
}
private static String extractExtension(String name) {
int dot = name.lastIndexOf('.');
if (dot < 0 || dot == name.length() - 1) {
return "";
}
return name.substring(dot + 1).toLowerCase(Locale.ROOT);
}
}

View File

@@ -1,6 +1,7 @@
package com.njcn.rdms.module.project.framework.rpc.config; package com.njcn.rdms.module.project.framework.rpc.config;
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.file.FileApi;
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.user.AdminUserApi; import com.njcn.rdms.module.system.api.user.AdminUserApi;
import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.cloud.openfeign.EnableFeignClients;
@@ -10,6 +11,6 @@ import org.springframework.context.annotation.Configuration;
* Project 模块的 RPC 配置 * Project 模块的 RPC 配置
*/ */
@Configuration(value = "projectRpcConfiguration", proxyBeanMethods = false) @Configuration(value = "projectRpcConfiguration", proxyBeanMethods = false)
@EnableFeignClients(clients = {AdminUserApi.class, ObjectPermissionApi.class, DictDataApi.class}) @EnableFeignClients(clients = {AdminUserApi.class, ObjectPermissionApi.class, DictDataApi.class, FileApi.class})
public class RpcConfiguration { public class RpcConfiguration {
} }

View File

@@ -0,0 +1,29 @@
package com.njcn.rdms.module.project.framework.security.service;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.Objects;
/**
* 项目对象业务授权服务。
*/
@Service
public class ProjectObjectAuthorizationService {
@Resource
private ProjectObjectPermissionService projectObjectPermissionService;
/**
* 当前登录人是业务负责人时直接放行,否则回落到项目对象角色权限校验。
*/
public void checkOwnerOrProjectPermission(Long projectId, Long ownerUserId, String permission) {
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
if (Objects.equals(loginUserId, ownerUserId)) {
return;
}
projectObjectPermissionService.checkPermission(projectId, permission, false);
}
}

View File

@@ -2,6 +2,7 @@ package com.njcn.rdms.module.project.service.product;
import com.njcn.rdms.framework.common.pojo.PageResult; import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductCreateWithTeamReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductOverviewSummaryRespVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductOverviewSummaryRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
@@ -23,6 +24,22 @@ public interface ProductService {
*/ */
Long createProduct(ProductSaveReqVO createReqVO); Long createProduct(ProductSaveReqVO createReqVO);
/**
* 创建产品并初始化团队(原子接口)。
*
* <p>由前端"产品两步向导"一次性提交,产品基础资料与全部初始团队成员
* 必须在同一事务内完成写入;任一步失败整体回滚。
*
* <p>与 {@link #createProduct(ProductSaveReqVO)} 的关键差异:经理成员
* 由前端在 {@code members} 中显式提交,后端不再根据
* {@code product.managerUserId} 自动追加,因此需要校验
* {@code members} 中必须存在 userId 等于 managerUserId 的记录。
*
* @param reqVO 创建请求(产品 + 初始团队)
* @return 产品编号
*/
Long createProductWithTeam(ProductCreateWithTeamReqVO reqVO);
/** /**
* 更新产品 * 更新产品
* *

View File

@@ -8,10 +8,12 @@ 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.ProductObjectConstants;
import com.njcn.rdms.module.project.controller.admin.product.vo.member.ProductMemberSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextNavRespVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextNavRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextProductRespVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextProductRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRoleRespVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRoleRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductCreateWithTeamReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductOverviewSummaryRespVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductOverviewSummaryRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO; import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
@@ -103,6 +105,109 @@ public class ProductServiceImpl implements ProductService {
return product.getId(); return product.getId();
} }
@Override
@Transactional(rollbackFor = Exception.class)
public Long createProductWithTeam(ProductCreateWithTeamReqVO reqVO) {
ProductSaveReqVO productReq = reqVO.getProduct();
List<ProductMemberSaveReqVO> members = reqVO.getMembers();
// 1) 复用旧 create 接口的产品资料校验(名称 / 编码唯一性、产品经理用户存在)
validateCreateReqVO(productReq);
validateManagerUser(productReq.getManagerUserId());
// 2) 初始团队校验包含经理、userId 不重复、roleId 合法(含经理那条必须为产品经理角色)
validateInitialMembers(productReq.getManagerUserId(), members);
// 3) 写入产品基础资料:与 createProduct 完全一致,唯一差异是不调用 initManagerMemberRelation —— 经理成员由 members 显式提供,避免重复插入
ProductDO product = new ProductDO();
product.setCode(generateProductCode(productReq.getCode()));
product.setDirectionCode(productReq.getDirectionCode());
String initialStatus = getInitialStatusCode();
product.setStatusCode(initialStatus);
product.setName(productReq.getName().trim());
product.setManagerUserId(productReq.getManagerUserId());
product.setDescription(normalizeNullableText(productReq.getDescription()));
productMapper.insert(product);
initDefaultRequirementModule(product);
// 4) 遍历写入初始成员含成员维度审计roleMap 在校验阶段已确保合法
LocalDateTime now = LocalDateTime.now();
for (ProductMemberSaveReqVO memberReq : members) {
UserObjectRoleDO member = new UserObjectRoleDO();
member.setUserId(memberReq.getUserId());
member.setObjectType(ProductObjectConstants.OBJECT_TYPE);
member.setObjectId(product.getId());
member.setRoleId(memberReq.getRoleId());
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
member.setJoinedTime(now);
member.setLeftTime(null);
member.setRemark(normalizeNullableText(memberReq.getRemark()));
userObjectRoleMapper.insert(member);
writeMemberInitAuditLog(member);
}
// 5) 产品维度的"指定经理"审计:与旧 initManagerMemberRelation 末尾一致,保留活动时间线一致性
writeManagerInitAuditLog(product.getId(), product.getManagerUserId());
// 6) 产品创建审计
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_CREATE, null, initialStatus,
buildProductFieldChanges(null, product), null);
return product.getId();
}
/**
* 校验初始团队成员列表。
*
* <p>校验维度:
* <ol>
* <li>members 中 userId 不可重复</li>
* <li>members 必须包含产品经理本人userId == managerUserId</li>
* <li>所有 roleId 必须落在 scopeType=object, objectType=product 且启用</li>
* <li>经理那条 members 的 roleId 必须是 {@code product_manager} 角色(数据一致性兜底)</li>
* </ol>
*/
@VisibleForTesting
void validateInitialMembers(Long managerUserId, List<ProductMemberSaveReqVO> members) {
if (members == null || members.isEmpty()) {
// @NotEmpty 已挡,此处兜底
throw exception(ErrorCodeConstants.PRODUCT_INITIAL_TEAM_MANAGER_REQUIRED);
}
Set<Long> userIds = new HashSet<>();
for (ProductMemberSaveReqVO m : members) {
if (!userIds.add(m.getUserId())) {
throw exception(ErrorCodeConstants.PRODUCT_INITIAL_TEAM_MEMBER_DUPLICATE);
}
}
if (!userIds.contains(managerUserId)) {
throw exception(ErrorCodeConstants.PRODUCT_INITIAL_TEAM_MANAGER_REQUIRED);
}
Set<Long> roleIds = members.stream().map(ProductMemberSaveReqVO::getRoleId).collect(Collectors.toSet());
List<ObjectRoleRespDTO> roles = objectPermissionApi
.getObjectRoleList(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProductObjectConstants.OBJECT_TYPE)
.getCheckedData();
Map<Long, ObjectRoleRespDTO> roleMap = roles == null ? Collections.emptyMap()
: roles.stream().collect(Collectors.toMap(ObjectRoleRespDTO::getId, Function.identity()));
for (Long roleId : roleIds) {
if (!roleMap.containsKey(roleId)) {
throw exception(ErrorCodeConstants.PRODUCT_INITIAL_TEAM_ROLE_INVALID);
}
}
// 经理那条 members 的 roleId 必须对应 product_manager 角色,否则 product.managerUserId 与成员表角色语义会错乱
ProductMemberSaveReqVO managerMember = members.stream()
.filter(m -> Objects.equals(m.getUserId(), managerUserId))
.findFirst()
.orElseThrow(() -> exception(ErrorCodeConstants.PRODUCT_INITIAL_TEAM_MANAGER_REQUIRED));
ObjectRoleRespDTO managerRole = roleMap.get(managerMember.getRoleId());
if (managerRole == null
|| !Objects.equals(ProductObjectConstants.MANAGER_ROLE_CODE, managerRole.getCode())) {
throw exception(ErrorCodeConstants.PRODUCT_INITIAL_TEAM_ROLE_INVALID);
}
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#updateReqVO.id", @CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#updateReqVO.id",

View File

@@ -2,6 +2,7 @@ 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.module.project.controller.admin.project.vo.project.ProjectContextRespVO; import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectCreateWithTeamReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectDeleteReqVO; import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO; import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
@@ -19,6 +20,26 @@ public interface ProjectService {
Long createProject(ProjectSaveReqVO createReqVO); Long createProject(ProjectSaveReqVO createReqVO);
/**
* 创建项目并初始化团队(原子接口)。
*
* <p>由前端"项目两步向导"一次性提交,项目基础资料与全部初始团队成员
* 必须在同一事务内完成写入;任一步失败整体回滚。
*
* <p>与 {@link #createProject(ProjectSaveReqVO)} 的关键差异:
* <ul>
* <li>经理成员由前端在 {@code members} 中显式提交,后端不再根据
* {@code project.managerUserId} 自动追加;并校验 {@code members}
* 中必须存在 userId 等于 managerUserId 的记录;</li>
* <li>{@code productId} 非空时,{@code directionCode} 必须与所属产品方向
* 一致(防御性校验),不一致直接报错而非覆盖。</li>
* </ul>
*
* @param reqVO 创建请求(项目 + 初始团队)
* @return 项目编号
*/
Long createProjectWithTeam(ProjectCreateWithTeamReqVO reqVO);
void updateProject(ProjectSaveReqVO updateReqVO); void updateProject(ProjectSaveReqVO updateReqVO);
ProjectDO getProject(Long id); ProjectDO getProject(Long id);

View File

@@ -9,10 +9,12 @@ 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.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.project.ProjectContextNavRespVO; import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextNavRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextProjectRespVO; import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextProjectRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO; import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRoleRespVO; import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRoleRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectCreateWithTeamReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectDeleteReqVO; import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO; import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
@@ -57,10 +59,12 @@ import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -71,7 +75,8 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
* 项目 Service 实现类 * 项目 Service 实现类
*/ */
@Service @Service
public class ProjectServiceImpl implements ProjectService { public
class ProjectServiceImpl implements ProjectService {
@Resource @Resource
private ProjectMapper projectMapper; private ProjectMapper projectMapper;
@@ -133,6 +138,155 @@ public class ProjectServiceImpl implements ProjectService {
return project.getId(); return project.getId();
} }
@Override
@Transactional(rollbackFor = Exception.class)
public Long createProjectWithTeam(ProjectCreateWithTeamReqVO reqVO) {
ProjectSaveReqVO projectReq = reqVO.getProject();
List<ProjectMemberSaveReqVO> members = reqVO.getMembers();
// 1) 复用旧 create 接口的项目资料校验name/code 唯一、计划/实际日期范围、字典类型、主线唯一、负责人有效)
validateCreateReqVO(projectReq);
validateProjectType(projectReq.getProjectType());
// 注意新接口对方向使用严格校验——productId 非空时 directionCode 必须与产品方向一致;旧 create 是覆盖式,保留不动
String finalDirectionCode = resolveDirectionCodeStrict(projectReq.getProductId(), projectReq.getDirectionCode());
validateMainlineProjectUnique(null, projectReq.getProductId(), projectReq.getProjectType());
validateManagerUser(projectReq.getManagerUserId());
// 2) 初始团队校验包含经理、userId 不重复、roleId 合法(含经理那条必须为项目经理角色)
validateInitialMembers(projectReq.getManagerUserId(), members);
// 3) 写入项目基础资料:与 createProject 完全一致,唯一差异是不调用 initManagerMemberRelation —— 经理成员由 members 显式提供,避免重复插入
ProjectDO project = new ProjectDO();
project.setProjectCode(generateProjectCode(projectReq.getProjectCode()));
project.setProjectName(projectReq.getProjectName().trim());
project.setProjectType(projectReq.getProjectType().trim());
project.setDirectionCode(finalDirectionCode);
project.setProjectSetId(projectReq.getProjectSetId());
project.setProductId(projectReq.getProductId());
project.setProductVersionId(projectReq.getProductVersionId());
project.setManagerUserId(projectReq.getManagerUserId());
String initialStatus = getInitialStatusCode();
project.setStatusCode(initialStatus);
project.setPlannedStartDate(projectReq.getPlannedStartDate());
project.setPlannedEndDate(projectReq.getPlannedEndDate());
// 新增模式不应携带 actualStartDate / actualEndDate前端不传时即为 null无需特殊处理
project.setActualStartDate(projectReq.getActualStartDate());
project.setActualEndDate(projectReq.getActualEndDate());
project.setProgressRate(BigDecimal.ZERO);
project.setProjectDesc(normalizeNullableText(projectReq.getProjectDesc()));
projectMapper.insert(project);
// 4) 遍历写入初始成员,含成员维度审计
LocalDateTime now = LocalDateTime.now();
for (ProjectMemberSaveReqVO memberReq : members) {
UserObjectRoleDO member = new UserObjectRoleDO();
member.setUserId(memberReq.getUserId());
member.setObjectType(ProjectObjectConstants.OBJECT_TYPE);
member.setObjectId(project.getId());
member.setRoleId(memberReq.getRoleId());
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
member.setJoinedTime(now);
member.setLeftTime(null);
member.setRemark(normalizeNullableText(memberReq.getRemark()));
userObjectRoleMapper.insert(member);
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, null, member, null);
}
// 5) 项目维度的"指定经理"审计:与旧 initManagerMemberRelation 末尾一致,保留活动时间线一致性
writeManagerChangeAuditLog(project.getId(), null, project.getManagerUserId(), null);
// 6) 项目创建审计
writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_CREATE, null, initialStatus,
buildProjectFieldChanges(null, project), null);
return project.getId();
}
/**
* 新接口专用:严格的方向解析。
*
* <p>与 {@link #resolveProjectDirectionCode(Long, String)} 的差异:当 {@code productId}
* 非空且前端显式传入 {@code directionCode} 时,必须与所属产品方向一致;
* 不一致直接抛 {@link ErrorCodeConstants#PROJECT_DIRECTION_NOT_MATCH_PRODUCT}
* 而非旧接口的覆盖式行为。前端正常流程会强制把 directionCode 设为产品方向后锁定,
* 此校验仅作为防御,防止接口被绕过前端直接调用。
*/
@VisibleForTesting
String resolveDirectionCodeStrict(Long productId, String directionCode) {
if (productId == null) {
if (!StringUtils.hasText(directionCode)) {
throw invalidParamException("项目方向不能为空");
}
String normalizedDirectionCode = directionCode.trim();
if (normalizedDirectionCode.length() > 32) {
throw invalidParamException("项目方向长度不能超过32个字符");
}
validateProjectDirection(normalizedDirectionCode);
return normalizedDirectionCode;
}
ProductDO product = validateProductUsable(productId);
String productDirectionCode = normalizeNullableText(product == null ? null : product.getDirectionCode());
if (!StringUtils.hasText(productDirectionCode)) {
throw exception(ErrorCodeConstants.PROJECT_DIRECTION_INVALID);
}
if (StringUtils.hasText(directionCode)
&& !Objects.equals(directionCode.trim(), productDirectionCode)) {
throw exception(ErrorCodeConstants.PROJECT_DIRECTION_NOT_MATCH_PRODUCT);
}
return productDirectionCode;
}
/**
* 校验初始团队成员列表。
*
* <p>校验维度:
* <ol>
* <li>members 中 userId 不可重复</li>
* <li>members 必须包含项目经理本人userId == managerUserId</li>
* <li>所有 roleId 必须落在 scopeType=object, objectType=project 且启用</li>
* <li>经理那条 members 的 roleId 必须是 {@code project_manager} 角色(数据一致性兜底)</li>
* </ol>
*/
@VisibleForTesting
void validateInitialMembers(Long managerUserId, List<ProjectMemberSaveReqVO> members) {
if (members == null || members.isEmpty()) {
// @NotEmpty 已挡,此处兜底
throw exception(ErrorCodeConstants.PROJECT_INITIAL_TEAM_MANAGER_REQUIRED);
}
Set<Long> userIds = new HashSet<>();
for (ProjectMemberSaveReqVO m : members) {
if (!userIds.add(m.getUserId())) {
throw exception(ErrorCodeConstants.PROJECT_INITIAL_TEAM_MEMBER_DUPLICATE);
}
}
if (!userIds.contains(managerUserId)) {
throw exception(ErrorCodeConstants.PROJECT_INITIAL_TEAM_MANAGER_REQUIRED);
}
Set<Long> roleIds = members.stream().map(ProjectMemberSaveReqVO::getRoleId).collect(Collectors.toSet());
List<ObjectRoleRespDTO> roles = objectPermissionApi
.getObjectRoleList(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProjectObjectConstants.OBJECT_TYPE)
.getCheckedData();
Map<Long, ObjectRoleRespDTO> roleMap = roles == null ? Collections.emptyMap()
: roles.stream().collect(Collectors.toMap(ObjectRoleRespDTO::getId, Function.identity()));
for (Long roleId : roleIds) {
if (!roleMap.containsKey(roleId)) {
throw exception(ErrorCodeConstants.PROJECT_INITIAL_TEAM_ROLE_INVALID);
}
}
// 经理那条 members 的 roleId 必须对应 project_manager 角色,否则 project.managerUserId 与成员表角色语义会错乱
ProjectMemberSaveReqVO managerMember = members.stream()
.filter(m -> Objects.equals(m.getUserId(), managerUserId))
.findFirst()
.orElseThrow(() -> exception(ErrorCodeConstants.PROJECT_INITIAL_TEAM_MANAGER_REQUIRED));
ObjectRoleRespDTO managerRole = roleMap.get(managerMember.getRoleId());
if (managerRole == null
|| !Objects.equals(ProjectObjectConstants.MANAGER_ROLE_CODE, managerRole.getCode())) {
throw exception(ErrorCodeConstants.PROJECT_INITIAL_TEAM_ROLE_INVALID);
}
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, @com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE,

View File

@@ -1,19 +1,24 @@
package com.njcn.rdms.module.project.service.project; package com.njcn.rdms.module.project.service.project;
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.ProjectTaskConstants; import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
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.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.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 jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.Objects;
@Service @Service
public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService { public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService {
@@ -24,20 +29,35 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
private ProjectExecutionMapper projectExecutionMapper; private ProjectExecutionMapper projectExecutionMapper;
@Resource @Resource
private ProjectTaskMapper projectTaskMapper; private ProjectTaskMapper projectTaskMapper;
@Resource
private VisibilityScopeResolver visibilityScopeResolver;
@Override @Override
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, reqVO, statusModels); return buildExecutionStatusBoard(projectId, scope, reqVO, statusModels);
} }
@Override @Override
public ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO) { public ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
VisibilityScope scope = visibilityScopeResolver.resolveForExecution(projectId, executionId, userId);
// 执行 owner = 当前用户 → 看本执行下全部任务,等价于 seesAll。
if (!scope.seesAll()) {
ProjectExecutionDO exec = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
if (exec != null && Objects.equals(exec.getOwnerId(), userId)) {
scope = VisibilityScope.all();
}
}
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE); List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
return buildTaskStatusBoard(projectId, executionId, reqVO, statusModels); return buildTaskStatusBoard(projectId, executionId, scope, reqVO, statusModels);
} }
private ProjectExecutionStatusBoardRespVO buildExecutionStatusBoard(Long projectId, ProjectExecutionStatusBoardReqVO reqVO, private ProjectExecutionStatusBoardRespVO buildExecutionStatusBoard(Long projectId,
VisibilityScope scope,
ProjectExecutionStatusBoardReqVO reqVO,
List<ObjectStatusModelDO> statusModels) { List<ObjectStatusModelDO> statusModels) {
ProjectExecutionStatusBoardRespVO respVO = new ProjectExecutionStatusBoardRespVO(); ProjectExecutionStatusBoardRespVO respVO = new ProjectExecutionStatusBoardRespVO();
List<ProjectExecutionStatusBoardRespVO.ProjectStatusBoardItemVO> items = statusModels.stream().map(statusModel -> { List<ProjectExecutionStatusBoardRespVO.ProjectStatusBoardItemVO> items = statusModels.stream().map(statusModel -> {
@@ -45,7 +65,7 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
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, reqVO, statusModel.getStatusCode()).longValue()); item.setCount(projectExecutionMapper.countByProjectIdAndStatusCode(projectId, scope, reqVO, statusModel.getStatusCode()).longValue());
item.setSort(statusModel.getSort()); item.setSort(statusModel.getSort());
item.setTerminal(statusModel.getTerminalFlag()); item.setTerminal(statusModel.getTerminalFlag());
return item; return item;
@@ -55,14 +75,16 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
return respVO; return respVO;
} }
private ProjectTaskStatusBoardRespVO buildTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO, private ProjectTaskStatusBoardRespVO buildTaskStatusBoard(Long projectId, Long executionId,
VisibilityScope scope,
ProjectTaskStatusBoardReqVO reqVO,
List<ObjectStatusModelDO> statusModels) { List<ObjectStatusModelDO> statusModels) {
ProjectTaskStatusBoardRespVO respVO = new ProjectTaskStatusBoardRespVO(); ProjectTaskStatusBoardRespVO respVO = new ProjectTaskStatusBoardRespVO();
List<ProjectTaskStatusBoardRespVO.ProjectStatusBoardItemVO> items = statusModels.stream().map(statusModel -> { List<ProjectTaskStatusBoardRespVO.ProjectStatusBoardItemVO> items = statusModels.stream().map(statusModel -> {
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, reqVO, item.setCount(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(projectId, executionId, scope, 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

@@ -0,0 +1,49 @@
package com.njcn.rdms.module.project.service.project.execution;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeSaveReqVO;
import java.util.List;
/**
* 执行协办人 ServiceB 模型 - 多行周期记录)。
* <p>
* 加入:每次 INSERT 一条新的活跃段(不复用旧段)+ 一条 join 日志。
* 失效:当前活跃段 setRemovedAt + 永久保留 removedReason写一条 inactive 日志。
* 负责人转移:在 ProjectExecutionService.changeOwner 内同步写 owner_transfer_in/out 双向事件。
*/
public interface ProjectExecutionAssigneeService {
/**
* 获取当前活跃协办人列表(仅 removed_at IS NULL 段)。
*/
List<ExecutionAssigneeRespVO> getExecutionAssigneeList(Long projectId, Long executionId);
/**
* 加入执行协办人。同 userId 当前已活跃则抛 ALREADY_EXISTS否则 INSERT 新活跃段,写 join 日志。
*/
Long createExecutionAssignee(Long projectId, Long executionId, ExecutionAssigneeSaveReqVO reqVO);
/**
* 失效执行协办人。当前段必须存在且活跃reason 必填(@NotBlank 已校验service 不再重复)。
* 失效后 removedReason 永久保留;写 inactive 日志。
*/
void inactiveExecutionAssignee(Long projectId, Long executionId, Long assigneeId, ExecutionAssigneeInactiveReqVO reqVO);
/**
* 分页查询执行协办人变更历史,按 actionTime DESC, id DESC 排序。
*/
PageResult<ExecutionAssigneeLogRespVO> getExecutionAssigneeLogPage(Long projectId, Long executionId,
ExecutionAssigneeLogPageReqVO reqVO);
/**
* 写一条执行协办人变更日志(供 ProjectExecutionServiceImpl.changeOwner 调用)。
* 只写用户编号与事件语义;昵称由查询阶段通过 AdminUserApi 按当前用户信息回填。
*/
void writeAssigneeLog(Long executionId, Long userId, String actionType, String reason);
}

View File

@@ -8,23 +8,23 @@ 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.member.ExecutionMemberInactiveReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberLogPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberLogRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberSaveReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeSaveReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO; import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO; import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberLogDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeLogDO;
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.dal.dataobject.status.ObjectStatusModelDO; import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
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.project.ProjectMapper; import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberLogMapper; import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeLogMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberMapper; 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.execution.ProjectExecutionMapper;
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.enums.ErrorCodeConstants; import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
@@ -52,22 +52,22 @@ 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.exception;
/** /**
* 执行成员 Service 实现类B 模型 - 多行周期记录 * 执行协办人 Service 实现类B 模型 - 多行周期记录
* <p> * <p>
* userId 在同执行内任意时刻只允许一段未失效重新加入新插一条独立段 * userId 在同执行内任意时刻只允许一段未失效重新加入新插一条独立段
* 旧段的 removed_reason 永久保留不再覆盖每次状态变更同步落 rdms_execution_member_log * 旧段的 removed_reason 永久保留不再覆盖每次状态变更同步落 rdms_execution_assignee_log
*/ */
@Service @Service
public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMemberService { public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssigneeService {
@Resource @Resource
private ProjectMapper projectMapper; private ProjectMapper projectMapper;
@Resource @Resource
private ProjectExecutionMapper projectExecutionMapper; private ProjectExecutionMapper projectExecutionMapper;
@Resource @Resource
private ExecutionMemberMapper executionMemberMapper; private ExecutionAssigneeMapper executionAssigneeMapper;
@Resource @Resource
private ExecutionMemberLogMapper executionMemberLogMapper; private ExecutionAssigneeLogMapper executionAssigneeLogMapper;
@Resource @Resource
private UserObjectRoleMapper userObjectRoleMapper; private UserObjectRoleMapper userObjectRoleMapper;
@Resource @Resource
@@ -78,20 +78,20 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
private AdminUserApi adminUserApi; private AdminUserApi adminUserApi;
@Override @Override
public List<ExecutionMemberRespVO> getExecutionMemberList(Long projectId, Long executionId) { public List<ExecutionAssigneeRespVO> getExecutionAssigneeList(Long projectId, Long executionId) {
validateProjectExists(projectId); validateProjectExists(projectId);
validateExecutionExists(projectId, executionId); validateExecutionExists(projectId, executionId);
// 仅返当前活跃段removed_at IS NULLB 模型下同 userId 至多一段 // 仅返当前活跃段removed_at IS NULLB 模型下同 userId 至多一段
List<ExecutionMemberDO> activeList = executionMemberMapper.selectActiveListByExecutionId(executionId); List<ExecutionAssigneeDO> activeList = executionAssigneeMapper.selectActiveListByExecutionId(executionId);
if (activeList.isEmpty()) { if (activeList.isEmpty()) {
return Collections.emptyList(); return Collections.emptyList();
} }
// 批量回查昵称避免 N+1 // 批量回查昵称避免 N+1
Map<Long, String> nicknameMap = loadUserNicknameMap(activeList.stream() Map<Long, String> nicknameMap = loadUserNicknameMap(activeList.stream()
.map(ExecutionMemberDO::getUserId).collect(Collectors.toCollection(LinkedHashSet::new))); .map(ExecutionAssigneeDO::getUserId).collect(Collectors.toCollection(LinkedHashSet::new)));
return activeList.stream().map(member -> { return activeList.stream().map(assignee -> {
ExecutionMemberRespVO respVO = buildMemberRespVO(member); ExecutionAssigneeRespVO respVO = buildAssigneeRespVO(assignee);
respVO.setUserNickname(nicknameMap.get(member.getUserId())); respVO.setUserNickname(nicknameMap.get(assignee.getUserId()));
return respVO; return respVO;
}).collect(Collectors.toList()); }).collect(Collectors.toList());
} }
@@ -99,70 +99,70 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_MEMBER) permission = ProjectExecutionConstants.PERMISSION_ASSIGNEE)
public Long createExecutionMember(Long projectId, Long executionId, ExecutionMemberSaveReqVO reqVO) { public Long createExecutionAssignee(Long projectId, Long executionId, ExecutionAssigneeSaveReqVO reqVO) {
validateEditableExecution(projectId, executionId); validateEditableExecution(projectId, executionId);
validateProjectMember(projectId, reqVO.getUserId()); validateProjectMember(projectId, reqVO.getUserId());
// B 模型只看是否有当前活跃段旧的失效段允许"重新加入"新插一行 // B 模型只看是否有当前活跃段旧的失效段允许"重新加入"新插一行
ExecutionMemberDO active = executionMemberMapper ExecutionAssigneeDO active = executionAssigneeMapper
.selectActiveByExecutionIdAndUserId(executionId, reqVO.getUserId()); .selectActiveByExecutionIdAndUserId(executionId, reqVO.getUserId());
if (active != null) { if (active != null) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_ALREADY_EXISTS); throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_ALREADY_EXISTS);
} }
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
ExecutionMemberDO member = new ExecutionMemberDO(); ExecutionAssigneeDO assignee = new ExecutionAssigneeDO();
member.setExecutionId(executionId); assignee.setExecutionId(executionId);
member.setUserId(reqVO.getUserId()); assignee.setUserId(reqVO.getUserId());
member.setJoinedAt(now); assignee.setJoinedAt(now);
member.setRemovedAt(null); assignee.setRemovedAt(null);
member.setRemovedReason(null); assignee.setRemovedReason(null);
executionMemberMapper.insert(member); executionAssigneeMapper.insert(assignee);
// 双写 BizAuditLog 通用审计 + rdms_execution_member_log 业务事件流 // 双写 BizAuditLog 通用审计 + rdms_execution_assignee_log 业务事件流
writeExecutionMemberAuditLog(executionId, ObjectActivityConstants.EXECUTION_MEMBER_ACTION_ADD, null, member, null); writeExecutionAssigneeAuditLog(executionId, ObjectActivityConstants.EXECUTION_ASSIGNEE_ACTION_ADD, null, assignee, null);
writeMemberLogInternal(executionId, reqVO.getUserId(), writeAssigneeLogInternal(executionId, reqVO.getUserId(),
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_JOIN, null, now); ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_JOIN, null, now);
return member.getId(); return assignee.getId();
} }
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_MEMBER) permission = ProjectExecutionConstants.PERMISSION_ASSIGNEE)
public void inactiveExecutionMember(Long projectId, Long executionId, Long memberId, ExecutionMemberInactiveReqVO reqVO) { public void inactiveExecutionAssignee(Long projectId, Long executionId, Long assigneeId, ExecutionAssigneeInactiveReqVO reqVO) {
validateEditableExecution(projectId, executionId); validateEditableExecution(projectId, executionId);
ExecutionMemberDO member = validateExecutionMemberExists(executionId, memberId); ExecutionAssigneeDO assignee = validateExecutionAssigneeExists(executionId, assigneeId);
if (member.getRemovedAt() != null) { if (assignee.getRemovedAt() != null) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_NOT_ACTIVE); throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_NOT_ACTIVE);
} }
ExecutionMemberDO before = cloneExecutionMember(member); ExecutionAssigneeDO before = cloneExecutionAssignee(assignee);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
String reason = normalizeNullableText(reqVO.getReason()); String reason = normalizeNullableText(reqVO.getReason());
member.setRemovedAt(now); assignee.setRemovedAt(now);
member.setRemovedReason(reason); assignee.setRemovedReason(reason);
executionMemberMapper.updateById(member); executionAssigneeMapper.updateById(assignee);
writeExecutionMemberAuditLog(executionId, ObjectActivityConstants.EXECUTION_MEMBER_ACTION_REMOVE, writeExecutionAssigneeAuditLog(executionId, ObjectActivityConstants.EXECUTION_ASSIGNEE_ACTION_REMOVE,
before, member, reason); before, assignee, reason);
writeMemberLogInternal(executionId, member.getUserId(), writeAssigneeLogInternal(executionId, assignee.getUserId(),
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_INACTIVE, reason, now); ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_INACTIVE, reason, now);
} }
@Override @Override
public PageResult<ExecutionMemberLogRespVO> getExecutionMemberLogPage(Long projectId, Long executionId, public PageResult<ExecutionAssigneeLogRespVO> getExecutionAssigneeLogPage(Long projectId, Long executionId,
ExecutionMemberLogPageReqVO reqVO) { ExecutionAssigneeLogPageReqVO reqVO) {
validateProjectExists(projectId); validateProjectExists(projectId);
validateExecutionExists(projectId, executionId); validateExecutionExists(projectId, executionId);
PageResult<ExecutionMemberLogDO> page = executionMemberLogMapper.selectPageByExecutionId(executionId, reqVO); PageResult<ExecutionAssigneeLogDO> page = executionAssigneeLogMapper.selectPageByExecutionId(executionId, reqVO);
PageResult<ExecutionMemberLogRespVO> result = BeanUtils.toBean(page, ExecutionMemberLogRespVO.class); PageResult<ExecutionAssigneeLogRespVO> result = BeanUtils.toBean(page, ExecutionAssigneeLogRespVO.class);
fillMemberLogNicknames(result.getList()); fillAssigneeLogNicknames(result.getList());
return result; return result;
} }
@Override @Override
public void writeMemberLog(Long executionId, Long userId, String actionType, String reason) { public void writeAssigneeLog(Long executionId, Long userId, String actionType, String reason) {
writeMemberLogInternal(executionId, userId, actionType, reason, LocalDateTime.now()); writeAssigneeLogInternal(executionId, userId, actionType, reason, LocalDateTime.now());
} }
@VisibleForTesting @VisibleForTesting
@@ -207,10 +207,10 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
@VisibleForTesting @VisibleForTesting
void validateProjectMember(Long projectId, Long userId) { void validateProjectMember(Long projectId, Long userId) {
UserObjectRoleDO member = userObjectRoleMapper UserObjectRoleDO projectMember = userObjectRoleMapper
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId); .selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId);
if (member == null) { if (projectMember == null) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_INVALID); throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_INVALID);
} }
} }
@@ -227,22 +227,22 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
} }
@VisibleForTesting @VisibleForTesting
ExecutionMemberDO validateExecutionMemberExists(Long executionId, Long memberId) { ExecutionAssigneeDO validateExecutionAssigneeExists(Long executionId, Long assigneeId) {
ExecutionMemberDO member = executionMemberMapper.selectByIdAndExecutionId(memberId, executionId); ExecutionAssigneeDO assignee = executionAssigneeMapper.selectByIdAndExecutionId(assigneeId, executionId);
if (member == null) { if (assignee == null) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_NOT_EXISTS); throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_NOT_EXISTS);
} }
return member; return assignee;
} }
private ExecutionMemberRespVO buildMemberRespVO(ExecutionMemberDO member) { private ExecutionAssigneeRespVO buildAssigneeRespVO(ExecutionAssigneeDO assignee) {
ExecutionMemberRespVO respVO = new ExecutionMemberRespVO(); ExecutionAssigneeRespVO respVO = new ExecutionAssigneeRespVO();
respVO.setId(member.getId()); respVO.setId(assignee.getId());
respVO.setExecutionId(member.getExecutionId()); respVO.setExecutionId(assignee.getExecutionId());
respVO.setUserId(member.getUserId()); respVO.setUserId(assignee.getUserId());
respVO.setJoinedAt(member.getJoinedAt()); respVO.setJoinedAt(assignee.getJoinedAt());
respVO.setRemovedAt(member.getRemovedAt()); respVO.setRemovedAt(assignee.getRemovedAt());
respVO.setRemovedReason(member.getRemovedReason()); respVO.setRemovedReason(assignee.getRemovedReason());
return respVO; return respVO;
} }
@@ -251,12 +251,12 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
* <p> * <p>
* 这里只用 userId / operatorUserId 查当前昵称不依赖历史快照避免用户改名后前后展示不一致 * 这里只用 userId / operatorUserId 查当前昵称不依赖历史快照避免用户改名后前后展示不一致
*/ */
private void fillMemberLogNicknames(List<ExecutionMemberLogRespVO> logs) { private void fillAssigneeLogNicknames(List<ExecutionAssigneeLogRespVO> logs) {
if (logs == null || logs.isEmpty()) { if (logs == null || logs.isEmpty()) {
return; return;
} }
Set<Long> userIds = new LinkedHashSet<>(); Set<Long> userIds = new LinkedHashSet<>();
for (ExecutionMemberLogRespVO log : logs) { for (ExecutionAssigneeLogRespVO log : logs) {
if (log == null) { if (log == null) {
continue; continue;
} }
@@ -271,7 +271,7 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
return; return;
} }
Map<Long, String> nicknameMap = loadUserNicknameMap(userIds); Map<Long, String> nicknameMap = loadUserNicknameMap(userIds);
for (ExecutionMemberLogRespVO log : logs) { for (ExecutionAssigneeLogRespVO log : logs) {
if (log == null) { if (log == null) {
continue; continue;
} }
@@ -280,16 +280,16 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
} }
} }
private void writeExecutionMemberAuditLog(Long executionId, private void writeExecutionAssigneeAuditLog(Long executionId,
String actionType, String actionType,
ExecutionMemberDO before, ExecutionAssigneeDO before,
ExecutionMemberDO after, ExecutionAssigneeDO after,
String reason) { String reason) {
BizAuditLogDO auditLog = new BizAuditLogDO(); BizAuditLogDO auditLog = new BizAuditLogDO();
auditLog.setBizType(ProjectExecutionConstants.BIZ_TYPE); auditLog.setBizType(ProjectExecutionConstants.BIZ_TYPE);
auditLog.setBizId(executionId); auditLog.setBizId(executionId);
auditLog.setActionType(actionType); auditLog.setActionType(actionType);
auditLog.setFieldChanges(buildExecutionMemberFieldChanges(before, after)); auditLog.setFieldChanges(buildExecutionAssigneeFieldChanges(before, after));
auditLog.setReason(reason); auditLog.setReason(reason);
auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname())); auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
@@ -297,19 +297,19 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
} }
/** /**
* 写一条 rdms_execution_member_log 事件 * 写一条 rdms_execution_assignee_log 事件
* 只写 ID 和事件语义字段昵称展示由查询阶段按当前系统用户信息回填 * 只写 ID 和事件语义字段昵称展示由查询阶段按当前系统用户信息回填
*/ */
private void writeMemberLogInternal(Long executionId, Long userId, private void writeAssigneeLogInternal(Long executionId, Long userId,
String actionType, String reason, LocalDateTime when) { String actionType, String reason, LocalDateTime when) {
ExecutionMemberLogDO log = new ExecutionMemberLogDO(); ExecutionAssigneeLogDO log = new ExecutionAssigneeLogDO();
log.setExecutionId(executionId); log.setExecutionId(executionId);
log.setUserId(userId); log.setUserId(userId);
log.setActionType(actionType); log.setActionType(actionType);
log.setReason(reason); log.setReason(reason);
log.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId()); log.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
log.setActionTime(when); log.setActionTime(when);
executionMemberLogMapper.insert(log); executionAssigneeLogMapper.insert(log);
} }
private Map<Long, String> loadUserNicknameMap(Collection<Long> userIds) { private Map<Long, String> loadUserNicknameMap(Collection<Long> userIds) {
@@ -325,23 +325,23 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
return nicknameMap; return nicknameMap;
} }
private String buildExecutionMemberFieldChanges(ExecutionMemberDO before, ExecutionMemberDO after) { private String buildExecutionAssigneeFieldChanges(ExecutionAssigneeDO before, ExecutionAssigneeDO after) {
Map<String, Object> fieldChanges = new LinkedHashMap<>(); Map<String, Object> fieldChanges = new LinkedHashMap<>();
appendFieldChange(fieldChanges, "executionId", valueOf(before, ExecutionMemberDO::getExecutionId), appendFieldChange(fieldChanges, "executionId", valueOf(before, ExecutionAssigneeDO::getExecutionId),
valueOf(after, ExecutionMemberDO::getExecutionId)); valueOf(after, ExecutionAssigneeDO::getExecutionId));
appendFieldChange(fieldChanges, "userId", valueOf(before, ExecutionMemberDO::getUserId), appendFieldChange(fieldChanges, "userId", valueOf(before, ExecutionAssigneeDO::getUserId),
valueOf(after, ExecutionMemberDO::getUserId)); valueOf(after, ExecutionAssigneeDO::getUserId));
appendFieldChange(fieldChanges, "joinedAt", valueOf(before, ExecutionMemberDO::getJoinedAt), appendFieldChange(fieldChanges, "joinedAt", valueOf(before, ExecutionAssigneeDO::getJoinedAt),
valueOf(after, ExecutionMemberDO::getJoinedAt)); valueOf(after, ExecutionAssigneeDO::getJoinedAt));
appendFieldChange(fieldChanges, "removedAt", valueOf(before, ExecutionMemberDO::getRemovedAt), appendFieldChange(fieldChanges, "removedAt", valueOf(before, ExecutionAssigneeDO::getRemovedAt),
valueOf(after, ExecutionMemberDO::getRemovedAt)); valueOf(after, ExecutionAssigneeDO::getRemovedAt));
appendFieldChange(fieldChanges, "removedReason", valueOf(before, ExecutionMemberDO::getRemovedReason), appendFieldChange(fieldChanges, "removedReason", valueOf(before, ExecutionAssigneeDO::getRemovedReason),
valueOf(after, ExecutionMemberDO::getRemovedReason)); valueOf(after, ExecutionAssigneeDO::getRemovedReason));
return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges); return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges);
} }
private ExecutionMemberDO cloneExecutionMember(ExecutionMemberDO source) { private ExecutionAssigneeDO cloneExecutionAssignee(ExecutionAssigneeDO source) {
ExecutionMemberDO target = new ExecutionMemberDO(); ExecutionAssigneeDO target = new ExecutionAssigneeDO();
target.setId(source.getId()); target.setId(source.getId());
target.setExecutionId(source.getExecutionId()); target.setExecutionId(source.getExecutionId());
target.setUserId(source.getUserId()); target.setUserId(source.getUserId());
@@ -351,8 +351,8 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
return target; return target;
} }
private <T> T valueOf(ExecutionMemberDO member, Function<ExecutionMemberDO, T> getter) { private <T> T valueOf(ExecutionAssigneeDO assignee, Function<ExecutionAssigneeDO, T> getter) {
return member == null ? null : getter.apply(member); return assignee == null ? null : getter.apply(assignee);
} }
private void appendFieldChange(Map<String, Object> fieldChanges, String fieldName, Object before, Object after) { private void appendFieldChange(Map<String, Object> fieldChanges, String fieldName, Object before, Object after) {

View File

@@ -1,49 +0,0 @@
package com.njcn.rdms.module.project.service.project.execution;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberLogPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberLogRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberSaveReqVO;
import java.util.List;
/**
* 执行成员 ServiceB 模型 - 多行周期记录)。
* <p>
* 加入:每次 INSERT 一条新的活跃段(不复用旧段)+ 一条 join 日志。
* 失效:当前活跃段 setRemovedAt + 永久保留 removedReason写一条 inactive 日志。
* 负责人转移:在 ProjectExecutionService.changeOwner 内同步写 owner_transfer_in/out 双向事件。
*/
public interface ProjectExecutionMemberService {
/**
* 获取当前活跃成员列表(仅 removed_at IS NULL 段)。
*/
List<ExecutionMemberRespVO> getExecutionMemberList(Long projectId, Long executionId);
/**
* 加入执行成员。同 userId 当前已活跃则抛 ALREADY_EXISTS否则 INSERT 新活跃段,写 join 日志。
*/
Long createExecutionMember(Long projectId, Long executionId, ExecutionMemberSaveReqVO reqVO);
/**
* 失效执行成员。当前段必须存在且活跃reason 必填(@NotBlank 已校验service 不再重复)。
* 失效后 removedReason 永久保留;写 inactive 日志。
*/
void inactiveExecutionMember(Long projectId, Long executionId, Long memberId, ExecutionMemberInactiveReqVO reqVO);
/**
* 分页查询执行成员变更历史,按 actionTime DESC, id DESC 排序。
*/
PageResult<ExecutionMemberLogRespVO> getExecutionMemberLogPage(Long projectId, Long executionId,
ExecutionMemberLogPageReqVO reqVO);
/**
* 写一条执行成员变更日志(供 ProjectExecutionServiceImpl.changeOwner 调用)。
* 只写用户编号与事件语义;昵称由查询阶段通过 AdminUserApi 按当前用户信息回填。
*/
void writeMemberLog(Long executionId, Long userId, String actionType, String reason);
}

View File

@@ -4,7 +4,9 @@ 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;
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;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionSaveReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionCreateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
@@ -13,9 +15,9 @@ import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExec
*/ */
public interface ProjectExecutionService { public interface ProjectExecutionService {
Long createExecution(Long projectId, ProjectExecutionSaveReqVO reqVO); Long createExecution(Long projectId, ProjectExecutionCreateReqVO reqVO);
void updateExecution(Long projectId, ProjectExecutionSaveReqVO reqVO); void updateExecution(Long projectId, ProjectExecutionUpdateReqVO reqVO);
ProjectExecutionDO getExecution(Long projectId, Long executionId); ProjectExecutionDO getExecution(Long projectId, Long executionId);
@@ -33,6 +35,23 @@ public interface ProjectExecutionService {
void changeOwner(Long projectId, Long executionId, ProjectExecutionOwnerChangeReqVO reqVO); void changeOwner(Long projectId, Long executionId, ProjectExecutionOwnerChangeReqVO reqVO);
/**
* 删除执行(软删 + CAS 状态校验 + 三重确认)。
* <p>仅初始态 pending 可删;权限码 project:execution:delete 推荐挂"项目负责人"角色。
* <p>不级联软删 rdms_execution_assignee / rdms_task与项目侧 deleteProject 风格一致,下挂记录通过 deleted=0 自然不可见)。
*/
void deleteExecution(Long projectId, Long executionId, ProjectExecutionDeleteReqVO reqVO);
void changeExecutionStatus(Long projectId, Long executionId, ProjectExecutionStatusActionReqVO reqVO); void changeExecutionStatus(Long projectId, Long executionId, ProjectExecutionStatusActionReqVO reqVO);
/**
* 任务状态变更回调(事件驱动)。
* 任务侧在 auto_start / complete / cancel 三个动作的成功落库后调用本方法pause / resume 不调用。
* 执行侧据此自行分发响应(如自身 auto_start 联动、完成校验链、向项目 / 需求继续传播等)。
* 调用规则见 docs/项目/任务状态流转设计.md §6.4 触发表。
* 当前为预埋接口;具体响应在《执行闭环设计》中落地。
*/
void onTaskStatusChanged(Long executionId, Long taskId, String fromStatus, String toStatus,
String actionCode, String reason);
} }

View File

@@ -12,36 +12,47 @@ import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
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;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionSaveReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionCreateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO; import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO; import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
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.dal.dataobject.project.execution.ProjectExecutionStatusLogDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionStatusLogDO;
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.dataobject.status.ObjectStatusTransitionDO; 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.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.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.execution.ExecutionMemberMapper; 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.execution.ProjectExecutionMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionStatusLogMapper; import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionStatusLogMapper;
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;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants; import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
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.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.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;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; 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.Collection; import java.util.Collection;
@@ -63,6 +74,7 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
* 执行主数据 Service 实现类。 * 执行主数据 Service 实现类。
*/ */
@Service @Service
@Slf4j
public class ProjectExecutionServiceImpl implements ProjectExecutionService { public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@Resource @Resource
@@ -70,7 +82,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@Resource @Resource
private ProjectExecutionMapper projectExecutionMapper; private ProjectExecutionMapper projectExecutionMapper;
@Resource @Resource
private ExecutionMemberMapper executionMemberMapper; private ExecutionAssigneeMapper executionAssigneeMapper;
@Resource @Resource
private UserObjectRoleMapper userObjectRoleMapper; private UserObjectRoleMapper userObjectRoleMapper;
@Resource @Resource
@@ -86,15 +98,28 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@Resource @Resource
private ProjectExecutionStatusViewService projectExecutionStatusViewService; private ProjectExecutionStatusViewService projectExecutionStatusViewService;
@Resource @Resource
private ProjectExecutionMemberService projectExecutionMemberService; private ProjectExecutionAssigneeService projectExecutionAssigneeService;
@Resource @Resource
private AdminUserApi adminUserApi; private AdminUserApi adminUserApi;
@Resource
private ProjectTaskMapper projectTaskMapper;
@Resource
private ProjectService projectService;
@Resource
private VisibilityScopeResolver visibilityScopeResolver;
/**
* 任务服务:执行 cancel / pause / resume 时级联调任务侧 internal 入口。
* 与 ProjectTaskService 互相依赖(任务侧已注入 ProjectExecutionService 用于通知执行),用 @Lazy 打破循环。
*/
@Resource
@Lazy
private ProjectTaskService projectTaskService;
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_CREATE) permission = ProjectExecutionConstants.PERMISSION_CREATE)
public Long createExecution(Long projectId, ProjectExecutionSaveReqVO reqVO) { public Long createExecution(Long projectId, ProjectExecutionCreateReqVO reqVO) {
validateEditableProject(projectId); validateEditableProject(projectId);
validateRequirementIdPhaseOne(reqVO.getProjectRequirementId()); validateRequirementIdPhaseOne(reqVO.getProjectRequirementId());
String executionName = normalizeRequiredName(reqVO.getExecutionName()); String executionName = normalizeRequiredName(reqVO.getExecutionName());
@@ -102,7 +127,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
validateOwnerIsActiveProjectMember(projectId, reqVO.getOwnerId()); validateOwnerIsActiveProjectMember(projectId, reqVO.getOwnerId());
String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType()); String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType());
validateExecutionType(executionType); validateExecutionType(executionType);
Set<Long> memberUserIds = normalizeRequiredMemberUserIds(reqVO.getMemberUserIds()); Set<Long> assigneeUserIds = normalizeRequiredAssigneeUserIds(reqVO.getAssigneeUserIds());
validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期"); validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
ProjectExecutionDO execution = new ProjectExecutionDO(); ProjectExecutionDO execution = new ProjectExecutionDO();
@@ -123,7 +148,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_CREATE, null, writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_CREATE, null,
initialStatusCode, buildExecutionFieldChanges(null, execution), null); initialStatusCode, buildExecutionFieldChanges(null, execution), null);
createExecutionMembers(execution.getId(), projectId, memberUserIds); createExecutionAssignees(execution.getId(), projectId, assigneeUserIds);
return execution.getId(); return execution.getId();
} }
@@ -131,7 +156,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_UPDATE) permission = ProjectExecutionConstants.PERMISSION_UPDATE)
public void updateExecution(Long projectId, ProjectExecutionSaveReqVO reqVO) { public void updateExecution(Long projectId, ProjectExecutionUpdateReqVO reqVO) {
if (reqVO.getId() == null) { if (reqVO.getId() == null) {
throw invalidParamException("执行编号不能为空"); throw invalidParamException("执行编号不能为空");
} }
@@ -141,15 +166,14 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
validateRequirementIdPhaseOne(reqVO.getProjectRequirementId()); validateRequirementIdPhaseOne(reqVO.getProjectRequirementId());
String executionName = normalizeRequiredName(reqVO.getExecutionName()); String executionName = normalizeRequiredName(reqVO.getExecutionName());
validateExecutionNameUnique(projectId, execution.getId(), executionName); validateExecutionNameUnique(projectId, execution.getId(), executionName);
validateOwnerIsActiveProjectMember(projectId, reqVO.getOwnerId());
String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType()); String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType());
validateExecutionType(executionType); validateExecutionType(executionType);
validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期"); validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
// 不再处理 ownerId换负责人必须走 /change-owner 端点spec §6.3 契约收口)
ProjectExecutionDO before = cloneExecution(execution); ProjectExecutionDO before = cloneExecution(execution);
execution.setExecutionName(executionName); execution.setExecutionName(executionName);
execution.setExecutionType(executionType); execution.setExecutionType(executionType);
execution.setOwnerId(reqVO.getOwnerId());
execution.setProjectRequirementId(null); execution.setProjectRequirementId(null);
execution.setPlannedStartDate(reqVO.getPlannedStartDate()); execution.setPlannedStartDate(reqVO.getPlannedStartDate());
execution.setPlannedEndDate(reqVO.getPlannedEndDate()); execution.setPlannedEndDate(reqVO.getPlannedEndDate());
@@ -169,13 +193,24 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@Override @Override
public PageResult<ProjectExecutionDO> getExecutionPage(Long projectId, ProjectExecutionPageReqVO reqVO) { public PageResult<ProjectExecutionDO> getExecutionPage(Long projectId, ProjectExecutionPageReqVO reqVO) {
validateProjectExists(projectId); validateProjectExists(projectId);
return projectExecutionMapper.selectPageByProjectId(projectId, reqVO); // 数据可见性:项目经理看全部;非经理按"我 owner 的执行 我活跃协办的执行"过滤
Long userId = SecurityFrameworkUtils.getLoginUserId();
VisibilityScope scope = visibilityScopeResolver.resolveForProject(projectId, userId);
return projectExecutionMapper.selectPageByProjectId(projectId, scope, reqVO);
} }
@Override @Override
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));
applyLifecycle(respVO); applyLifecycle(respVO);
respVO.setOwnerNickname(loadOwnerNickname(execution.getOwnerId())); respVO.setOwnerNickname(loadOwnerNickname(execution.getOwnerId()));
return respVO; return respVO;
@@ -189,13 +224,24 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
if (list == null || list.isEmpty()) { if (list == null || list.isEmpty()) {
return voPageResult; return voPageResult;
} }
// 批量补负责人昵称,避免 N+1lifecycle 字段保持原行为不在分页装配) fillExecutionProgress(projectId, list);
// 批量补负责人昵称,避免 N+1
Set<Long> ownerIds = list.stream() Set<Long> ownerIds = list.stream()
.map(ProjectExecutionRespVO::getOwnerId) .map(ProjectExecutionRespVO::getOwnerId)
.filter(Objects::nonNull) .filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new)); .collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, String> nicknameMap = loadOwnerNicknameMap(ownerIds); Map<Long, String> nicknameMap = loadOwnerNicknameMap(ownerIds);
list.forEach(vo -> vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId()))); list.forEach(vo -> {
vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId()));
// 列表行 cancel/pause/resume/complete 按钮依赖 availableActions与详情同款装配 lifecycle。
// 单行装配失败做兜底降级status_model 缺失等脏数据),避免影响整页返回。
try {
applyLifecycle(vo);
} catch (Exception e) {
log.warn("execution lifecycle apply failed in page assembly. executionId={}, statusCode={}, error={}",
vo.getId(), vo.getStatusCode(), e.getMessage());
}
});
return voPageResult; return voPageResult;
} }
@@ -220,11 +266,44 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
execution.getStatusCode(), buildExecutionFieldChanges(before, execution), reason); execution.getStatusCode(), buildExecutionFieldChanges(before, execution), reason);
// 双向写入成员变更历史:原 owner 转出 / 新 owner 转入oldOwnerId 为 null 时跳过转出(首次设负责人场景) // 双向写入成员变更历史:原 owner 转出 / 新 owner 转入oldOwnerId 为 null 时跳过转出(首次设负责人场景)
if (oldOwnerId != null) { if (oldOwnerId != null) {
projectExecutionMemberService.writeMemberLog(executionId, oldOwnerId, projectExecutionAssigneeService.writeAssigneeLog(executionId, oldOwnerId,
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_OUT, reason); ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_OUT, reason);
}
projectExecutionAssigneeService.writeAssigneeLog(executionId, reqVO.getNewOwnerId(),
ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN, reason);
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_DELETE)
public void deleteExecution(Long projectId, Long executionId, ProjectExecutionDeleteReqVO reqVO) {
validateEditableProject(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
validateDeleteConfirmText(reqVO.getConfirmText());
if (!Objects.equals(execution.getExecutionName(), reqVO.getExecutionName().trim())) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_DELETE_NAME_MISMATCH);
}
// 仅初始态可删(与项目侧 deleteProject 同款规则)
String initialStatusCode = getInitialExecutionStatusCode();
String fromStatus = execution.getStatusCode();
if (!Objects.equals(fromStatus, initialStatusCode)) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_ALLOW_DELETE);
}
String reason = reqVO.getReason().trim();
int deleteCount = projectExecutionMapper.deleteByIdAndStatus(executionId, fromStatus);
if (deleteCount != 1) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_CONCURRENT_MODIFIED);
}
writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_DELETE, fromStatus, null, null, reason);
}
private void validateDeleteConfirmText(String confirmText) {
String normalizedConfirmText = normalizeNullableText(confirmText);
if (normalizedConfirmText == null
|| !ProjectExecutionConstants.DELETE_CONFIRM_TEXTS.contains(normalizedConfirmText)) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_DELETE_CONFIRM_TEXT_INVALID);
} }
projectExecutionMemberService.writeMemberLog(executionId, reqVO.getNewOwnerId(),
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_IN, reason);
} }
@Override @Override
@@ -236,9 +315,15 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
String actionCode = normalizeRequiredActionCode(reqVO.getActionCode()); String actionCode = normalizeRequiredActionCode(reqVO.getActionCode());
String reason = normalizeNullableText(reqVO.getReason()); String reason = normalizeNullableText(reqVO.getReason());
// 负责人独占动作complete / cancel / pause / resume 必须由执行负责人触发
validateOwnerForAction(execution, actionCode);
String fromStatus = execution.getStatusCode(); String fromStatus = execution.getStatusCode();
ObjectStatusTransitionDO transition = validateExecutionTransition(fromStatus, actionCode, reason); ObjectStatusTransitionDO transition = validateExecutionTransition(fromStatus, actionCode, reason);
String toStatus = transition.getToStatusCode(); String toStatus = transition.getToStatusCode();
// complete 前置:执行下所有任务必须终态
if ("complete".equals(actionCode)) {
validateAllTasksTerminal(executionId);
}
int updateCount = projectExecutionMapper.updateStatusByIdAndStatus(executionId, fromStatus, toStatus, reason); int updateCount = projectExecutionMapper.updateStatusByIdAndStatus(executionId, fromStatus, toStatus, reason);
if (updateCount != 1) { if (updateCount != 1) {
@@ -246,10 +331,18 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
} }
execution.setStatusCode(toStatus); execution.setStatusCode(toStatus);
execution.setLastStatusReason(reason); execution.setLastStatusReason(reason);
applyExecutionActualDateByStatusAction(execution, actionCode, toStatus);
writeExecutionStatusLog(execution, actionCode, fromStatus, toStatus, reason); writeExecutionStatusLog(execution, actionCode, fromStatus, toStatus, reason);
writeExecutionAuditLog(execution, actionCode, fromStatus, toStatus, null, reason); writeExecutionAuditLog(execution, actionCode, fromStatus, toStatus, null, reason);
maybeFillActualDates(execution, fromStatus, toStatus);
// 完成动作:兜底把执行进度刷到 100%
if ("complete".equals(actionCode)) {
forceCompleteProgress(execution);
}
// 取消 / 暂停 / 恢复:级联执行下任务
cascadeTasksIfNeeded(executionId, actionCode, reason);
} }
@VisibleForTesting @VisibleForTesting
@@ -376,50 +469,50 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
} }
} }
private void createExecutionMembers(Long executionId, Long projectId, Set<Long> memberUserIds) { private void createExecutionAssignees(Long executionId, Long projectId, Set<Long> assigneeUserIds) {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
for (Long memberUserId : memberUserIds) { for (Long assigneeUserId : assigneeUserIds) {
validateExecutionMemberProjectScope(projectId, memberUserId); validateExecutionAssigneeProjectScope(projectId, assigneeUserId);
ExecutionMemberDO member = new ExecutionMemberDO(); ExecutionAssigneeDO member = new ExecutionAssigneeDO();
member.setExecutionId(executionId); member.setExecutionId(executionId);
member.setUserId(memberUserId); member.setUserId(assigneeUserId);
member.setJoinedAt(now); member.setJoinedAt(now);
member.setRemovedAt(null); member.setRemovedAt(null);
member.setRemovedReason(null); member.setRemovedReason(null);
executionMemberMapper.insert(member); executionAssigneeMapper.insert(member);
writeExecutionAuditLogByBizId(executionId, ObjectActivityConstants.EXECUTION_MEMBER_ACTION_ADD, writeExecutionAuditLogByBizId(executionId, ObjectActivityConstants.EXECUTION_ASSIGNEE_ACTION_ADD,
null, null, buildExecutionMemberFieldChanges(null, member), null); null, null, buildExecutionAssigneeFieldChanges(null, member), null);
// B 模型:每次创建活跃段同步写一条 join 事件,确保活跃成员都能在变更历史中追溯加入时间 // B 模型:每次创建活跃段同步写一条 join 事件,确保活跃成员都能在变更历史中追溯加入时间
projectExecutionMemberService.writeMemberLog(executionId, memberUserId, projectExecutionAssigneeService.writeAssigneeLog(executionId, assigneeUserId,
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_JOIN, null); ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_JOIN, null);
} }
} }
private void validateExecutionMemberProjectScope(Long projectId, Long userId) { private void validateExecutionAssigneeProjectScope(Long projectId, Long userId) {
UserObjectRoleDO member = userObjectRoleMapper UserObjectRoleDO member = userObjectRoleMapper
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId); .selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId);
if (member == null) { if (member == null) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_INVALID); throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_INVALID);
} }
} }
private Set<Long> normalizeMemberUserIds(List<Long> memberUserIds) { private Set<Long> normalizeAssigneeUserIds(List<Long> assigneeUserIds) {
if (memberUserIds == null || memberUserIds.isEmpty()) { if (assigneeUserIds == null || assigneeUserIds.isEmpty()) {
return Collections.emptySet(); return Collections.emptySet();
} }
Set<Long> normalizedUserIds = new LinkedHashSet<>(); Set<Long> normalizedUserIds = new LinkedHashSet<>();
for (Long memberUserId : memberUserIds) { for (Long assigneeUserId : assigneeUserIds) {
if (memberUserId != null) { if (assigneeUserId != null) {
normalizedUserIds.add(memberUserId); normalizedUserIds.add(assigneeUserId);
} }
} }
return normalizedUserIds; return normalizedUserIds;
} }
private Set<Long> normalizeRequiredMemberUserIds(List<Long> memberUserIds) { private Set<Long> normalizeRequiredAssigneeUserIds(List<Long> assigneeUserIds) {
Set<Long> normalizedUserIds = normalizeMemberUserIds(memberUserIds); Set<Long> normalizedUserIds = normalizeAssigneeUserIds(assigneeUserIds);
if (normalizedUserIds.isEmpty()) { if (normalizedUserIds.isEmpty()) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_REQUIRED); throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_REQUIRED);
} }
return normalizedUserIds; return normalizedUserIds;
} }
@@ -512,18 +605,18 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges); return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges);
} }
private String buildExecutionMemberFieldChanges(ExecutionMemberDO before, ExecutionMemberDO after) { private String buildExecutionAssigneeFieldChanges(ExecutionAssigneeDO before, ExecutionAssigneeDO after) {
Map<String, Object> fieldChanges = new LinkedHashMap<>(); Map<String, Object> fieldChanges = new LinkedHashMap<>();
appendFieldChange(fieldChanges, "executionId", valueOf(before, ExecutionMemberDO::getExecutionId), appendFieldChange(fieldChanges, "executionId", valueOf(before, ExecutionAssigneeDO::getExecutionId),
valueOf(after, ExecutionMemberDO::getExecutionId)); valueOf(after, ExecutionAssigneeDO::getExecutionId));
appendFieldChange(fieldChanges, "userId", valueOf(before, ExecutionMemberDO::getUserId), appendFieldChange(fieldChanges, "userId", valueOf(before, ExecutionAssigneeDO::getUserId),
valueOf(after, ExecutionMemberDO::getUserId)); valueOf(after, ExecutionAssigneeDO::getUserId));
appendFieldChange(fieldChanges, "joinedAt", valueOf(before, ExecutionMemberDO::getJoinedAt), appendFieldChange(fieldChanges, "joinedAt", valueOf(before, ExecutionAssigneeDO::getJoinedAt),
valueOf(after, ExecutionMemberDO::getJoinedAt)); valueOf(after, ExecutionAssigneeDO::getJoinedAt));
appendFieldChange(fieldChanges, "removedAt", valueOf(before, ExecutionMemberDO::getRemovedAt), appendFieldChange(fieldChanges, "removedAt", valueOf(before, ExecutionAssigneeDO::getRemovedAt),
valueOf(after, ExecutionMemberDO::getRemovedAt)); valueOf(after, ExecutionAssigneeDO::getRemovedAt));
appendFieldChange(fieldChanges, "removedReason", valueOf(before, ExecutionMemberDO::getRemovedReason), appendFieldChange(fieldChanges, "removedReason", valueOf(before, ExecutionAssigneeDO::getRemovedReason),
valueOf(after, ExecutionMemberDO::getRemovedReason)); valueOf(after, ExecutionAssigneeDO::getRemovedReason));
return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges); return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges);
} }
@@ -531,7 +624,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return execution == null ? null : getter.apply(execution); return execution == null ? null : getter.apply(execution);
} }
private <T> T valueOf(ExecutionMemberDO member, Function<ExecutionMemberDO, T> getter) { private <T> T valueOf(ExecutionAssigneeDO member, Function<ExecutionAssigneeDO, T> getter) {
return member == null ? null : getter.apply(member); return member == null ? null : getter.apply(member);
} }
@@ -578,8 +671,9 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
} }
private void applyLifecycle(ProjectExecutionRespVO respVO) { private void applyLifecycle(ProjectExecutionRespVO respVO) {
// 传入 ownerId 用于 availableActions 的 owner-only 字段硬卡过滤spec §7.1
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView lifecycle = ProjectExecutionStatusViewService.ProjectExecutionLifecycleView lifecycle =
projectExecutionStatusViewService.getLifecycle(respVO.getStatusCode()); projectExecutionStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId());
respVO.setStatusName(lifecycle.statusName()); respVO.setStatusName(lifecycle.statusName());
respVO.setTerminal(lifecycle.terminal()); respVO.setTerminal(lifecycle.terminal());
respVO.setAllowEdit(lifecycle.allowEdit()); respVO.setAllowEdit(lifecycle.allowEdit());
@@ -595,6 +689,76 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return user == null ? null : user.getNickname(); return user == null ? null : user.getNickname();
} }
private BigDecimal loadExecutionProgress(Long projectId, Long executionId) {
return normalizeProgress(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId));
}
private void fillExecutionProgress(Long projectId, List<ProjectExecutionRespVO> list) {
Set<Long> executionIds = list.stream()
.map(ProjectExecutionRespVO::getId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, BigDecimal> progressMap = loadExecutionProgressMap(projectId, executionIds);
list.forEach(vo -> vo.setProgressRate(progressMap.getOrDefault(vo.getId(), normalizeProgress(null))));
}
/**
* 列表口径批量聚合:一次 GROUP BY 取本页所有执行的一级任务平均进度。
* 未命中的 executionId执行下无一级任务不入 map由调用方 normalizeProgress 兜底为 0.00。
*/
private Map<Long, BigDecimal> loadExecutionProgressMap(Long projectId, Collection<Long> executionIds) {
if (executionIds == null || executionIds.isEmpty()) {
return Collections.emptyMap();
}
List<Map<String, Object>> rows = projectTaskMapper
.selectRootTaskAvgProgressGroupByExecutionIds(projectId, executionIds);
if (rows == null || rows.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, BigDecimal> result = new HashMap<>(rows.size());
for (Map<String, Object> row : rows) {
if (row == null) {
continue;
}
Long executionId = toLong(row.getOrDefault("executionId", row.get("execution_id")));
BigDecimal progress = toBigDecimal(row.getOrDefault("progressRate", row.get("progress_rate")));
if (executionId != null) {
result.put(executionId, normalizeProgress(progress));
}
}
return result;
}
private Long toLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String text && StringUtils.hasText(text)) {
return Long.valueOf(text.trim());
}
return null;
}
private BigDecimal toBigDecimal(Object value) {
if (value instanceof BigDecimal decimal) {
return decimal;
}
if (value instanceof Number number) {
return new BigDecimal(number.toString());
}
if (value instanceof String text && StringUtils.hasText(text)) {
return new BigDecimal(text.trim());
}
return null;
}
private BigDecimal normalizeProgress(BigDecimal progress) {
if (progress == null) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
return progress.setScale(2, RoundingMode.HALF_UP);
}
private Map<Long, String> loadOwnerNicknameMap(Collection<Long> ownerIds) { private Map<Long, String> loadOwnerNicknameMap(Collection<Long> ownerIds) {
if (ownerIds == null || ownerIds.isEmpty()) { if (ownerIds == null || ownerIds.isEmpty()) {
return Collections.emptyMap(); return Collections.emptyMap();
@@ -608,20 +772,173 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
return nicknameMap; return nicknameMap;
} }
private void applyExecutionActualDateByStatusAction(ProjectExecutionDO execution, String actionCode, String toStatus) { /**
* 通用语义位驱动的实际日期回填:
* - 首次离开初始态fromStatus.initialFlag=true且 actualStartDate 未填时,写今天
* - 进入终态toStatus.terminalFlag=true且 actualEndDate 未填时,写今天
* 与任务侧 maybeFillActualDates 同款风格,消除原 applyExecutionActualDateByStatusAction 的硬编码 actionCode 判断。
*/
private void maybeFillActualDates(ProjectExecutionDO execution, String fromStatus, String toStatus) {
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now();
boolean changed = false; boolean changed = false;
if ("start".equals(actionCode) && execution.getActualStartDate() == null) { if (execution.getActualStartDate() == null) {
execution.setActualStartDate(today); ObjectStatusModelDO fromModel = objectStatusModelMapper
changed = true; .selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, fromStatus);
if (fromModel != null && Boolean.TRUE.equals(fromModel.getInitialFlag())) {
execution.setActualStartDate(today);
changed = true;
}
} }
if ("completed".equals(toStatus) && execution.getActualEndDate() == null) { if (execution.getActualEndDate() == null) {
execution.setActualEndDate(today); ObjectStatusModelDO toModel = objectStatusModelMapper
changed = true; .selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, toStatus);
if (toModel != null && Boolean.TRUE.equals(toModel.getTerminalFlag())) {
execution.setActualEndDate(today);
changed = true;
}
} }
if (changed) { if (changed) {
projectExecutionMapper.updateById(execution); projectExecutionMapper.updateById(execution);
} }
} }
/**
* 完成动作兜底:把执行进度刷到 100%。
* 正常流程下前端会确保点完成时进度已经是 100%;此处为防止前端绕过 / bug 导致脏数据。
*/
private void forceCompleteProgress(ProjectExecutionDO execution) {
BigDecimal full = BigDecimal.valueOf(100);
BigDecimal current = execution.getProgressRate() == null ? BigDecimal.ZERO : execution.getProgressRate();
if (full.compareTo(current) == 0) {
return;
}
execution.setProgressRate(full);
projectExecutionMapper.updateById(execution);
}
/**
* 负责人独占动作校验complete / cancel / pause / resume 必须由执行负责人触发。
* 系统级别的 internalAutoStartExecutionByTask 不经此校验。
*/
private void validateOwnerForAction(ProjectExecutionDO execution, String actionCode) {
if (!isOwnerOnlyAction(actionCode)) {
return;
}
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
if (!Objects.equals(loginUserId, execution.getOwnerId())) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_OWNER_ONLY,
resolveActionDisplayName(actionCode));
}
}
private boolean isOwnerOnlyAction(String actionCode) {
return "complete".equals(actionCode)
|| "cancel".equals(actionCode)
|| "pause".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
*/
private void validateAllTasksTerminal(Long executionId) {
List<String> terminalStatusCodes = objectStatusModelMapper
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
Integer openTaskCount = projectTaskMapper.countByExecutionIdNotInStatus(executionId, terminalStatusCodes);
if (openTaskCount != null && openTaskCount > 0) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_COMPLETE_TASKS_REQUIRED);
}
}
/**
* 级联触发:根据 actionCode 调任务侧 internal 入口批量处理执行下任务。
*/
private void cascadeTasksIfNeeded(Long executionId, String actionCode, String executionReason) {
switch (actionCode) {
case "cancel" -> projectTaskService.cascadeCancelTasksByExecutionId(executionId, executionReason);
case "pause" -> projectTaskService.cascadePauseTasksByExecutionId(executionId, executionReason);
case "resume" -> projectTaskService.cascadeResumeTasksByExecutionId(executionId, executionReason);
default -> {
// 其他动作不级联
}
}
}
/**
* 由任务 auto_start 事件驱动的执行自动开始。
* 仅在执行处于初始态时触发;失败均 WARN 静默 return不抛错保证不阻断任务事件主路径
* 成功后联动项目 auto_start同事务若项目联动抛错则整体回滚 —— 与任务侧 createTask 调用 autoStartProjectIfPending 同款)。
*/
private void internalAutoStartExecutionByTask(Long executionId) {
if (executionId == null) {
return;
}
ProjectExecutionDO current = projectExecutionMapper.selectById(executionId);
if (current == null) {
return;
}
String fromStatus = current.getStatusCode();
ObjectStatusModelDO fromModel = objectStatusModelMapper
.selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, fromStatus);
if (fromModel == null || !Boolean.TRUE.equals(fromModel.getInitialFlag())) {
return;
}
String actionCode = "auto_start";
ObjectStatusTransitionDO transition = objectStatusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(ProjectExecutionConstants.OBJECT_TYPE, fromStatus, actionCode);
if (transition == null) {
log.warn("execution auto-start skipped: transition not found. executionId={}, fromStatus={}",
executionId, fromStatus);
return;
}
String toStatus = transition.getToStatusCode();
String reason = "由任务推进自动触发";
int updateCount = projectExecutionMapper.updateStatusByIdAndStatus(executionId, fromStatus, toStatus, reason);
if (updateCount != 1) {
log.warn("execution auto-start skipped: concurrent modification. executionId={}", executionId);
return;
}
current.setStatusCode(toStatus);
current.setLastStatusReason(reason);
writeExecutionStatusLog(current, actionCode, fromStatus, toStatus, reason);
writeExecutionAuditLog(current, actionCode, fromStatus, toStatus, null, reason);
maybeFillActualDates(current, fromStatus, toStatus);
// 联动项目 auto_start项目仍在 pending 时跟着进 active
projectService.autoStartProjectIfPending(current.getProjectId(),
ObjectActivityConstants.PROJECT_TRIGGER_EXECUTION_AUTO_START);
}
@Override
public void onTaskStatusChanged(Long executionId, Long taskId, String fromStatus, String toStatus,
String actionCode, String reason) {
log.info("[onTaskStatusChanged] executionId={}, taskId={}, action={}, status: {} -> {}, reason={}",
executionId, taskId, actionCode, fromStatus, toStatus, reason);
if (executionId == null) {
return;
}
// 任务 auto_start执行如果还在 pending自动跟着进入进行中事件驱动§8.1
if ("auto_start".equals(actionCode)) {
try {
internalAutoStartExecutionByTask(executionId);
} catch (Exception e) {
// 副作用失败不阻断任务事件主路径
log.warn("execution auto-start failed during onTaskStatusChanged. executionId={}, error={}",
executionId, e.getMessage());
}
}
// complete / cancel当前仅 INFO 日志不立即响应§8.1 拉模式:项目主动点完成时统一扫表校验)
}
} }

View File

@@ -1,5 +1,7 @@
package com.njcn.rdms.module.project.service.project.execution; package com.njcn.rdms.module.project.service.project.execution;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants; import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionLifecycleActionRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionLifecycleActionRespVO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO; import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
@@ -12,18 +14,38 @@ import org.springframework.stereotype.Service;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Set;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
* 执行状态生命周期视图。
* <p>
* {@code getLifecycle} 返回的 {@code availableActions} 按 spec §7.1 实现约束过滤:
* <ol>
* <li>剔除系统级动作 {@code auto_start}(不出现在 UI 按钮)。</li>
* <li>对 owner-only 状态推进动作complete / cancel / pause / resume按 {@code execution.ownerId == currentUserId} 字段硬卡过滤,
* 与 {@link ProjectExecutionServiceImpl} 中 {@code validateOwnerForAction} 同款判定。</li>
* </ol>
* 非状态动作delete / change-owner / update / assignee的权限码 / 字段过滤未纳入本字段,
* 前端按各动作对应权限码与 owner 字段独立判断spec §6.5 允许条件矩阵);
* 全量化纳入 {@code availableActions} 留待后续阶段统一改造。
*/
@Service @Service
public class ProjectExecutionStatusViewService { public class ProjectExecutionStatusViewService {
/**
* 状态推进动作中要求 owner-only 字段硬卡的集合,与 ProjectExecutionServiceImpl#validateOwnerForAction 一致。
*/
private static final Set<String> OWNER_ONLY_ACTIONS = Set.of("complete", "cancel", "pause", "resume");
@Resource @Resource
private ObjectStatusModelMapper objectStatusModelMapper; private ObjectStatusModelMapper objectStatusModelMapper;
@Resource @Resource
private ObjectStatusTransitionMapper objectStatusTransitionMapper; private ObjectStatusTransitionMapper objectStatusTransitionMapper;
public ProjectExecutionLifecycleView getLifecycle(String statusCode) { public ProjectExecutionLifecycleView getLifecycle(String statusCode, Long ownerId) {
ObjectStatusModelDO statusModel = objectStatusModelMapper ObjectStatusModelDO statusModel = objectStatusModelMapper
.selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, statusCode); .selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, statusCode);
if (statusModel == null) { if (statusModel == null) {
@@ -33,23 +55,31 @@ public class ProjectExecutionStatusViewService {
statusModel.getStatusName(), statusModel.getStatusName(),
statusModel.getTerminalFlag(), statusModel.getTerminalFlag(),
statusModel.getAllowEdit(), statusModel.getAllowEdit(),
buildAvailableActions(statusCode) buildAvailableActions(statusCode, ownerId)
); );
} }
private List<ProjectExecutionLifecycleActionRespVO> buildAvailableActions(String statusCode) { private List<ProjectExecutionLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId) {
List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper
.selectListByObjectTypeAndFromStatus(ProjectExecutionConstants.OBJECT_TYPE, statusCode); .selectListByObjectTypeAndFromStatus(ProjectExecutionConstants.OBJECT_TYPE, statusCode);
if (transitions == null || transitions.isEmpty()) { if (transitions == null || transitions.isEmpty()) {
return Collections.emptyList(); return Collections.emptyList();
} }
return transitions.stream().map(transition -> { Long currentUserId = SecurityFrameworkUtils.getLoginUserId();
ProjectExecutionLifecycleActionRespVO action = new ProjectExecutionLifecycleActionRespVO(); return transitions.stream()
action.setActionCode(transition.getActionCode()); // 剔除系统级动作 auto_start由后端业务事件触发不暴露给前端按钮
action.setActionName(transition.getActionName()); .filter(transition -> !ObjectActivityConstants.PROJECT_ACTION_AUTO_START.equals(transition.getActionCode()))
action.setNeedReason(transition.getNeedReason()); // owner-only 字段硬卡:非负责人本人时不返回 complete / cancel / pause / resume 动作
return action; .filter(transition -> !OWNER_ONLY_ACTIONS.contains(transition.getActionCode())
}).toList(); || (currentUserId != null && Objects.equals(ownerId, currentUserId)))
.map(transition -> {
ProjectExecutionLifecycleActionRespVO action = new ProjectExecutionLifecycleActionRespVO();
action.setActionCode(transition.getActionCode());
action.setActionName(transition.getActionName());
action.setNeedReason(transition.getNeedReason());
return action;
})
.toList();
} }
public record ProjectExecutionLifecycleView(String statusName, public record ProjectExecutionLifecycleView(String statusName,

View File

@@ -0,0 +1,31 @@
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

@@ -0,0 +1,28 @@
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

@@ -0,0 +1,76 @@
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

@@ -1,6 +1,7 @@
package com.njcn.rdms.module.project.service.project.task; package com.njcn.rdms.module.project.service.project.task;
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.task.vo.ProjectTaskDeleteReqVO;
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.ProjectTaskRespVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskSaveReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskSaveReqVO;
@@ -32,4 +33,54 @@ public interface ProjectTaskService {
void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO); void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO);
/**
* 删除任务(软删 + CAS 状态校验 + 三重确认 + 执行 owner 字段硬卡)。
* <p>仅初始态可删;权限码 project:task:delete 作菜单入口可见度;实际拦截以 execution.ownerId == currentUserId 为准spec §5.1 上级硬卡范式)。
* <p>不级联软删任务下挂的工时 / 协办人 / 子任务(通过 deleted=0 自然不可见)。
*/
void deleteTask(Long projectId, Long executionId, Long taskId, ProjectTaskDeleteReqVO reqVO);
/**
* 以"任务负责人本人最新一条工时"的进度为准,同步到任务自身 progressRate 并触发父任务 AVG 重算。
* 由工时模块在 owner 维度的 worklog create/update/delete 后调用。
* 若 owner 当前在该任务下不存在任何工时记录,则保留 task.progress_rate 原值不动(避免删除场景下意外归零)。
* 仅对叶子任务生效;父任务由子任务汇总驱动,不走该路径。
*/
void syncOwnerProgressFromLatestWorklog(Long taskId);
/**
* 由工时填报5.11触发的任务自动开始当任务当前处于初始态initialFlag=true
* 走一次 {@link com.njcn.rdms.module.project.constant.ObjectActivityConstants#TASK_ACTION_AUTO_START}
* 动作把任务推进到下一状态,并复用 5.5 的钩子写入 actualStartDate / 状态日志 / 审计日志。
* <p>
* 调用方必须已自行确认登录人是 task.ownerId仅 owner 工时驱动状态推进,与 progressRate 同步口径一致)。
* 本方法**不走** @CheckObjectPermission因为 5.11 持有的是 PERMISSION_WORKLOG
* 而 5.5 changeTaskStatus 持有的是 PERMISSION_STATUS两者权限边界不同。
* <p>
* 容错若任务当前状态非初始态、transition 配置缺失/禁用、并发被抢占affected=0等情况
* 本方法静默 return仅打 WARN 日志),不影响工时主流程入库。
*/
void internalAutoStartByWorklog(ProjectTaskDO task);
/**
* 由执行 cancel 触发的级联取消指定执行下所有未终态的顶层任务parentTaskId IS NULL
* 顶层任务自身的内部链路会再级联自己的子任务,整棵子树通过链式实现。
* 跳过任务侧的 owner 校验(系统行为),但复用 doInternalChangeTaskStatus 的事务 / CAS / 审计 / 实际日期回填等。
* 调用方ProjectExecutionServiceImpl 在 cancel 成功后追加。
*/
void cascadeCancelTasksByExecutionId(Long executionId, String executionReason);
/**
* 由执行 pause 触发的级联:暂停指定执行下所有顶层任务中状态为 active 的节点。
* 顶层任务自身会再级联子任务,整棵子树同步暂停。
*/
void cascadePauseTasksByExecutionId(Long executionId, String executionReason);
/**
* 由执行 resume 触发的级联:恢复指定执行下所有顶层任务中状态为 paused 的节点。
* 顶层任务自身会再级联子任务,整棵子树同步恢复。
* "误恢复"边界(任务级 paused 与父级 paused 不区分)与任务侧 §4.4 同款,已识别并接受。
*/
void cascadeResumeTasksByExecutionId(Long executionId, String executionReason);
} }

View File

@@ -4,11 +4,13 @@ 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;
import com.njcn.rdms.framework.common.util.object.BeanUtils; import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
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.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.constant.ProjectTaskConstants; import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeleteReqVO;
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.ProjectTaskRespVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskSaveReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskSaveReqVO;
@@ -19,11 +21,12 @@ import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExec
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.project.task.ProjectTaskStatusLogDO; import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskStatusLogDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO; import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskWorklogDO;
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.dataobject.status.ObjectStatusTransitionDO; 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.audit.BizAuditLogMapper;
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.execution.ExecutionMemberMapper; 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.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.project.task.ProjectTaskStatusLogMapper; import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskStatusLogMapper;
@@ -31,13 +34,20 @@ import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper;
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;
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.security.annotation.CheckObjectPermission; import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
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.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.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;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -65,6 +75,7 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
* 项目任务 Service 实现类。 * 项目任务 Service 实现类。
*/ */
@Service @Service
@Slf4j
public class ProjectTaskServiceImpl implements ProjectTaskService { public class ProjectTaskServiceImpl implements ProjectTaskService {
@Resource @Resource
@@ -72,7 +83,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Resource @Resource
private ProjectExecutionMapper projectExecutionMapper; private ProjectExecutionMapper projectExecutionMapper;
@Resource @Resource
private ExecutionMemberMapper executionMemberMapper; private ExecutionAssigneeMapper executionAssigneeMapper;
@Resource @Resource
private ProjectTaskMapper projectTaskMapper; private ProjectTaskMapper projectTaskMapper;
@Resource @Resource
@@ -88,6 +99,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Resource @Resource
private ProjectService projectService; private ProjectService projectService;
@Resource @Resource
private ProjectExecutionService projectExecutionService;
@Resource
private ProjectTaskStatusViewService projectTaskStatusViewService; private ProjectTaskStatusViewService projectTaskStatusViewService;
@Resource @Resource
private TaskAssigneeService taskAssigneeService; private TaskAssigneeService taskAssigneeService;
@@ -95,21 +108,31 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
private TaskWorklogService taskWorklogService; private TaskWorklogService taskWorklogService;
@Resource @Resource
private AdminUserApi adminUserApi; private AdminUserApi adminUserApi;
@Resource
private AttachmentFileIdResolver attachmentFileIdResolver;
@Resource
private ProjectObjectAuthorizationService projectObjectAuthorizationService;
@Resource
private VisibilityScopeResolver visibilityScopeResolver;
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_CREATE)
public Long createTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) { public Long createTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) {
validateEditableProject(projectId); validateEditableProject(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
validateExecutionAllowEdit(execution); validateExecutionAllowEdit(execution);
ProjectTaskDO parentTask = validateParentTask(projectId, executionId, reqVO.getParentTaskId()); ProjectTaskDO parentTask = validateParentTask(projectId, executionId, reqVO.getParentTaskId());
// 上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码)"建;子任务由"父任务负责人 OR 项目负责人(权限码)"建
Long upperOwnerId = parentTask == null ? execution.getOwnerId() : parentTask.getOwnerId();
projectObjectAuthorizationService.checkOwnerOrProjectPermission(
projectId, upperOwnerId, ProjectTaskConstants.PERMISSION_CREATE);
Long ownerId = resolveTaskOwner(reqVO.getOwnerId(), parentTask); Long ownerId = resolveTaskOwner(reqVO.getOwnerId(), parentTask);
validateOwnerIsActiveExecutionMember(executionId, ownerId); validateOwnerIsActiveExecutionAssignee(executionId, ownerId);
validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期"); validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
// 叶子转父校验:当前 parentTask 还是叶子(无任何子任务)时,要求其进度=0 且无工时记录 // 叶子转父校验:当前 parentTask 还是叶子(无任何子任务)时,要求其进度=0 且无工时记录
validateLeafToParentSplit(parentTask); validateLeafToParentSplit(parentTask);
AttachmentValidator.validate(reqVO.getAttachments());
attachmentFileIdResolver.resolve(reqVO.getAttachments());
ProjectTaskDO task = new ProjectTaskDO(); ProjectTaskDO task = new ProjectTaskDO();
task.setProjectId(projectId); task.setProjectId(projectId);
@@ -118,12 +141,13 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
task.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle())); task.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle()));
task.setOwnerId(ownerId); task.setOwnerId(ownerId);
task.setStatusCode(getInitialTaskStatusCode()); task.setStatusCode(getInitialTaskStatusCode());
// 新建任务自身一定是叶子(尚无子任务),按 owner 手填值落库(默认 0 // 任务进度统一由 worklog 驱动;新建任务强制为 0
task.setProgressRate(normalizeProgress(reqVO.getProgressRate())); task.setProgressRate(BigDecimal.ZERO);
task.setPlannedStartDate(reqVO.getPlannedStartDate()); task.setPlannedStartDate(reqVO.getPlannedStartDate());
task.setPlannedEndDate(reqVO.getPlannedEndDate()); task.setPlannedEndDate(reqVO.getPlannedEndDate());
// 实际开始/结束日期不允许人工填写,由 changeTaskStatus 在状态流转时推导 // 实际开始/结束日期不允许人工填写,由 changeTaskStatus 在状态流转时推导
task.setTaskDesc(normalizeNullableText(reqVO.getTaskDesc())); task.setTaskDesc(normalizeNullableText(reqVO.getTaskDesc()));
task.setAttachments(reqVO.getAttachments());
projectTaskMapper.insert(task); projectTaskMapper.insert(task);
// 创建任务时初始化协办人列表(同事务,任一项失败整笔回滚;列表为空跳过) // 创建任务时初始化协办人列表(同事务,任一项失败整笔回滚;列表为空跳过)
@@ -142,8 +166,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_UPDATE)
public void updateTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) { public void updateTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) {
if (reqVO.getId() == null) { if (reqVO.getId() == null) {
throw invalidParamException("任务编号不能为空"); throw invalidParamException("任务编号不能为空");
@@ -153,12 +175,19 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
validateExecutionAllowEdit(execution); validateExecutionAllowEdit(execution);
ProjectTaskDO task = validateTaskExists(projectId, executionId, reqVO.getId()); ProjectTaskDO task = validateTaskExists(projectId, executionId, reqVO.getId());
validateTaskAllowEdit(task); validateTaskAllowEdit(task);
// 上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码)"改;子任务由"父任务负责人 OR 项目负责人(权限码)"改
// 按当前 task 的 parentTaskId修改前判定改父亲属于改动结果授权按改动前形态裁决
Long upperOwnerId = task.getParentTaskId() == null
? execution.getOwnerId()
: projectTaskMapper.selectById(task.getParentTaskId()).getOwnerId();
projectObjectAuthorizationService.checkOwnerOrProjectPermission(
projectId, upperOwnerId, ProjectTaskConstants.PERMISSION_UPDATE);
ProjectTaskDO parentTask = validateParentTask(projectId, executionId, reqVO.getParentTaskId()); ProjectTaskDO parentTask = validateParentTask(projectId, executionId, reqVO.getParentTaskId());
if (parentTask != null && Objects.equals(parentTask.getId(), task.getId())) { if (parentTask != null && Objects.equals(parentTask.getId(), task.getId())) {
throw exception(ErrorCodeConstants.PROJECT_TASK_PARENT_INVALID); throw exception(ErrorCodeConstants.PROJECT_TASK_PARENT_INVALID);
} }
Long ownerId = resolveTaskOwner(reqVO.getOwnerId(), parentTask); Long ownerId = resolveTaskOwner(reqVO.getOwnerId(), parentTask);
validateOwnerIsActiveExecutionMember(executionId, ownerId); validateOwnerIsActiveExecutionAssignee(executionId, ownerId);
validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期"); validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
// 父任务变更校验:迁到新父时,新父若仍是叶子(即将变父),要求新父进度=0 且无工时 // 父任务变更校验:迁到新父时,新父若仍是叶子(即将变父),要求新父进度=0 且无工时
Long oldParentId = task.getParentTaskId(); Long oldParentId = task.getParentTaskId();
@@ -166,18 +195,19 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
if (!Objects.equals(oldParentId, newParentId)) { if (!Objects.equals(oldParentId, newParentId)) {
validateLeafToParentSplit(parentTask); validateLeafToParentSplit(parentTask);
} }
// 进度入参根据"当前任务是否已是父任务"决定:父任务拒绝手填变更,叶子任务沿用原规则 AttachmentValidator.validate(reqVO.getAttachments());
BigDecimal resolvedProgress = resolveProgressRateOnUpdate(task, reqVO.getProgressRate()); attachmentFileIdResolver.resolve(reqVO.getAttachments());
// 任务进度由 worklog 驱动owner 填工时回写 + 父任务 AVG 汇总),编辑任务接口不接受进度入参
ProjectTaskDO before = cloneTask(task); ProjectTaskDO before = cloneTask(task);
task.setParentTaskId(newParentId); task.setParentTaskId(newParentId);
task.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle())); task.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle()));
task.setOwnerId(ownerId); task.setOwnerId(ownerId);
task.setProgressRate(resolvedProgress);
task.setPlannedStartDate(reqVO.getPlannedStartDate()); task.setPlannedStartDate(reqVO.getPlannedStartDate());
task.setPlannedEndDate(reqVO.getPlannedEndDate()); task.setPlannedEndDate(reqVO.getPlannedEndDate());
// 实际开始/结束日期不允许人工编辑,保留原值由状态流转维护 // 实际开始/结束日期不允许人工编辑,保留原值由状态流转维护
task.setTaskDesc(normalizeNullableText(reqVO.getTaskDesc())); task.setTaskDesc(normalizeNullableText(reqVO.getTaskDesc()));
task.setAttachments(reqVO.getAttachments());
projectTaskMapper.updateById(task); projectTaskMapper.updateById(task);
// 进度联动:旧父链 + 新父链都需要重算(父任务不可手填进度,由本逻辑统一刷新) // 进度联动:旧父链 + 新父链都需要重算(父任务不可手填进度,由本逻辑统一刷新)
@@ -194,6 +224,122 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
task.getStatusCode(), buildTaskFieldChanges(before, task), null); task.getStatusCode(), buildTaskFieldChanges(before, task), null);
} }
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteTask(Long projectId, Long executionId, Long taskId, ProjectTaskDeleteReqVO reqVO) {
validateEditableProject(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
validateExecutionAllowEdit(execution);
ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
// 上级硬卡:一级任务由"执行负责人 OR 项目负责人(权限码)"删;子任务由"父任务负责人 OR 项目负责人(权限码)"删
Long upperOwnerId = task.getParentTaskId() == null
? execution.getOwnerId()
: projectTaskMapper.selectById(task.getParentTaskId()).getOwnerId();
projectObjectAuthorizationService.checkOwnerOrProjectPermission(
projectId, upperOwnerId, ProjectTaskConstants.PERMISSION_DELETE);
validateDeleteConfirmText(reqVO.getConfirmText());
if (!Objects.equals(task.getTaskTitle(), reqVO.getTaskName().trim())) {
throw exception(ErrorCodeConstants.PROJECT_TASK_DELETE_NAME_MISMATCH);
}
// 仅初始态可删
String fromStatus = task.getStatusCode();
ObjectStatusModelDO statusModel = objectStatusModelMapper
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, fromStatus);
if (statusModel == null || !Boolean.TRUE.equals(statusModel.getInitialFlag())) {
throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_ALLOW_DELETE);
}
String reason = reqVO.getReason().trim();
int deleteCount = projectTaskMapper.deleteByIdAndStatus(taskId, fromStatus);
if (deleteCount != 1) {
throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_CONCURRENT_MODIFIED);
}
writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_DELETE, fromStatus, null, null, reason);
}
private void validateDeleteConfirmText(String confirmText) {
String normalizedConfirmText = normalizeNullableText(confirmText);
if (normalizedConfirmText == null
|| !ProjectTaskConstants.DELETE_CONFIRM_TEXTS.contains(normalizedConfirmText)) {
throw exception(ErrorCodeConstants.PROJECT_TASK_DELETE_CONFIRM_TEXT_INVALID);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void syncOwnerProgressFromLatestWorklog(Long taskId) {
if (taskId == null) {
return;
}
ProjectTaskDO task = projectTaskMapper.selectById(taskId);
if (task == null || task.getOwnerId() == null) {
return;
}
// 仅对叶子任务生效:父任务进度由子任务 AVG 汇总,不接受 worklog 驱动
if (projectTaskMapper.countChildrenByParentTaskId(taskId) > 0) {
return;
}
TaskWorklogDO latest = taskWorklogMapper.selectLatestByTaskIdAndUserId(taskId, task.getOwnerId());
// owner 已无任何工时记录:保留 task.progress_rate 原值不动(不归零)
if (latest == null) {
return;
}
BigDecimal newProgress = normalizeProgress(latest.getProgressRate());
if (progressNumericallyEquals(newProgress, task.getProgressRate())) {
return;
}
projectTaskMapper.updateProgressRateById(taskId, newProgress);
if (task.getParentTaskId() != null) {
recalcParentProgressFrom(task.getParentTaskId());
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void internalAutoStartByWorklog(ProjectTaskDO task) {
if (task == null || task.getId() == null) {
return;
}
// 二次 reload调用方传入的是工时入库前的快照以 DB 现状为准判定是否需要自动开始
ProjectTaskDO current = projectTaskMapper.selectById(task.getId());
if (current == null) {
return;
}
String fromStatus = current.getStatusCode();
// 仅在初始态触发:避免对 active/paused/已结束等状态产生副作用
ObjectStatusModelDO fromModel = objectStatusModelMapper
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, fromStatus);
if (fromModel == null || !Boolean.TRUE.equals(fromModel.getInitialFlag())) {
return;
}
String actionCode = ObjectActivityConstants.TASK_ACTION_AUTO_START;
ObjectStatusTransitionDO transition = objectStatusTransitionMapper
.selectByObjectTypeAndFromStatusAndAction(ProjectTaskConstants.OBJECT_TYPE, fromStatus, actionCode);
if (transition == null) {
// transition 配置缺失/被禁用/已删除:静默不影响工时入库,仅 WARN 让运维感知配置漂移
log.warn("task auto-start by worklog skipped: transition not found or disabled. taskId={}, fromStatus={}, actionCode={}",
current.getId(), fromStatus, actionCode);
return;
}
String toStatus = transition.getToStatusCode();
String reason = "由工时填报自动触发";
int updateCount = projectTaskMapper.updateStatusByIdAndStatus(current.getId(), fromStatus, toStatus, reason);
if (updateCount != 1) {
// 并发抢占(已被别的链路推进):静默 return
log.warn("task auto-start by worklog skipped: concurrent modification on status. taskId={}, expectedFromStatus={}",
current.getId(), fromStatus);
return;
}
current.setStatusCode(toStatus);
current.setLastStatusReason(reason);
writeTaskStatusLog(current, actionCode, fromStatus, toStatus, reason);
writeTaskAuditLog(current, actionCode, fromStatus, toStatus, null, reason);
maybeFillActualDates(current, fromStatus, toStatus);
// 通知执行:任务自动开始也属于"任务状态变更"事件,按 §6.4 触发表上报
projectExecutionService.onTaskStatusChanged(
current.getExecutionId(), current.getId(), fromStatus, toStatus, actionCode, reason);
}
@Override @Override
public ProjectTaskDO getTask(Long projectId, Long executionId, Long taskId) { public ProjectTaskDO getTask(Long projectId, Long executionId, Long taskId) {
validateExecutionExists(projectId, executionId); validateExecutionExists(projectId, executionId);
@@ -202,19 +348,54 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Override @Override
public PageResult<ProjectTaskDO> getTaskPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) { public PageResult<ProjectTaskDO> getTaskPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) {
validateExecutionExists(projectId, executionId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
return projectTaskMapper.selectPageByExecutionId(projectId, executionId, reqVO); VisibilityScope scope = computeTaskScope(projectId, executionId, execution);
return projectTaskMapper.selectPageByExecutionId(projectId, executionId, scope, 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
public ProjectTaskRespVO getTaskRespVO(Long projectId, Long executionId, Long taskId) { public ProjectTaskRespVO getTaskRespVO(Long projectId, Long executionId, Long taskId) {
ProjectTaskDO task = getTask(projectId, executionId, taskId); // 内联 validate便于接住 execution 供前端 executionOwnerId 字段使用
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
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()));
respVO.setAssignees(buildAssigneeViews(taskAssigneeService respVO.setAssignees(buildAssigneeViews(taskAssigneeService
.loadActiveAssigneesGroupedByTaskId(List.of(task.getId())).getOrDefault(task.getId(), List.of()))); .loadActiveAssigneesGroupedByTaskId(List.of(task.getId())).getOrDefault(task.getId(), List.of())));
respVO.setTotalSpentMinutes(taskWorklogService.sumDurationByTaskId(task.getId())); respVO.setTotalSpentHours(taskWorklogService.sumDurationByTaskId(task.getId()));
respVO.setExecutionOwnerId(execution.getOwnerId());
if (task.getParentTaskId() != null) {
ProjectTaskDO parentTask = projectTaskMapper.selectById(task.getParentTaskId());
respVO.setParentTaskOwnerId(parentTask == null ? null : parentTask.getOwnerId());
}
return respVO; return respVO;
} }
@@ -231,20 +412,42 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, List<TaskAssigneeDO>> assigneeMap = taskAssigneeService Map<Long, List<TaskAssigneeDO>> assigneeMap = taskAssigneeService
.loadActiveAssigneesGroupedByTaskId(taskIds); .loadActiveAssigneesGroupedByTaskId(taskIds);
Map<Long, Long> spentMinutesMap = taskWorklogService.sumDurationGroupedByTaskIds(taskIds); Map<Long, BigDecimal> spentHoursMap = taskWorklogService.sumDurationGroupedByTaskIds(taskIds);
Set<Long> userIdsToResolve = list.stream() Set<Long> userIdsToResolve = list.stream()
.map(ProjectTaskRespVO::getOwnerId) .map(ProjectTaskRespVO::getOwnerId)
.filter(Objects::nonNull) .filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new)); .collect(Collectors.toCollection(LinkedHashSet::new));
assigneeMap.values().forEach(items -> items.forEach(a -> userIdsToResolve.add(a.getUserId()))); assigneeMap.values().forEach(items -> items.forEach(a -> userIdsToResolve.add(a.getUserId())));
Map<Long, String> nicknameMap = loadOwnerNicknameMap(userIdsToResolve); 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
ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
Long executionOwnerId = execution == null ? null : execution.getOwnerId();
list.forEach(vo -> { list.forEach(vo -> {
vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId())); vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId()));
List<TaskAssigneeDO> activeList = assigneeMap.getOrDefault(vo.getId(), List.of()); List<TaskAssigneeDO> activeList = assigneeMap.getOrDefault(vo.getId(), List.of());
vo.setAssignees(activeList.stream() vo.setAssignees(activeList.stream()
.map(a -> toAssigneeView(a, nicknameMap.get(a.getUserId()))) .map(a -> toAssigneeView(a, nicknameMap.get(a.getUserId())))
.collect(Collectors.toList())); .collect(Collectors.toList()));
vo.setTotalSpentMinutes(spentMinutesMap.getOrDefault(vo.getId(), 0L)); vo.setTotalSpentHours(spentHoursMap.getOrDefault(vo.getId(), BigDecimal.ZERO));
vo.setExecutionOwnerId(executionOwnerId);
if (vo.getParentTaskId() != null) {
vo.setParentTaskOwnerId(parentTaskOwnerMap.get(vo.getParentTaskId()));
}
// 列表行 cancel/pause/resume/complete 按钮依赖 availableActions与详情同款装配 lifecycle。
// 单行装配失败做兜底降级status_model 缺失等脏数据),避免影响整页返回。
try {
applyLifecycle(vo);
} catch (Exception e) {
log.warn("task lifecycle apply failed in page assembly. taskId={}, statusCode={}, error={}",
vo.getId(), vo.getStatusCode(), e.getMessage());
}
}); });
return voPageResult; return voPageResult;
} }
@@ -277,21 +480,41 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId); ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
String actionCode = normalizeRequiredActionCode(reqVO.getActionCode()); String actionCode = normalizeRequiredActionCode(reqVO.getActionCode());
String reason = normalizeNullableText(reqVO.getReason()); String reason = normalizeNullableText(reqVO.getReason());
String fromStatus = task.getStatusCode(); // owner-only 字段硬卡cancel / pause / resume / complete 必须任务负责人本人触发(与执行同款,不接受角色权限码兜底)
ObjectStatusTransitionDO transition = validateTaskTransition(fromStatus, actionCode, reason); validateOwnerForAction(task, actionCode);
String toStatus = transition.getToStatusCode(); doInternalChangeTaskStatus(task, actionCode, reason);
validateBeforeStatusChange(task, toStatus); }
int updateCount = projectTaskMapper.updateStatusByIdAndStatus(taskId, fromStatus, toStatus, reason); /**
if (updateCount != 1) { * 负责人独占动作校验complete / cancel / pause / resume 必须由任务负责人触发。
throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_CONCURRENT_MODIFIED); * 系统级别的 internalAutoStartByWorklog 不经此校验。
*/
private void validateOwnerForAction(ProjectTaskDO task, String actionCode) {
if (!isOwnerOnlyAction(actionCode)) {
return;
} }
task.setStatusCode(toStatus); Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
task.setLastStatusReason(reason); if (!Objects.equals(loginUserId, task.getOwnerId())) {
throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_OWNER_ONLY,
resolveActionDisplayName(actionCode));
}
}
writeTaskStatusLog(task, actionCode, fromStatus, toStatus, reason); private boolean isOwnerOnlyAction(String actionCode) {
writeTaskAuditLog(task, actionCode, fromStatus, toStatus, null, reason); return "complete".equals(actionCode)
maybeFillActualDates(task, fromStatus, toStatus); || "cancel".equals(actionCode)
|| "pause".equals(actionCode)
|| "resume".equals(actionCode);
}
private String resolveActionDisplayName(String actionCode) {
return switch (actionCode) {
case "complete" -> "完成";
case "cancel" -> "取消";
case "pause" -> "暂停";
case "resume" -> "恢复";
default -> actionCode;
};
} }
/** /**
@@ -408,8 +631,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
} }
} }
private void validateOwnerIsActiveExecutionMember(Long executionId, Long ownerId) { private void validateOwnerIsActiveExecutionAssignee(Long executionId, Long ownerId) {
if (ownerId == null || executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, ownerId) == null) { if (ownerId == null || executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, ownerId) == null) {
throw exception(ErrorCodeConstants.PROJECT_TASK_OWNER_INVALID); throw exception(ErrorCodeConstants.PROJECT_TASK_OWNER_INVALID);
} }
} }
@@ -439,9 +662,14 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
/** /**
* 进入终态前置校验当目标状态属于终态terminalFlag=true要求所有子任务已进入终态 * 进入终态前置校验当目标状态属于终态terminalFlag=true要求所有子任务已进入终态
* 避免父任务关闭后子任务仍处于未结束状态。判定基于通用语义位,不识别具体 statusCode。 * 避免父任务关闭后子任务仍处于未结束状态。判定基于通用语义位,不识别具体 statusCode。
* 进度合理性(如完成时进度 100%)由前端在动作触发前的交互层把关 * cancel 动作走"自动级联取消"路径cascadeCancelChildren跳过本校验
* 进度合理性(如完成时进度 100%由前端在动作触发前的交互层把关doInternalChangeTaskStatus
* 在 complete 落库后会兜底刷一次进度到 100%。
*/ */
private void validateBeforeStatusChange(ProjectTaskDO task, String toStatus) { private void validateBeforeStatusChange(ProjectTaskDO task, String actionCode, String toStatus) {
if ("cancel".equals(actionCode)) {
return;
}
ObjectStatusModelDO toModel = objectStatusModelMapper ObjectStatusModelDO toModel = objectStatusModelMapper
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, toStatus); .selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, toStatus);
if (toModel == null || !Boolean.TRUE.equals(toModel.getTerminalFlag())) { if (toModel == null || !Boolean.TRUE.equals(toModel.getTerminalFlag())) {
@@ -455,6 +683,200 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
} }
} }
/**
* 任务状态变更核心逻辑(不做手工入口授权校验)。
* 手工入口 changeTaskStatus 在做完 owner/项目角色权限校验后调用;
* 系统级联cascade*Children跳过授权校验直接调用整棵子树状态变更通过链式触发实现。
*/
private void doInternalChangeTaskStatus(ProjectTaskDO task, String actionCode, String reason) {
String fromStatus = task.getStatusCode();
ObjectStatusTransitionDO transition = validateTaskTransition(fromStatus, actionCode, reason);
String toStatus = transition.getToStatusCode();
validateBeforeStatusChange(task, actionCode, toStatus);
int updateCount = projectTaskMapper.updateStatusByIdAndStatus(task.getId(), fromStatus, toStatus, reason);
if (updateCount != 1) {
throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_CONCURRENT_MODIFIED);
}
task.setStatusCode(toStatus);
task.setLastStatusReason(reason);
writeTaskStatusLog(task, actionCode, fromStatus, toStatus, reason);
writeTaskAuditLog(task, actionCode, fromStatus, toStatus, null, reason);
maybeFillActualDates(task, fromStatus, toStatus);
// 完成动作:兜底把任务进度刷到 100%,并触发父任务 AVG 重算
if ("complete".equals(actionCode)) {
forceCompleteProgress(task);
}
// 取消 / 暂停 / 恢复:触发子任务级联(每个子任务的内部链路自身会再级联自己的子,链式实现整棵子树)
cascadeIfNeeded(task, actionCode, reason);
// 通知执行(仅 complete / cancelpause / resume 不通知auto_start 在 internalAutoStartByWorklog 单独调用)
if ("complete".equals(actionCode) || "cancel".equals(actionCode)) {
projectExecutionService.onTaskStatusChanged(
task.getExecutionId(), task.getId(), fromStatus, toStatus, actionCode, reason);
}
}
/**
* 完成动作后将任务进度兜底置为 100%,并触发父任务 AVG 重算。
* 正常流程下前端会确保点完成时进度已经是 100%,此处为兜底(防止前端绕过 / bug 导致脏数据)。
*/
private void forceCompleteProgress(ProjectTaskDO task) {
BigDecimal full = BigDecimal.valueOf(100);
if (progressNumericallyEquals(task.getProgressRate(), full)) {
return;
}
projectTaskMapper.updateProgressRateById(task.getId(), full);
task.setProgressRate(full);
if (task.getParentTaskId() != null) {
recalcParentProgressFrom(task.getParentTaskId());
}
}
/**
* 级联触发:根据 actionCode 对子任务批量执行对应动作。
* - cancel所有未终态的直接子任务自动取消
* - pause所有 active 的直接子任务自动暂停
* - resume所有 paused 的直接子任务自动恢复
* 其他动作不级联。
*/
private void cascadeIfNeeded(ProjectTaskDO task, String actionCode, String parentReason) {
switch (actionCode) {
case "cancel" -> cascadeCancelChildren(task.getId(), parentReason);
case "pause" -> cascadePauseChildren(task.getId(), parentReason);
case "resume" -> cascadeResumeChildren(task.getId(), parentReason);
default -> {
// 其他动作不级联
}
}
}
private void cascadeCancelChildren(Long parentTaskId, String parentReason) {
List<ProjectTaskDO> children = loadDirectChildren(parentTaskId);
if (children.isEmpty()) {
return;
}
List<String> terminalStatusCodes = objectStatusModelMapper
.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
for (ProjectTaskDO child : children) {
// 已终态的子任务跳过cancelled / completed
if (terminalStatusCodes.contains(child.getStatusCode())) {
continue;
}
doInternalChangeTaskStatus(child, "cancel",
formatCascadeReason("取消", parentTaskId, parentReason));
}
}
private void cascadePauseChildren(Long parentTaskId, String parentReason) {
List<ProjectTaskDO> children = loadDirectChildren(parentTaskId);
if (children.isEmpty()) {
return;
}
for (ProjectTaskDO child : children) {
// 只对 active 子任务级联暂停其他状态pending / paused / 终态)按设计跳过
if (!"active".equals(child.getStatusCode())) {
continue;
}
doInternalChangeTaskStatus(child, "pause",
formatCascadeReason("暂停", parentTaskId, parentReason));
}
}
private void cascadeResumeChildren(Long parentTaskId, String parentReason) {
List<ProjectTaskDO> children = loadDirectChildren(parentTaskId);
if (children.isEmpty()) {
return;
}
for (ProjectTaskDO child : children) {
// 只对 paused 子任务级联恢复(含父任务暂停前已主动 paused 的——A 方案接受的"误恢复"边界)
if (!"paused".equals(child.getStatusCode())) {
continue;
}
doInternalChangeTaskStatus(child, "resume",
formatCascadeReason("恢复", parentTaskId, parentReason));
}
}
private List<ProjectTaskDO> loadDirectChildren(Long parentTaskId) {
return projectTaskMapper.selectList(new LambdaQueryWrapperX<ProjectTaskDO>()
.eq(ProjectTaskDO::getParentTaskId, parentTaskId));
}
private String formatCascadeReason(String actionDisplay, Long parentTaskId, String parentReason) {
if (StringUtils.hasText(parentReason)) {
return String.format("父任务%s时自动级联父任务ID: %d父任务原因: %s",
actionDisplay, parentTaskId, parentReason);
}
return String.format("父任务%s时自动级联父任务ID: %d", actionDisplay, parentTaskId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void cascadeCancelTasksByExecutionId(Long executionId, String executionReason) {
cascadeAllRootTasksByExecutionId(executionId, "cancel", "取消", executionReason);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void cascadePauseTasksByExecutionId(Long executionId, String executionReason) {
cascadeAllRootTasksByExecutionId(executionId, "pause", "暂停", executionReason);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void cascadeResumeTasksByExecutionId(Long executionId, String executionReason) {
cascadeAllRootTasksByExecutionId(executionId, "resume", "恢复", executionReason);
}
/**
* 按动作类型级联执行下所有顶层任务parentTaskId IS NULL顶层任务自身的内部链路会再级联子任务。
* 跳过 owner 校验,复用 doInternalChangeTaskStatus 的事务 / CAS / 审计 / 日期回填。
*/
private void cascadeAllRootTasksByExecutionId(Long executionId, String actionCode,
String actionDisplay, String executionReason) {
if (executionId == null) {
return;
}
List<ProjectTaskDO> rootTasks = projectTaskMapper.selectList(new LambdaQueryWrapperX<ProjectTaskDO>()
.eq(ProjectTaskDO::getExecutionId, executionId)
.isNull(ProjectTaskDO::getParentTaskId));
if (rootTasks.isEmpty()) {
return;
}
List<String> terminalStatusCodes = "cancel".equals(actionCode)
? objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE)
: null;
for (ProjectTaskDO task : rootTasks) {
if (!isTaskEligibleForExecutionCascade(task, actionCode, terminalStatusCodes)) {
continue;
}
doInternalChangeTaskStatus(task, actionCode,
formatExecutionCascadeReason(actionDisplay, executionId, executionReason));
}
}
private boolean isTaskEligibleForExecutionCascade(ProjectTaskDO task, String actionCode,
List<String> terminalStatusCodes) {
return switch (actionCode) {
case "cancel" -> terminalStatusCodes != null && !terminalStatusCodes.contains(task.getStatusCode());
case "pause" -> "active".equals(task.getStatusCode());
case "resume" -> "paused".equals(task.getStatusCode());
default -> false;
};
}
private String formatExecutionCascadeReason(String actionDisplay, Long executionId, String executionReason) {
if (StringUtils.hasText(executionReason)) {
return String.format("执行%s时自动级联执行ID: %d执行原因: %s",
actionDisplay, executionId, executionReason);
}
return String.format("执行%s时自动级联执行ID: %d", actionDisplay, executionId);
}
private String getInitialTaskStatusCode() { private String getInitialTaskStatusCode() {
ObjectStatusModelDO statusModel = objectStatusModelMapper ObjectStatusModelDO statusModel = objectStatusModelMapper
.selectInitialByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE); .selectInitialByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
@@ -574,25 +996,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
return value == null ? BigDecimal.ZERO : value; return value == null ? BigDecimal.ZERO : value;
} }
/**
* 父任务进度由子任务自动汇总,不接受手工修改。
* <ul>
* <li>当前任务是叶子(无子):返回 normalizeProgress 后的入参,沿用 owner 手填模式。</li>
* <li>当前任务是父≥1 个子):忽略入参变更,保留数据库原值;若入参与原值数值不一致则抛错。
* 这样前端把读取到的 progressRate 原样回传不会触发拒错。</li>
* </ul>
*/
private BigDecimal resolveProgressRateOnUpdate(ProjectTaskDO existing, BigDecimal requested) {
boolean isParent = projectTaskMapper.countChildrenByParentTaskId(existing.getId()) > 0;
if (!isParent) {
return normalizeProgress(requested);
}
if (requested != null && !progressNumericallyEquals(requested, existing.getProgressRate())) {
throw exception(ErrorCodeConstants.PROJECT_TASK_PROGRESS_PARENT_NOT_EDITABLE);
}
return existing.getProgressRate();
}
/** /**
* 校验"叶子转父"前置条件:仅当 parentTask 当前还是叶子(无任何子任务)时校验 * 校验"叶子转父"前置条件:仅当 parentTask 当前还是叶子(无任何子任务)时校验
* 自身进度=0 且没有任何工时记录。已是父任务则直接放行。 * 自身进度=0 且没有任何工时记录。已是父任务则直接放行。
@@ -680,8 +1083,9 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
} }
private void applyLifecycle(ProjectTaskRespVO respVO) { private void applyLifecycle(ProjectTaskRespVO respVO) {
// 传入 ownerId 用于 availableActions 的 owner-only 字段硬卡过滤spec §7.1
ProjectTaskStatusViewService.ProjectTaskLifecycleView lifecycle = ProjectTaskStatusViewService.ProjectTaskLifecycleView lifecycle =
projectTaskStatusViewService.getLifecycle(respVO.getStatusCode()); projectTaskStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId());
respVO.setStatusName(lifecycle.statusName()); respVO.setStatusName(lifecycle.statusName());
respVO.setTerminal(lifecycle.terminal()); respVO.setTerminal(lifecycle.terminal());
respVO.setAllowEdit(lifecycle.allowEdit()); respVO.setAllowEdit(lifecycle.allowEdit());

View File

@@ -1,5 +1,7 @@
package com.njcn.rdms.module.project.service.project.task; package com.njcn.rdms.module.project.service.project.task;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.constant.ProjectTaskConstants; import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskLifecycleActionRespVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskLifecycleActionRespVO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO; import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
@@ -12,18 +14,37 @@ import org.springframework.stereotype.Service;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Set;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception; import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
/**
* 任务状态生命周期视图。
* <p>
* {@code getLifecycle} 返回的 {@code availableActions} 按 spec §7.1 实现约束过滤:
* <ol>
* <li>剔除系统级动作 {@code auto_start}(由工时填报内部触发,不出现在 UI 按钮)。</li>
* <li>对 owner-only 状态推进动作complete / cancel / pause / resume按 {@code task.ownerId == currentUserId} 字段硬卡过滤,
* 与 {@link ProjectTaskServiceImpl#validateOwnerForAction} 同款判定。</li>
* </ol>
* 非状态动作delete / 加协办人 / 工时填报等)的权限码 / 字段过滤未纳入本字段,前端按各动作对应权限码与 owner 字段独立判断;
* 全量化纳入 {@code availableActions} 留待后续阶段统一改造。
*/
@Service @Service
public class ProjectTaskStatusViewService { public class ProjectTaskStatusViewService {
/**
* 状态推进动作中要求 owner-only 字段硬卡的集合,与 ProjectTaskServiceImpl#validateOwnerForAction 一致。
*/
private static final Set<String> OWNER_ONLY_ACTIONS = Set.of("complete", "cancel", "pause", "resume");
@Resource @Resource
private ObjectStatusModelMapper objectStatusModelMapper; private ObjectStatusModelMapper objectStatusModelMapper;
@Resource @Resource
private ObjectStatusTransitionMapper objectStatusTransitionMapper; private ObjectStatusTransitionMapper objectStatusTransitionMapper;
public ProjectTaskLifecycleView getLifecycle(String statusCode) { public ProjectTaskLifecycleView getLifecycle(String statusCode, Long ownerId) {
ObjectStatusModelDO statusModel = objectStatusModelMapper ObjectStatusModelDO statusModel = objectStatusModelMapper
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, statusCode); .selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, statusCode);
if (statusModel == null) { if (statusModel == null) {
@@ -33,23 +54,31 @@ public class ProjectTaskStatusViewService {
statusModel.getStatusName(), statusModel.getStatusName(),
statusModel.getTerminalFlag(), statusModel.getTerminalFlag(),
statusModel.getAllowEdit(), statusModel.getAllowEdit(),
buildAvailableActions(statusCode) buildAvailableActions(statusCode, ownerId)
); );
} }
private List<ProjectTaskLifecycleActionRespVO> buildAvailableActions(String statusCode) { private List<ProjectTaskLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId) {
List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper
.selectListByObjectTypeAndFromStatus(ProjectTaskConstants.OBJECT_TYPE, statusCode); .selectListByObjectTypeAndFromStatus(ProjectTaskConstants.OBJECT_TYPE, statusCode);
if (transitions == null || transitions.isEmpty()) { if (transitions == null || transitions.isEmpty()) {
return Collections.emptyList(); return Collections.emptyList();
} }
return transitions.stream().map(transition -> { Long currentUserId = SecurityFrameworkUtils.getLoginUserId();
ProjectTaskLifecycleActionRespVO action = new ProjectTaskLifecycleActionRespVO(); return transitions.stream()
action.setActionCode(transition.getActionCode()); // 剔除系统级动作 auto_start由工时填报触发不暴露给前端按钮
action.setActionName(transition.getActionName()); .filter(transition -> !ObjectActivityConstants.TASK_ACTION_AUTO_START.equals(transition.getActionCode()))
action.setNeedReason(transition.getNeedReason()); // owner-only 字段硬卡:非负责人本人时不返回 complete / cancel / pause / resume 动作
return action; .filter(transition -> !OWNER_ONLY_ACTIONS.contains(transition.getActionCode())
}).toList(); || (currentUserId != null && Objects.equals(ownerId, currentUserId)))
.map(transition -> {
ProjectTaskLifecycleActionRespVO action = new ProjectTaskLifecycleActionRespVO();
action.setActionCode(transition.getActionCode());
action.setActionName(transition.getActionName());
action.setNeedReason(transition.getNeedReason());
return action;
})
.toList();
} }
public record ProjectTaskLifecycleView(String statusName, public record ProjectTaskLifecycleView(String statusName,

View File

@@ -20,13 +20,14 @@ import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeLogDO; import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeLogDO;
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.ProjectMapper; import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberMapper; 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.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.project.task.TaskAssigneeLogMapper; import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeLogMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper; import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper;
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.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;
@@ -57,7 +58,7 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
@Resource @Resource
private ProjectTaskMapper projectTaskMapper; private ProjectTaskMapper projectTaskMapper;
@Resource @Resource
private ExecutionMemberMapper executionMemberMapper; private ExecutionAssigneeMapper executionAssigneeMapper;
@Resource @Resource
private TaskAssigneeMapper taskAssigneeMapper; private TaskAssigneeMapper taskAssigneeMapper;
@Resource @Resource
@@ -85,6 +86,8 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_ASSIGNEE)
public Long createAssignee(Long projectId, Long executionId, Long taskId, TaskAssigneeSaveReqVO reqVO) { public Long createAssignee(Long projectId, Long executionId, Long taskId, TaskAssigneeSaveReqVO reqVO) {
ProjectTaskDO task = validateEditableContext(projectId, executionId, taskId); ProjectTaskDO task = validateEditableContext(projectId, executionId, taskId);
Long userId = reqVO.getUserId(); Long userId = reqVO.getUserId();
@@ -94,6 +97,8 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_ASSIGNEE)
public void inactiveAssignee(Long projectId, Long executionId, Long taskId, Long assigneeId, public void inactiveAssignee(Long projectId, Long executionId, Long taskId, Long assigneeId,
TaskAssigneeInactiveReqVO reqVO) { TaskAssigneeInactiveReqVO reqVO) {
validateEditableContext(projectId, executionId, taskId); validateEditableContext(projectId, executionId, taskId);
@@ -218,7 +223,7 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
if (Objects.equals(userId, ownerId)) { if (Objects.equals(userId, ownerId)) {
throw exception(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_OWNER_CONFLICT); throw exception(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_OWNER_CONFLICT);
} }
if (executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, userId) == null) { if (executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, userId) == null) {
throw exception(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_INVALID_MEMBER); throw exception(ErrorCodeConstants.PROJECT_TASK_ASSIGNEE_INVALID_MEMBER);
} }
if (taskAssigneeMapper.selectActiveByTaskIdAndUserId(taskId, userId) != null) { if (taskAssigneeMapper.selectActiveByTaskIdAndUserId(taskId, userId) != null) {

View File

@@ -5,34 +5,40 @@ import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.Tas
import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogRespVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogSaveReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogSaveReqVO;
import java.math.BigDecimal;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* 任务工时 Service。 * 任务工时 Service。
* <p> * <p>
* 决策来源docs/任务工时与进度模型_业内标杆调研.md。 * 决策来源docs/项目/2026-05-09-task-worklog-design.md。
* 核心约束:仅叶子任务可挂工时;填报人限制为 owner + 在岗协办人;时长按分钟存30 分钟整数倍); * 核心约束:仅叶子任务可挂工时;填报人限制为 owner + 在岗协办人;
* 同一 user × task × work_date 允许多条;改/删自己的owner 可删别人的。 * 按段记录startDate/endDate 必填,单天=二者相等);同人同任务下日期范围禁止重叠;
* 颗粒durationHours 必须 > 0 且为 0.5 的整数倍;
* 进度owner 填报触发任务进度同步(按 endDate 排序取本人最新一条),协作人填报仅本人自评。
*/ */
public interface TaskWorklogService { public interface TaskWorklogService {
/** /**
* 任务工时分页(按 workDate DESC, id DESC * 任务工时分页(按 endDate DESC, id DESC
* 支持按填报人 / 日期区间筛选;上下文校验仅要求项目/执行/任务存在,不要求允许编辑。 * 支持按填报人 / 段相交过滤;上下文校验仅要求项目/执行/任务存在,不要求允许编辑。
*/ */
PageResult<TaskWorklogRespVO> getWorklogPage(Long projectId, Long executionId, Long taskId, PageResult<TaskWorklogRespVO> getWorklogPage(Long projectId, Long executionId, Long taskId,
TaskWorklogPageReqVO reqVO); TaskWorklogPageReqVO reqVO);
/** /**
* 新增工时记录。校验:上下文可编辑、任务为叶子、登录人是 owner 或在岗协办人、时长合法。 * 新增工时记录。校验:上下文可编辑、任务为叶子、登录人是 owner 或在岗协办人、
* 段日期合法、与已有记录不重叠、时长合法。
* 填报人 userId 取登录用户,前端不传。返回新建记录编号。 * 填报人 userId 取登录用户,前端不传。返回新建记录编号。
*/ */
Long createWorklog(Long projectId, Long executionId, Long taskId, TaskWorklogSaveReqVO reqVO); Long createWorklog(Long projectId, Long executionId, Long taskId, TaskWorklogSaveReqVO reqVO);
/** /**
* 修改工时记录(含工作日期/时长/工作内容)。校验:记录归属任务、登录人是该记录原填报人、时长合法。 * 修改工时记录(含起止日期/时长/进度/工作内容/附件)。校验:记录归属任务、登录人是该记录原填报人、
* 段日期合法、与该用户其他记录不重叠(排除自身)、时长合法。
* 不允许修改 taskId / userId前端不可传 * 不允许修改 taskId / userId前端不可传
*/ */
void updateWorklog(Long projectId, Long executionId, Long taskId, Long worklogId, void updateWorklog(Long projectId, Long executionId, Long taskId, Long worklogId,
@@ -44,19 +50,20 @@ public interface TaskWorklogService {
void deleteWorklog(Long projectId, Long executionId, Long taskId, Long worklogId); void deleteWorklog(Long projectId, Long executionId, Long taskId, Long worklogId);
/** /**
* 批量任务工时汇总(分钟),用于详情/分页装配 totalSpentMinutes避免 N+1。 * 批量任务工时小时数汇总,用于详情/分页装配 totalSpentHours避免 N+1。
* 任务无任何工时时不会出现在结果中(调用方需用默认 0 兜底)。 * 任务无任何工时时不会出现在结果中(调用方需用默认 0 兜底)。
*/ */
Map<Long, Long> sumDurationGroupedByTaskIds(Collection<Long> taskIds); Map<Long, BigDecimal> sumDurationGroupedByTaskIds(Collection<Long> taskIds);
/** /**
* 单任务工时汇总(分钟)。无记录返回 0。 * 单任务工时小时数汇总。无记录返回 0。
*/ */
default long sumDurationByTaskId(Long taskId) { default BigDecimal sumDurationByTaskId(Long taskId) {
if (taskId == null) { if (taskId == null) {
return 0L; return BigDecimal.ZERO;
} }
return sumDurationGroupedByTaskIds(Collections.singletonList(taskId)).getOrDefault(taskId, 0L); return sumDurationGroupedByTaskIds(Collections.singletonList(taskId))
.getOrDefault(taskId, BigDecimal.ZERO);
} }
} }

View File

@@ -22,13 +22,20 @@ import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper; import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper;
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.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.AttachmentValidator;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import com.njcn.rdms.module.project.service.project.task.ProjectTaskService;
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;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
@@ -44,9 +51,9 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
public class TaskWorklogServiceImpl implements TaskWorklogService { public class TaskWorklogServiceImpl implements TaskWorklogService {
/** /**
* 工时颗粒:30 分钟(0.5 小时。duration_minutes 必须 > 0 且为本值的整数倍。 * 工时颗粒0.5 小时。durationHours 必须 > 0 且为本值的整数倍。
*/ */
private static final int DURATION_GRANULARITY_MINUTES = 30; private static final BigDecimal DURATION_GRANULARITY_HOURS = new BigDecimal("0.5");
@Resource @Resource
private ProjectMapper projectMapper; private ProjectMapper projectMapper;
@@ -62,6 +69,15 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
private ObjectStatusModelMapper objectStatusModelMapper; private ObjectStatusModelMapper objectStatusModelMapper;
@Resource @Resource
private AdminUserApi adminUserApi; private AdminUserApi adminUserApi;
@Resource
private AttachmentFileIdResolver attachmentFileIdResolver;
/**
* 与 ProjectTaskService 互相依赖ProjectTaskService 也注入本类),用 @Lazy 打破循环。
* 仅用于 owner 工时变更后同步任务自身进度并触发父任务汇总。
*/
@Resource
@Lazy
private ProjectTaskService projectTaskService;
@Override @Override
public PageResult<TaskWorklogRespVO> getWorklogPage(Long projectId, Long executionId, Long taskId, public PageResult<TaskWorklogRespVO> getWorklogPage(Long projectId, Long executionId, Long taskId,
@@ -84,46 +100,73 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_WORKLOG)
public Long createWorklog(Long projectId, Long executionId, Long taskId, TaskWorklogSaveReqVO reqVO) { public Long createWorklog(Long projectId, Long executionId, Long taskId, TaskWorklogSaveReqVO reqVO) {
ProjectTaskDO task = validateEditableContext(projectId, executionId, taskId); ProjectTaskDO task = validateEditableContext(projectId, executionId, taskId);
validateLeafTask(taskId); validateLeafTask(taskId);
Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
validateFileWorklogPermission(taskId, task.getOwnerId(), loginUserId); validateFileWorklogPermission(taskId, task.getOwnerId(), loginUserId);
validateDurationGranularity(reqVO.getDurationMinutes()); validateDateRange(reqVO.getStartDate(), reqVO.getEndDate());
validateDurationGranularity(reqVO.getDurationHours());
validateNoOverlap(taskId, loginUserId, reqVO.getStartDate(), reqVO.getEndDate(), null);
validateProgressMonotonicity(taskId, loginUserId, reqVO.getEndDate(), reqVO.getProgressRate(), null);
AttachmentValidator.validate(reqVO.getAttachments());
attachmentFileIdResolver.resolve(reqVO.getAttachments());
TaskWorklogDO worklog = new TaskWorklogDO(); TaskWorklogDO worklog = buildWorklog(taskId, loginUserId, reqVO);
worklog.setTaskId(taskId);
worklog.setUserId(loginUserId);
worklog.setWorkDate(reqVO.getWorkDate());
worklog.setDurationMinutes(reqVO.getDurationMinutes());
worklog.setWorkContent(normalizeNullableText(reqVO.getWorkContent()));
taskWorklogMapper.insert(worklog); taskWorklogMapper.insert(worklog);
// 任意填报人owner / 协办人)都触发任务自动开始(仅当任务仍处初始态时生效),并写入 actualStartDate。
// 任务"是否开始"是客观事实,协办人开工同样代表任务已开始,不应等 owner 补工时才反映。
projectTaskService.internalAutoStartByWorklog(task);
// 仅 owner 填报触发任务进度同步:任务整体进度以 owner 本人最新一条工时为权威源,
// 协办人填报的是个体进度,不参与任务整体进度计算。
if (Objects.equals(loginUserId, task.getOwnerId())) {
projectTaskService.syncOwnerProgressFromLatestWorklog(taskId);
}
return worklog.getId(); return worklog.getId();
} }
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_WORKLOG)
public void updateWorklog(Long projectId, Long executionId, Long taskId, Long worklogId, public void updateWorklog(Long projectId, Long executionId, Long taskId, Long worklogId,
TaskWorklogSaveReqVO reqVO) { TaskWorklogSaveReqVO reqVO) {
validateEditableContext(projectId, executionId, taskId); ProjectTaskDO task = validateEditableContext(projectId, executionId, taskId);
TaskWorklogDO worklog = loadWorklog(worklogId, taskId); TaskWorklogDO worklog = loadWorklog(worklogId, taskId);
Long loginUserId = SecurityFrameworkUtils.getLoginUserId(); Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
// 仅原填报人可改;任务负责人也不能改协办人的记录(避免争议) // 仅原填报人可改;任务负责人也不能改协办人的记录(避免争议)
if (!Objects.equals(worklog.getUserId(), loginUserId)) { if (!Objects.equals(worklog.getUserId(), loginUserId)) {
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_EDIT_NOT_OWN); throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_EDIT_NOT_OWN);
} }
validateDurationGranularity(reqVO.getDurationMinutes()); validateDateRange(reqVO.getStartDate(), reqVO.getEndDate());
validateDurationGranularity(reqVO.getDurationHours());
// 与该用户其他工时记录不可重叠(排除自身)
validateNoOverlap(taskId, worklog.getUserId(), reqVO.getStartDate(), reqVO.getEndDate(), worklog.getId());
validateProgressMonotonicity(taskId, worklog.getUserId(), reqVO.getEndDate(), reqVO.getProgressRate(), worklog.getId());
AttachmentValidator.validate(reqVO.getAttachments());
attachmentFileIdResolver.resolve(reqVO.getAttachments());
TaskWorklogDO update = new TaskWorklogDO(); TaskWorklogDO update = new TaskWorklogDO();
update.setId(worklog.getId()); update.setId(worklog.getId());
update.setWorkDate(reqVO.getWorkDate()); update.setStartDate(reqVO.getStartDate());
update.setDurationMinutes(reqVO.getDurationMinutes()); update.setEndDate(reqVO.getEndDate());
update.setDurationHours(reqVO.getDurationHours());
update.setProgressRate(reqVO.getProgressRate());
update.setWorkContent(normalizeNullableText(reqVO.getWorkContent())); update.setWorkContent(normalizeNullableText(reqVO.getWorkContent()));
update.setAttachments(reqVO.getAttachments());
taskWorklogMapper.updateById(update); taskWorklogMapper.updateById(update);
// owner 改自己工时:按"owner 最新一条"重算任务进度
if (Objects.equals(worklog.getUserId(), task.getOwnerId())) {
projectTaskService.syncOwnerProgressFromLatestWorklog(taskId);
}
} }
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_WORKLOG)
public void deleteWorklog(Long projectId, Long executionId, Long taskId, Long worklogId) { public void deleteWorklog(Long projectId, Long executionId, Long taskId, Long worklogId) {
ProjectTaskDO task = validateEditableContext(projectId, executionId, taskId); ProjectTaskDO task = validateEditableContext(projectId, executionId, taskId);
TaskWorklogDO worklog = loadWorklog(worklogId, taskId); TaskWorklogDO worklog = loadWorklog(worklogId, taskId);
@@ -134,10 +177,15 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DELETE_FORBIDDEN); throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DELETE_FORBIDDEN);
} }
taskWorklogMapper.deleteById(worklog.getId()); taskWorklogMapper.deleteById(worklog.getId());
// 删的是 owner 自己的工时:按"owner 剩余最新一条"重算任务进度;
// 若 owner 已无任何工时syncOwnerProgressFromLatestWorklog 内部会保留原值不动(不归零)
if (Objects.equals(worklog.getUserId(), task.getOwnerId())) {
projectTaskService.syncOwnerProgressFromLatestWorklog(taskId);
}
} }
@Override @Override
public Map<Long, Long> sumDurationGroupedByTaskIds(Collection<Long> taskIds) { public Map<Long, BigDecimal> sumDurationGroupedByTaskIds(Collection<Long> taskIds) {
if (taskIds == null || taskIds.isEmpty()) { if (taskIds == null || taskIds.isEmpty()) {
return Collections.emptyMap(); return Collections.emptyMap();
} }
@@ -145,12 +193,14 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
if (rows == null || rows.isEmpty()) { if (rows == null || rows.isEmpty()) {
return Collections.emptyMap(); return Collections.emptyMap();
} }
Map<Long, Long> result = new HashMap<>(rows.size()); Map<Long, BigDecimal> result = new HashMap<>(rows.size());
for (Map<String, Object> row : rows) { for (Map<String, Object> row : rows) {
Object idValue = row.getOrDefault("taskId", row.get("task_id")); Object idValue = row.getOrDefault("taskId", row.get("task_id"));
Object totalValue = row.get("total"); Object totalValue = row.get("total");
if (idValue instanceof Number idNum && totalValue instanceof Number totalNum) { if (idValue instanceof Number idNum && totalValue != null) {
result.put(idNum.longValue(), totalNum.longValue()); BigDecimal totalBd = totalValue instanceof BigDecimal bd
? bd : new BigDecimal(totalValue.toString());
result.put(idNum.longValue(), totalBd);
} }
} }
return result; return result;
@@ -158,6 +208,19 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
// -------------------- 内部辅助 -------------------- // -------------------- 内部辅助 --------------------
private TaskWorklogDO buildWorklog(Long taskId, Long userId, TaskWorklogSaveReqVO reqVO) {
TaskWorklogDO worklog = new TaskWorklogDO();
worklog.setTaskId(taskId);
worklog.setUserId(userId);
worklog.setStartDate(reqVO.getStartDate());
worklog.setEndDate(reqVO.getEndDate());
worklog.setDurationHours(reqVO.getDurationHours());
worklog.setProgressRate(reqVO.getProgressRate());
worklog.setWorkContent(normalizeNullableText(reqVO.getWorkContent()));
worklog.setAttachments(reqVO.getAttachments());
return worklog;
}
/** /**
* 校验项目/执行/任务都允许编辑,并返回任务实体。与 TaskAssigneeServiceImpl 同口径。 * 校验项目/执行/任务都允许编辑,并返回任务实体。与 TaskAssigneeServiceImpl 同口径。
*/ */
@@ -167,10 +230,26 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
validateAllowEdit(ProjectExecutionConstants.OBJECT_TYPE, execution.getStatusCode()); validateAllowEdit(ProjectExecutionConstants.OBJECT_TYPE, execution.getStatusCode());
ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId); ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
validateAllowEdit(ProjectTaskConstants.OBJECT_TYPE, task.getStatusCode()); // 任务层放宽completed 状态下非 owner即协办人允许继续维护自己的工时§4.2.4 矩阵)。
// 其他状态pending / active / paused / cancelled仍按 allow_edit 判定owner 在 completed 下被拦截,
// 避免与"完成时硬置进度 100%"冲突。
if (!isCompletedAssigneeWorklogContext(task)) {
validateAllowEdit(ProjectTaskConstants.OBJECT_TYPE, task.getStatusCode());
}
return task; return task;
} }
/**
* 是否处于"任务已完成、协办人维护工时"的放行场景。
*/
private boolean isCompletedAssigneeWorklogContext(ProjectTaskDO task) {
if (!"completed".equals(task.getStatusCode())) {
return false;
}
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
return loginUserId != null && !Objects.equals(loginUserId, task.getOwnerId());
}
private void validateExecutionAndTaskExists(Long projectId, Long executionId, Long taskId) { private void validateExecutionAndTaskExists(Long projectId, Long executionId, Long taskId) {
validateExecutionExists(projectId, executionId); validateExecutionExists(projectId, executionId);
validateTaskExists(projectId, executionId, taskId); validateTaskExists(projectId, executionId, taskId);
@@ -244,14 +323,59 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_OWNER_OR_ASSIGNEE); throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_OWNER_OR_ASSIGNEE);
} }
private void validateDurationGranularity(Integer durationMinutes) { /**
if (durationMinutes == null * 校验段日期startDate 不能晚于 endDateNotNull 已由 Bean Validation 保证)。
|| durationMinutes <= 0 */
|| durationMinutes % DURATION_GRANULARITY_MINUTES != 0) { private void validateDateRange(LocalDate startDate, LocalDate endDate) {
if (startDate == null || endDate == null || startDate.isAfter(endDate)) {
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DATE_RANGE_INVALID);
}
}
/**
* 校验小时颗粒:> 0 且为 0.5 的整数倍(用 remainder 处理 scale 差异)。
*/
private void validateDurationGranularity(BigDecimal durationHours) {
if (durationHours == null
|| durationHours.compareTo(BigDecimal.ZERO) <= 0
|| durationHours.remainder(DURATION_GRANULARITY_HOURS).compareTo(BigDecimal.ZERO) != 0) {
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DURATION_INVALID); throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DURATION_INVALID);
} }
} }
/**
* 校验本次提交的段与该 task × user 的已有工时记录无日期重叠(更新场景需排除自身)。
*/
private void validateNoOverlap(Long taskId, Long userId, LocalDate startDate, LocalDate endDate, Long excludeId) {
if (taskWorklogMapper.existsOverlapping(taskId, userId, startDate, endDate, excludeId)) {
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DATE_OVERLAP);
}
}
/**
* 校验"按 endDate 排序后进度非递减"。仅与目标段相邻的前/后一条比较:
* - 早段endDate 更小)的 progressRate 不得大于本段
* - 晚段endDate 更大)的 progressRate 不得小于本段
* progressRate 为 null 时跳过比较excludeId 用于更新场景排除自身。
* 仅校验邻段以避开历史脏数据:库内若已有违反单调的记录,本次更新只要不再扩大违反范围即可放过。
*/
private void validateProgressMonotonicity(Long taskId, Long userId, LocalDate endDate,
BigDecimal progressRate, Long excludeId) {
if (progressRate == null || endDate == null) {
return;
}
TaskWorklogDO prev = taskWorklogMapper.selectPrevByEndDate(taskId, userId, endDate, excludeId);
if (prev != null && prev.getProgressRate() != null
&& prev.getProgressRate().compareTo(progressRate) > 0) {
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_PROGRESS_NOT_MONOTONIC);
}
TaskWorklogDO next = taskWorklogMapper.selectNextByEndDate(taskId, userId, endDate, excludeId);
if (next != null && next.getProgressRate() != null
&& next.getProgressRate().compareTo(progressRate) < 0) {
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_PROGRESS_NOT_MONOTONIC);
}
}
private TaskWorklogDO loadWorklog(Long worklogId, Long taskId) { private TaskWorklogDO loadWorklog(Long worklogId, Long taskId) {
if (worklogId == null) { if (worklogId == null) {
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_EXISTS); throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_EXISTS);

View File

@@ -0,0 +1,52 @@
package com.njcn.rdms.module.project.framework.attachment;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
import com.njcn.rdms.module.system.api.file.FileApi;
import com.njcn.rdms.module.system.api.file.dto.FileRespDTO;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.List;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AttachmentFileIdResolverTest extends BaseMockitoUnitTest {
@InjectMocks
private AttachmentFileIdResolver resolver;
@Mock
private FileApi fileApi;
@Test
void resolve_whenAttachmentIdBlank_shouldFillByUrl() {
String url = "http://oss.example.com/task/20260511/test.txt?X-Amz-Signature=abc";
AttachmentItem attachment = new AttachmentItem();
attachment.setUrl(url);
FileRespDTO file = new FileRespDTO();
file.setId(2053737548535996418L);
when(fileApi.getFileByUrl(url)).thenReturn(success(file));
resolver.resolve(List.of(attachment));
assertEquals("2053737548535996418", attachment.getId());
}
@Test
void resolve_whenAttachmentIdPresent_shouldNotQueryFileApi() {
AttachmentItem attachment = new AttachmentItem();
attachment.setId("10001");
attachment.setUrl("http://oss.example.com/task/20260511/test.txt");
resolver.resolve(List.of(attachment));
verify(fileApi, never()).getFileByUrl(attachment.getUrl());
assertEquals("10001", attachment.getId());
}
}

View File

@@ -0,0 +1,51 @@
package com.njcn.rdms.module.project.framework.security.service;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
class ProjectObjectAuthorizationServiceTest extends BaseMockitoUnitTest {
@InjectMocks
private ProjectObjectAuthorizationService authorizationService;
@Mock
private ProjectObjectPermissionService projectObjectPermissionService;
@Test
void checkOwnerOrProjectPermission_whenCurrentUserIsOwner_shouldSkipProjectPermissionCheck() {
Long projectId = 1001L;
Long ownerUserId = 2001L;
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(ownerUserId)) {
authorizationService.checkOwnerOrProjectPermission(projectId, ownerUserId, "project:task:status");
}
verify(projectObjectPermissionService, never()).checkPermission(projectId, "project:task:status", false);
}
@Test
void checkOwnerOrProjectPermission_whenCurrentUserIsNotOwner_shouldCheckProjectPermission() {
Long projectId = 1002L;
Long ownerUserId = 2002L;
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(2999L)) {
authorizationService.checkOwnerOrProjectPermission(projectId, ownerUserId, "project:task:status");
}
verify(projectObjectPermissionService).checkPermission(projectId, "project:task:status", false);
}
private MockedStatic<SecurityFrameworkUtils> mockLoginUser(Long loginUserId) {
MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class);
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
return mockedStatic;
}
}

View File

@@ -9,6 +9,9 @@ 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;
@@ -21,6 +24,7 @@ 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 {
@@ -33,6 +37,17 @@ 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() {
@@ -44,16 +59,16 @@ 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(ProjectExecutionStatusBoardReqVO.class), eq("pending"))) when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
.thenReturn(3); any(ProjectExecutionStatusBoardReqVO.class), eq("pending"))).thenReturn(3);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(ProjectExecutionStatusBoardReqVO.class), eq("active"))) when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
.thenReturn(8); any(ProjectExecutionStatusBoardReqVO.class), eq("active"))).thenReturn(8);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(ProjectExecutionStatusBoardReqVO.class), eq("paused"))) when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
.thenReturn(2); any(ProjectExecutionStatusBoardReqVO.class), eq("paused"))).thenReturn(2);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(ProjectExecutionStatusBoardReqVO.class), eq("completed"))) when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
.thenReturn(4); any(ProjectExecutionStatusBoardReqVO.class), eq("completed"))).thenReturn(4);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(ProjectExecutionStatusBoardReqVO.class), eq("cancelled"))) when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
.thenReturn(1); any(ProjectExecutionStatusBoardReqVO.class), eq("cancelled"))).thenReturn(1);
ProjectExecutionStatusBoardReqVO reqVO = new ProjectExecutionStatusBoardReqVO(); ProjectExecutionStatusBoardReqVO reqVO = new ProjectExecutionStatusBoardReqVO();
reqVO.setKeyword("接口"); reqVO.setKeyword("接口");
@@ -80,20 +95,20 @@ class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectListByObjectTypeEnabled("task")).thenReturn(List.of( when(objectStatusModelMapper.selectListByObjectTypeEnabled("task")).thenReturn(List.of(
createStatus("pending", "待开始", 10, false), createStatus("pending", "待开始", 10, false),
createStatus("active", "进行中", 20, false), createStatus("active", "进行中", 20, false),
createStatus("blocked", "阻塞", 30, false), createStatus("paused", "暂停", 30, false),
createStatus("completed", "已完成", 40, true), createStatus("completed", "已完成", 40, true),
createStatus("cancelled", "已取消", 50, true) createStatus("cancelled", "已取消", 50, true)
)); ));
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L), when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
any(ProjectTaskStatusBoardReqVO.class), eq("pending"))).thenReturn(5); any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("pending"))).thenReturn(5);
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L), when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
any(ProjectTaskStatusBoardReqVO.class), eq("active"))).thenReturn(12); any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("active"))).thenReturn(12);
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L), when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
any(ProjectTaskStatusBoardReqVO.class), eq("blocked"))).thenReturn(2); any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("paused"))).thenReturn(2);
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L), when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
any(ProjectTaskStatusBoardReqVO.class), eq("completed"))).thenReturn(4); any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("completed"))).thenReturn(4);
when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L), when(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(eq(2001L), eq(5001L),
any(ProjectTaskStatusBoardReqVO.class), eq("cancelled"))).thenReturn(1); any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("cancelled"))).thenReturn(1);
ProjectTaskStatusBoardReqVO reqVO = new ProjectTaskStatusBoardReqVO(); ProjectTaskStatusBoardReqVO reqVO = new ProjectTaskStatusBoardReqVO();
reqVO.setKeyword("任务"); reqVO.setKeyword("任务");
@@ -107,7 +122,7 @@ class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
assertEquals(5, result.getItems().size()); assertEquals(5, result.getItems().size());
assertEquals("pending", result.getItems().get(0).getStatusCode()); assertEquals("pending", result.getItems().get(0).getStatusCode());
assertEquals("active", result.getItems().get(1).getStatusCode()); assertEquals("active", result.getItems().get(1).getStatusCode());
assertEquals("blocked", result.getItems().get(2).getStatusCode()); assertEquals("paused", result.getItems().get(2).getStatusCode());
assertEquals("completed", result.getItems().get(3).getStatusCode()); assertEquals("completed", result.getItems().get(3).getStatusCode());
assertEquals("cancelled", result.getItems().get(4).getStatusCode()); assertEquals("cancelled", result.getItems().get(4).getStatusCode());
} }

View File

@@ -3,23 +3,23 @@ package com.njcn.rdms.module.project.service.project.execution;
import com.njcn.rdms.framework.common.exception.ServiceException; 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.test.core.ut.BaseMockitoUnitTest; import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberInactiveReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeInactiveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberLogPageReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberLogRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeLogRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberRespVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.member.ExecutionMemberSaveReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.assignee.ExecutionAssigneeSaveReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO; import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO; import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberLogDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeLogDO;
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.dal.dataobject.status.ObjectStatusModelDO; import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
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.project.ProjectMapper; import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberLogMapper; import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeLogMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberMapper; 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.execution.ProjectExecutionMapper;
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.enums.ErrorCodeConstants; import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
@@ -46,18 +46,18 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest { class ProjectExecutionAssigneeServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks @InjectMocks
private ProjectExecutionMemberServiceImpl projectExecutionMemberService; private ProjectExecutionAssigneeServiceImpl projectExecutionAssigneeService;
@Mock @Mock
private ProjectMapper projectMapper; private ProjectMapper projectMapper;
@Mock @Mock
private ProjectExecutionMapper projectExecutionMapper; private ProjectExecutionMapper projectExecutionMapper;
@Mock @Mock
private ExecutionMemberMapper executionMemberMapper; private ExecutionAssigneeMapper executionAssigneeMapper;
@Mock @Mock
private ExecutionMemberLogMapper executionMemberLogMapper; private ExecutionAssigneeLogMapper executionAssigneeLogMapper;
@Mock @Mock
private UserObjectRoleMapper userObjectRoleMapper; private UserObjectRoleMapper userObjectRoleMapper;
@Mock @Mock
@@ -72,51 +72,51 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
private static final Long OWNER_ID = 3001L; private static final Long OWNER_ID = 3001L;
private static final Long USER_ID = 3002L; private static final Long USER_ID = 3002L;
// -------------------- getExecutionMemberList -------------------- // -------------------- getExecutionAssigneeList --------------------
@Test @Test
void getExecutionMemberList_shouldReturnOnlyActiveSegments() { void getExecutionAssigneeList_shouldReturnOnlyActiveSegments() {
when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject()); when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject());
when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID)) when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID))
.thenReturn(createExecution()); .thenReturn(createExecution());
when(executionMemberMapper.selectActiveListByExecutionId(EXECUTION_ID)) when(executionAssigneeMapper.selectActiveListByExecutionId(EXECUTION_ID))
.thenReturn(List.of(createMember(7001L, USER_ID, null))); .thenReturn(List.of(createMember(7001L, USER_ID, null)));
List<ExecutionMemberRespVO> respVOList = projectExecutionMemberService List<ExecutionAssigneeRespVO> respVOList = projectExecutionAssigneeService
.getExecutionMemberList(PROJECT_ID, EXECUTION_ID); .getExecutionAssigneeList(PROJECT_ID, EXECUTION_ID);
assertEquals(1, respVOList.size()); assertEquals(1, respVOList.size());
assertEquals(USER_ID, respVOList.get(0).getUserId()); assertEquals(USER_ID, respVOList.get(0).getUserId());
assertNull(respVOList.get(0).getRemovedAt()); assertNull(respVOList.get(0).getRemovedAt());
verify(executionMemberMapper, never()).selectListByExecutionId(any()); verify(executionAssigneeMapper, never()).selectListByExecutionId(any());
} }
// -------------------- createExecutionMember -------------------- // -------------------- createExecutionAssignee --------------------
@Test @Test
void createExecutionMember_byUserNeverJoined_shouldInsertActiveAndWriteJoinLog() { void createExecutionAssignee_byUserNeverJoined_shouldInsertActiveAndWriteJoinLog() {
stubEditableContext(); stubEditableContext();
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID)) when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
.thenReturn(createProjectMember()); .thenReturn(createProjectMember());
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null); when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
when(executionMemberMapper.insert(any(ExecutionMemberDO.class))).thenAnswer(inv -> { when(executionAssigneeMapper.insert(any(ExecutionAssigneeDO.class))).thenAnswer(inv -> {
ExecutionMemberDO m = inv.getArgument(0); ExecutionAssigneeDO m = inv.getArgument(0);
m.setId(7001L); m.setId(7001L);
return 1; return 1;
}); });
ExecutionMemberSaveReqVO reqVO = new ExecutionMemberSaveReqVO(); ExecutionAssigneeSaveReqVO reqVO = new ExecutionAssigneeSaveReqVO();
reqVO.setUserId(USER_ID); reqVO.setUserId(USER_ID);
Long memberId = projectExecutionMemberService.createExecutionMember(PROJECT_ID, EXECUTION_ID, reqVO); Long memberId = projectExecutionAssigneeService.createExecutionAssignee(PROJECT_ID, EXECUTION_ID, reqVO);
assertEquals(7001L, memberId); assertEquals(7001L, memberId);
ArgumentCaptor<ExecutionMemberDO> activeCaptor = ArgumentCaptor.forClass(ExecutionMemberDO.class); ArgumentCaptor<ExecutionAssigneeDO> activeCaptor = ArgumentCaptor.forClass(ExecutionAssigneeDO.class);
verify(executionMemberMapper).insert(activeCaptor.capture()); verify(executionAssigneeMapper).insert(activeCaptor.capture());
assertNull(activeCaptor.getValue().getRemovedAt()); assertNull(activeCaptor.getValue().getRemovedAt());
assertNull(activeCaptor.getValue().getRemovedReason()); assertNull(activeCaptor.getValue().getRemovedReason());
ArgumentCaptor<ExecutionMemberLogDO> logCaptor = ArgumentCaptor.forClass(ExecutionMemberLogDO.class); ArgumentCaptor<ExecutionAssigneeLogDO> logCaptor = ArgumentCaptor.forClass(ExecutionAssigneeLogDO.class);
verify(executionMemberLogMapper).insert(logCaptor.capture()); verify(executionAssigneeLogMapper).insert(logCaptor.capture());
assertEquals("join", logCaptor.getValue().getActionType()); assertEquals("join", logCaptor.getValue().getActionType());
assertEquals(USER_ID, logCaptor.getValue().getUserId()); assertEquals(USER_ID, logCaptor.getValue().getUserId());
assertNull(logCaptor.getValue().getReason()); assertNull(logCaptor.getValue().getReason());
@@ -125,49 +125,49 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
} }
@Test @Test
void createExecutionMember_whenInactiveSegmentExists_shouldInsertNewSegmentAndPreserveHistoricalReason() { void createExecutionAssignee_whenInactiveSegmentExists_shouldInsertNewSegmentAndPreserveHistoricalReason() {
// B 模型用户曾失效重新加入新插一段旧段不动 // B 模型用户曾失效重新加入新插一段旧段不动
stubEditableContext(); stubEditableContext();
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID)) when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
.thenReturn(createProjectMember()); .thenReturn(createProjectMember());
// 当前没有活跃段旧段已失效通过 active-only 校验 // 当前没有活跃段旧段已失效通过 active-only 校验
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null); when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
when(executionMemberMapper.insert(any(ExecutionMemberDO.class))).thenAnswer(inv -> { when(executionAssigneeMapper.insert(any(ExecutionAssigneeDO.class))).thenAnswer(inv -> {
ExecutionMemberDO m = inv.getArgument(0); ExecutionAssigneeDO m = inv.getArgument(0);
m.setId(7002L); m.setId(7002L);
return 1; return 1;
}); });
ExecutionMemberSaveReqVO reqVO = new ExecutionMemberSaveReqVO(); ExecutionAssigneeSaveReqVO reqVO = new ExecutionAssigneeSaveReqVO();
reqVO.setUserId(USER_ID); reqVO.setUserId(USER_ID);
Long memberId = projectExecutionMemberService.createExecutionMember(PROJECT_ID, EXECUTION_ID, reqVO); Long memberId = projectExecutionAssigneeService.createExecutionAssignee(PROJECT_ID, EXECUTION_ID, reqVO);
assertEquals(7002L, memberId); assertEquals(7002L, memberId);
// 不应触碰旧段updateById 不应被调用 // 不应触碰旧段updateById 不应被调用
verify(executionMemberMapper, never()).updateById(any(ExecutionMemberDO.class)); verify(executionAssigneeMapper, never()).updateById(any(ExecutionAssigneeDO.class));
verify(executionMemberMapper).insert(any(ExecutionMemberDO.class)); verify(executionAssigneeMapper).insert(any(ExecutionAssigneeDO.class));
} }
@Test @Test
void createExecutionMember_whenAlreadyActive_shouldThrowAlreadyExists() { void createExecutionAssignee_whenAlreadyActive_shouldThrowAlreadyExists() {
stubEditableContext(); stubEditableContext();
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID)) when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
.thenReturn(createProjectMember()); .thenReturn(createProjectMember());
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)) when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
.thenReturn(createMember(7001L, USER_ID, null)); .thenReturn(createMember(7001L, USER_ID, null));
ExecutionMemberSaveReqVO reqVO = new ExecutionMemberSaveReqVO(); ExecutionAssigneeSaveReqVO reqVO = new ExecutionAssigneeSaveReqVO();
reqVO.setUserId(USER_ID); reqVO.setUserId(USER_ID);
ServiceException ex = assertThrows(ServiceException.class, ServiceException ex = assertThrows(ServiceException.class,
() -> projectExecutionMemberService.createExecutionMember(PROJECT_ID, EXECUTION_ID, reqVO)); () -> projectExecutionAssigneeService.createExecutionAssignee(PROJECT_ID, EXECUTION_ID, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_ALREADY_EXISTS.getCode(), ex.getCode()); assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_ALREADY_EXISTS.getCode(), ex.getCode());
verify(executionMemberMapper, never()).insert(any(ExecutionMemberDO.class)); verify(executionAssigneeMapper, never()).insert(any(ExecutionAssigneeDO.class));
} }
@Test @Test
void createExecutionMember_whenExecutionPaused_shouldThrowNotAllowEdit() { void createExecutionAssignee_whenExecutionPaused_shouldThrowNotAllowEdit() {
ProjectExecutionDO execution = createExecution(); ProjectExecutionDO execution = createExecution();
execution.setStatusCode("paused"); execution.setStatusCode("paused");
when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject()); when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject());
@@ -177,32 +177,32 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "paused")) when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "paused"))
.thenReturn(createStatus("execution", "paused", false)); .thenReturn(createStatus("execution", "paused", false));
ExecutionMemberSaveReqVO reqVO = new ExecutionMemberSaveReqVO(); ExecutionAssigneeSaveReqVO reqVO = new ExecutionAssigneeSaveReqVO();
reqVO.setUserId(USER_ID); reqVO.setUserId(USER_ID);
ServiceException ex = assertThrows(ServiceException.class, ServiceException ex = assertThrows(ServiceException.class,
() -> projectExecutionMemberService.createExecutionMember(PROJECT_ID, EXECUTION_ID, reqVO)); () -> projectExecutionAssigneeService.createExecutionAssignee(PROJECT_ID, EXECUTION_ID, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode()); assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode());
} }
// -------------------- inactiveExecutionMember -------------------- // -------------------- inactiveExecutionAssignee --------------------
@Test @Test
void inactiveExecutionMember_shouldSetRemovedAtAndWriteInactiveLog() { void inactiveExecutionAssignee_shouldSetRemovedAtAndWriteInactiveLog() {
ExecutionMemberDO member = createMember(7001L, USER_ID, null); ExecutionAssigneeDO member = createMember(7001L, USER_ID, null);
stubEditableContext(); stubEditableContext();
when(executionMemberMapper.selectByIdAndExecutionId(7001L, EXECUTION_ID)).thenReturn(member); when(executionAssigneeMapper.selectByIdAndExecutionId(7001L, EXECUTION_ID)).thenReturn(member);
ExecutionMemberInactiveReqVO reqVO = new ExecutionMemberInactiveReqVO(); ExecutionAssigneeInactiveReqVO reqVO = new ExecutionAssigneeInactiveReqVO();
reqVO.setReason("阶段性退出"); reqVO.setReason("阶段性退出");
projectExecutionMemberService.inactiveExecutionMember(PROJECT_ID, EXECUTION_ID, 7001L, reqVO); projectExecutionAssigneeService.inactiveExecutionAssignee(PROJECT_ID, EXECUTION_ID, 7001L, reqVO);
assertNotNull(member.getRemovedAt()); assertNotNull(member.getRemovedAt());
assertEquals("阶段性退出", member.getRemovedReason()); assertEquals("阶段性退出", member.getRemovedReason());
verify(executionMemberMapper).updateById(member); verify(executionAssigneeMapper).updateById(member);
ArgumentCaptor<ExecutionMemberLogDO> logCaptor = ArgumentCaptor.forClass(ExecutionMemberLogDO.class); ArgumentCaptor<ExecutionAssigneeLogDO> logCaptor = ArgumentCaptor.forClass(ExecutionAssigneeLogDO.class);
verify(executionMemberLogMapper).insert(logCaptor.capture()); verify(executionAssigneeLogMapper).insert(logCaptor.capture());
assertEquals("inactive", logCaptor.getValue().getActionType()); assertEquals("inactive", logCaptor.getValue().getActionType());
assertEquals(USER_ID, logCaptor.getValue().getUserId()); assertEquals(USER_ID, logCaptor.getValue().getUserId());
assertEquals("阶段性退出", logCaptor.getValue().getReason()); assertEquals("阶段性退出", logCaptor.getValue().getReason());
@@ -210,22 +210,22 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
} }
@Test @Test
void inactiveExecutionMember_whenAlreadyInactive_shouldThrowNotActive() { void inactiveExecutionAssignee_whenAlreadyInactive_shouldThrowNotActive() {
ExecutionMemberDO inactiveMember = createMember(7001L, USER_ID, LocalDateTime.now().minusDays(1)); ExecutionAssigneeDO inactiveMember = createMember(7001L, USER_ID, LocalDateTime.now().minusDays(1));
stubEditableContext(); stubEditableContext();
when(executionMemberMapper.selectByIdAndExecutionId(7001L, EXECUTION_ID)).thenReturn(inactiveMember); when(executionAssigneeMapper.selectByIdAndExecutionId(7001L, EXECUTION_ID)).thenReturn(inactiveMember);
ExecutionMemberInactiveReqVO reqVO = new ExecutionMemberInactiveReqVO(); ExecutionAssigneeInactiveReqVO reqVO = new ExecutionAssigneeInactiveReqVO();
reqVO.setReason("再次失效"); reqVO.setReason("再次失效");
ServiceException ex = assertThrows(ServiceException.class, ServiceException ex = assertThrows(ServiceException.class,
() -> projectExecutionMemberService.inactiveExecutionMember(PROJECT_ID, EXECUTION_ID, 7001L, reqVO)); () -> projectExecutionAssigneeService.inactiveExecutionAssignee(PROJECT_ID, EXECUTION_ID, 7001L, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_NOT_ACTIVE.getCode(), ex.getCode()); assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_NOT_ACTIVE.getCode(), ex.getCode());
verify(executionMemberMapper, never()).updateById(any(ExecutionMemberDO.class)); verify(executionAssigneeMapper, never()).updateById(any(ExecutionAssigneeDO.class));
} }
@Test @Test
void inactiveExecutionMember_whenExecutionPaused_shouldThrowNotAllowEdit() { void inactiveExecutionAssignee_whenExecutionPaused_shouldThrowNotAllowEdit() {
ProjectExecutionDO execution = createExecution(); ProjectExecutionDO execution = createExecution();
execution.setStatusCode("paused"); execution.setStatusCode("paused");
when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject()); when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject());
@@ -235,33 +235,33 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "paused")) when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "paused"))
.thenReturn(createStatus("execution", "paused", false)); .thenReturn(createStatus("execution", "paused", false));
ExecutionMemberInactiveReqVO reqVO = new ExecutionMemberInactiveReqVO(); ExecutionAssigneeInactiveReqVO reqVO = new ExecutionAssigneeInactiveReqVO();
reqVO.setReason("暂停态退出"); reqVO.setReason("暂停态退出");
ServiceException ex = assertThrows(ServiceException.class, ServiceException ex = assertThrows(ServiceException.class,
() -> projectExecutionMemberService.inactiveExecutionMember(PROJECT_ID, EXECUTION_ID, 7001L, reqVO)); () -> projectExecutionAssigneeService.inactiveExecutionAssignee(PROJECT_ID, EXECUTION_ID, 7001L, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode()); assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode());
} }
// -------------------- getExecutionMemberLogPage -------------------- // -------------------- getExecutionAssigneeLogPage --------------------
@Test @Test
void getExecutionMemberLogPage_shouldDelegateToMapperAndConvertVO() { void getExecutionAssigneeLogPage_shouldDelegateToMapperAndConvertVO() {
when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject()); when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject());
when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID)) when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID))
.thenReturn(createExecution()); .thenReturn(createExecution());
ExecutionMemberLogDO logRow = new ExecutionMemberLogDO(); ExecutionAssigneeLogDO logRow = new ExecutionAssigneeLogDO();
logRow.setId(12001L); logRow.setId(12001L);
logRow.setExecutionId(EXECUTION_ID); logRow.setExecutionId(EXECUTION_ID);
logRow.setUserId(USER_ID); logRow.setUserId(USER_ID);
logRow.setActionType("join"); logRow.setActionType("join");
logRow.setActionTime(LocalDateTime.now()); logRow.setActionTime(LocalDateTime.now());
when(executionMemberLogMapper.selectPageByExecutionId(eq(EXECUTION_ID), any(ExecutionMemberLogPageReqVO.class))) when(executionAssigneeLogMapper.selectPageByExecutionId(eq(EXECUTION_ID), any(ExecutionAssigneeLogPageReqVO.class)))
.thenReturn(new PageResult<>(List.of(logRow), 1L)); .thenReturn(new PageResult<>(List.of(logRow), 1L));
ExecutionMemberLogPageReqVO reqVO = new ExecutionMemberLogPageReqVO(); ExecutionAssigneeLogPageReqVO reqVO = new ExecutionAssigneeLogPageReqVO();
PageResult<ExecutionMemberLogRespVO> page = projectExecutionMemberService PageResult<ExecutionAssigneeLogRespVO> page = projectExecutionAssigneeService
.getExecutionMemberLogPage(PROJECT_ID, EXECUTION_ID, reqVO); .getExecutionAssigneeLogPage(PROJECT_ID, EXECUTION_ID, reqVO);
assertEquals(1L, page.getTotal()); assertEquals(1L, page.getTotal());
assertEquals("join", page.getList().get(0).getActionType()); assertEquals("join", page.getList().get(0).getActionType());
@@ -269,11 +269,11 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
} }
@Test @Test
void getExecutionMemberLogPage_whenSnapshotExists_shouldUseCurrentNicknamesByAdminUserApi() { void getExecutionAssigneeLogPage_whenSnapshotExists_shouldUseCurrentNicknamesByAdminUserApi() {
when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject()); when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject());
when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID)) when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID))
.thenReturn(createExecution()); .thenReturn(createExecution());
ExecutionMemberLogDO logRow = new ExecutionMemberLogDO(); ExecutionAssigneeLogDO logRow = new ExecutionAssigneeLogDO();
logRow.setId(12002L); logRow.setId(12002L);
logRow.setExecutionId(EXECUTION_ID); logRow.setExecutionId(EXECUTION_ID);
logRow.setUserId(USER_ID); logRow.setUserId(USER_ID);
@@ -282,29 +282,29 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
logRow.setOperatorUserId(OWNER_ID); logRow.setOperatorUserId(OWNER_ID);
logRow.setOperatorNicknameSnapshot("旧操作人名"); logRow.setOperatorNicknameSnapshot("旧操作人名");
logRow.setActionTime(LocalDateTime.now()); logRow.setActionTime(LocalDateTime.now());
when(executionMemberLogMapper.selectPageByExecutionId(eq(EXECUTION_ID), any(ExecutionMemberLogPageReqVO.class))) when(executionAssigneeLogMapper.selectPageByExecutionId(eq(EXECUTION_ID), any(ExecutionAssigneeLogPageReqVO.class)))
.thenReturn(new PageResult<>(List.of(logRow), 1L)); .thenReturn(new PageResult<>(List.of(logRow), 1L));
when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of( when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(
USER_ID, createUser("陈道飞"), USER_ID, createUser("陈道飞"),
OWNER_ID, createUser("灿能源码"))); OWNER_ID, createUser("灿能源码")));
ExecutionMemberLogPageReqVO reqVO = new ExecutionMemberLogPageReqVO(); ExecutionAssigneeLogPageReqVO reqVO = new ExecutionAssigneeLogPageReqVO();
PageResult<ExecutionMemberLogRespVO> page = projectExecutionMemberService PageResult<ExecutionAssigneeLogRespVO> page = projectExecutionAssigneeService
.getExecutionMemberLogPage(PROJECT_ID, EXECUTION_ID, reqVO); .getExecutionAssigneeLogPage(PROJECT_ID, EXECUTION_ID, reqVO);
assertEquals("陈道飞", page.getList().get(0).getUserNicknameSnapshot()); assertEquals("陈道飞", page.getList().get(0).getUserNicknameSnapshot());
assertEquals("灿能源码", page.getList().get(0).getOperatorNicknameSnapshot()); assertEquals("灿能源码", page.getList().get(0).getOperatorNicknameSnapshot());
verify(adminUserApi).getUserMap(anyCollection()); verify(adminUserApi).getUserMap(anyCollection());
} }
// -------------------- writeMemberLog 直接调用 -------------------- // -------------------- writeAssigneeLog 直接调用 --------------------
@Test @Test
void writeMemberLog_shouldInsertWithProvidedFields() { void writeAssigneeLog_shouldInsertWithProvidedFields() {
projectExecutionMemberService.writeMemberLog(EXECUTION_ID, USER_ID, "owner_transfer_in", "test reason"); projectExecutionAssigneeService.writeAssigneeLog(EXECUTION_ID, USER_ID, "owner_transfer_in", "test reason");
ArgumentCaptor<ExecutionMemberLogDO> captor = ArgumentCaptor.forClass(ExecutionMemberLogDO.class); ArgumentCaptor<ExecutionAssigneeLogDO> captor = ArgumentCaptor.forClass(ExecutionAssigneeLogDO.class);
verify(executionMemberLogMapper).insert(captor.capture()); verify(executionAssigneeLogMapper).insert(captor.capture());
assertEquals(EXECUTION_ID, captor.getValue().getExecutionId()); assertEquals(EXECUTION_ID, captor.getValue().getExecutionId());
assertEquals(USER_ID, captor.getValue().getUserId()); assertEquals(USER_ID, captor.getValue().getUserId());
assertEquals("owner_transfer_in", captor.getValue().getActionType()); assertEquals("owner_transfer_in", captor.getValue().getActionType());
@@ -346,8 +346,8 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
return execution; return execution;
} }
private ExecutionMemberDO createMember(Long id, Long userId, LocalDateTime removedAt) { private ExecutionAssigneeDO createMember(Long id, Long userId, LocalDateTime removedAt) {
ExecutionMemberDO member = new ExecutionMemberDO(); ExecutionAssigneeDO member = new ExecutionAssigneeDO();
member.setId(id); member.setId(id);
member.setExecutionId(EXECUTION_ID); member.setExecutionId(EXECUTION_ID);
member.setUserId(userId); member.setUserId(userId);

View File

@@ -5,13 +5,15 @@ import com.njcn.rdms.framework.common.pojo.PageResult;
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.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.ProjectExecutionSaveReqVO; 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.ProjectExecutionCreateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO; import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO; import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants; import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
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.dataobject.project.ProjectDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
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.dal.dataobject.project.execution.ProjectExecutionStatusLogDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionStatusLogDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO; import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
@@ -19,14 +21,17 @@ 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.project.ProjectMapper; import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberMapper; 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.execution.ProjectExecutionMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionStatusLogMapper; import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionStatusLogMapper;
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;
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;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
@@ -34,7 +39,11 @@ import org.mockito.Mock;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map;
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.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -42,7 +51,10 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.lenient;
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;
@@ -56,7 +68,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
@Mock @Mock
private ProjectExecutionMapper projectExecutionMapper; private ProjectExecutionMapper projectExecutionMapper;
@Mock @Mock
private ExecutionMemberMapper executionMemberMapper; private ExecutionAssigneeMapper executionAssigneeMapper;
@Mock @Mock
private UserObjectRoleMapper userObjectRoleMapper; private UserObjectRoleMapper userObjectRoleMapper;
@Mock @Mock
@@ -68,19 +80,39 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
@Mock @Mock
private ProjectExecutionStatusLogMapper projectExecutionStatusLogMapper; private ProjectExecutionStatusLogMapper projectExecutionStatusLogMapper;
@Mock @Mock
private ProjectTaskMapper projectTaskMapper;
@Mock
private DictDataApi dictDataApi; private DictDataApi dictDataApi;
@Mock @Mock
private ProjectExecutionMemberService projectExecutionMemberService; private AdminUserApi adminUserApi;
@Mock
private ProjectExecutionStatusViewService projectExecutionStatusViewService;
@Mock
private ProjectExecutionAssigneeService projectExecutionAssigneeService;
@Mock
private com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver visibilityScopeResolver;
/**
* 默认让 VisibilityScopeResolver 放行seesAll=true既有测试无需关心 scope。
* 真正需要测试 scope 行为的用例可在方法内显式覆盖。
*/
@BeforeEach
void setupVisibilityScopeAll() {
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());
}
@Test @Test
void createExecution_shouldInsertPendingExecutionAndMembers() { void createExecution_shouldInsertPendingExecutionAndMembers() {
Long projectId = 2001L; Long projectId = 2001L;
ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO(); ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
reqVO.setExecutionName("后端接口联调"); reqVO.setExecutionName("后端接口联调");
reqVO.setExecutionType("feature"); reqVO.setExecutionType("feature");
reqVO.setOwnerId(3001L); reqVO.setOwnerId(3001L);
reqVO.setProjectRequirementId(null); reqVO.setProjectRequirementId(null);
reqVO.setMemberUserIds(List.of(3002L, 3003L)); reqVO.setAssigneeUserIds(List.of(3002L, 3003L));
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
@@ -113,14 +145,14 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
assertNull(executionCaptor.getValue().getProjectRequirementId()); assertNull(executionCaptor.getValue().getProjectRequirementId());
assertNull(executionCaptor.getValue().getActualStartDate()); assertNull(executionCaptor.getValue().getActualStartDate());
assertNull(executionCaptor.getValue().getActualEndDate()); assertNull(executionCaptor.getValue().getActualEndDate());
verify(executionMemberMapper, times(2)).insert(any(ExecutionMemberDO.class)); verify(executionAssigneeMapper, times(2)).insert(any(ExecutionAssigneeDO.class));
verify(bizAuditLogMapper, atLeastOnce()).insert(any(BizAuditLogDO.class)); verify(bizAuditLogMapper, atLeastOnce()).insert(any(BizAuditLogDO.class));
} }
@Test @Test
void createExecution_whenRequirementIdNotNull_shouldThrowNotReady() { void createExecution_whenRequirementIdNotNull_shouldThrowNotReady() {
Long projectId = 2001L; Long projectId = 2001L;
ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO(); ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
reqVO.setExecutionName("后端接口联调"); reqVO.setExecutionName("后端接口联调");
reqVO.setOwnerId(3001L); reqVO.setOwnerId(3001L);
reqVO.setProjectRequirementId(9001L); reqVO.setProjectRequirementId(9001L);
@@ -138,11 +170,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
@Test @Test
void createExecution_whenExecutionTypeInvalid_shouldThrow() { void createExecution_whenExecutionTypeInvalid_shouldThrow() {
Long projectId = 2001L; Long projectId = 2001L;
ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO(); ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
reqVO.setExecutionName("后端接口联调"); reqVO.setExecutionName("后端接口联调");
reqVO.setExecutionType("unknown"); reqVO.setExecutionType("unknown");
reqVO.setOwnerId(3001L); reqVO.setOwnerId(3001L);
reqVO.setMemberUserIds(List.of(3002L)); reqVO.setAssigneeUserIds(List.of(3002L));
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
@@ -162,11 +194,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
@Test @Test
void createExecution_whenMemberListEmpty_shouldThrowRequired() { void createExecution_whenMemberListEmpty_shouldThrowRequired() {
Long projectId = 2001L; Long projectId = 2001L;
ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO(); ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
reqVO.setExecutionName("后端接口联调"); reqVO.setExecutionName("后端接口联调");
reqVO.setExecutionType("feature"); reqVO.setExecutionType("feature");
reqVO.setOwnerId(3001L); reqVO.setOwnerId(3001L);
reqVO.setMemberUserIds(List.of()); reqVO.setAssigneeUserIds(List.of());
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
@@ -180,7 +212,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
ServiceException ex = assertThrows(ServiceException.class, ServiceException ex = assertThrows(ServiceException.class,
() -> projectExecutionService.createExecution(projectId, reqVO)); () -> projectExecutionService.createExecution(projectId, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_REQUIRED.getCode(), ex.getCode()); assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_REQUIRED.getCode(), ex.getCode());
} }
@Test @Test
@@ -188,11 +220,10 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
Long projectId = 2001L; Long projectId = 2001L;
Long executionId = 5001L; Long executionId = 5001L;
ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L); ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO(); ProjectExecutionUpdateReqVO reqVO = new ProjectExecutionUpdateReqVO();
reqVO.setId(executionId); reqVO.setId(executionId);
reqVO.setExecutionName("接口联调-修订"); reqVO.setExecutionName("接口联调-修订");
reqVO.setExecutionType("feature"); reqVO.setExecutionType("feature");
reqVO.setOwnerId(3001L);
reqVO.setProjectRequirementId(null); reqVO.setProjectRequirementId(null);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
@@ -237,10 +268,10 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
verify(projectExecutionMapper).updateById(execution); verify(projectExecutionMapper).updateById(execution);
verify(bizAuditLogMapper).insert(any(BizAuditLogDO.class)); verify(bizAuditLogMapper).insert(any(BizAuditLogDO.class));
// 双向写入成员变更历史:原 owner 转出 + 新 owner 转入 // 双向写入成员变更历史:原 owner 转出 + 新 owner 转入
verify(projectExecutionMemberService).writeMemberLog(executionId, 3001L, verify(projectExecutionAssigneeService).writeAssigneeLog(executionId, 3001L,
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_OUT, "负责人调整"); ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_OUT, "负责人调整");
verify(projectExecutionMemberService).writeMemberLog(executionId, 3002L, verify(projectExecutionAssigneeService).writeAssigneeLog(executionId, 3002L,
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_IN, "负责人调整"); ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN, "负责人调整");
} }
@Test @Test
@@ -338,10 +369,9 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
Long executionId = 5001L; Long executionId = 5001L;
ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L); ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
execution.setStatusCode("paused"); execution.setStatusCode("paused");
ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO(); ProjectExecutionUpdateReqVO reqVO = new ProjectExecutionUpdateReqVO();
reqVO.setId(executionId); reqVO.setId(executionId);
reqVO.setExecutionName("接口联调-修订"); reqVO.setExecutionName("接口联调-修订");
reqVO.setOwnerId(3001L);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending")) when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
@@ -378,6 +408,104 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode()); assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode());
} }
@Test
void getExecutionRespVO_shouldOverwriteCachedProgressWithAggregatedRootTaskProgress() {
Long projectId = 2001L;
Long executionId = 5001L;
ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
execution.setProgressRate(BigDecimal.ZERO);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId))
.thenReturn(new BigDecimal("66.666"));
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
.thenReturn(createLifecycleView());
ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);
assertEquals(new BigDecimal("66.67"), result.getProgressRate());
verify(projectTaskMapper).selectRootTaskAvgProgressByExecutionId(projectId, executionId);
}
@Test
void getExecutionRespVO_whenExecutionHasNoRootTasks_shouldReturnZeroProgress() {
Long projectId = 2001L;
Long executionId = 5001L;
ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
execution.setProgressRate(new BigDecimal("100.00"));
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId)).thenReturn(null);
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
.thenReturn(createLifecycleView());
ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);
assertEquals(new BigDecimal("0.00"), result.getProgressRate());
}
@Test
void getExecutionRespVOPage_shouldBatchOverwriteCachedProgress() {
Long projectId = 2001L;
ProjectExecutionPageReqVO reqVO = new ProjectExecutionPageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
ProjectExecutionDO first = createExecution(projectId, 5001L, 3001L);
first.setProgressRate(BigDecimal.ZERO);
ProjectExecutionDO second = createExecution(projectId, 5002L, 3002L);
second.setProgressRate(new BigDecimal("100.00"));
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
.thenReturn(new PageResult<>(List.of(first, second), 2L));
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection()))
.thenReturn(List.of(Map.of("execution_id", 5001L, "progress_rate", new BigDecimal("25.555"))));
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
.thenReturn(createLifecycleView());
when(projectExecutionStatusViewService.getLifecycle("pending", 3002L))
.thenReturn(createLifecycleView());
PageResult<ProjectExecutionRespVO> result = projectExecutionService.getExecutionRespVOPage(projectId, reqVO);
assertEquals(new BigDecimal("25.56"), result.getList().get(0).getProgressRate());
assertEquals(new BigDecimal("0.00"), result.getList().get(1).getProgressRate());
ArgumentCaptor<Collection<Long>> executionIdsCaptor = ArgumentCaptor.forClass(Collection.class);
verify(projectTaskMapper).selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), executionIdsCaptor.capture());
assertEquals(List.of(5001L, 5002L), List.copyOf(executionIdsCaptor.getValue()));
}
@Test
void getExecutionRespVOPage_shouldTolerateLooseAggregationRows() {
Long projectId = 2001L;
ProjectExecutionPageReqVO reqVO = new ProjectExecutionPageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
ProjectExecutionDO first = createExecution(projectId, 5001L, 3001L);
ProjectExecutionDO second = createExecution(projectId, 5002L, 3002L);
List<Map<String, Object>> rows = new ArrayList<>();
rows.add(null);
rows.add(Map.of("executionId", " 5001 ", "progressRate", " 25.555 "));
rows.add(Map.of("executionId", 5002L, "progressRate", 10));
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
.thenReturn(new PageResult<>(List.of(first, second), 2L));
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection()))
.thenReturn(rows);
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
.thenReturn(createLifecycleView());
when(projectExecutionStatusViewService.getLifecycle("pending", 3002L))
.thenReturn(createLifecycleView());
PageResult<ProjectExecutionRespVO> result = projectExecutionService.getExecutionRespVOPage(projectId, reqVO);
assertEquals(new BigDecimal("25.56"), result.getList().get(0).getProgressRate());
assertEquals(new BigDecimal("10.00"), result.getList().get(1).getProgressRate());
}
@Test @Test
void getExecutionPage_shouldDelegateMapper() { void getExecutionPage_shouldDelegateMapper() {
Long projectId = 2001L; Long projectId = 2001L;
@@ -385,7 +513,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(projectId, reqVO)) when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
.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);
@@ -428,6 +556,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
return transition; return transition;
} }
private ProjectExecutionStatusViewService.ProjectExecutionLifecycleView createLifecycleView() {
return new ProjectExecutionStatusViewService.ProjectExecutionLifecycleView(
"待处理", false, true, Collections.emptyList());
}
private UserObjectRoleDO createProjectMember(Long projectId, Long userId, Long roleId) { private UserObjectRoleDO createProjectMember(Long projectId, Long userId, Long roleId) {
UserObjectRoleDO member = new UserObjectRoleDO(); UserObjectRoleDO member = new UserObjectRoleDO();
member.setId(projectId + userId); member.setId(projectId + userId);

View File

@@ -1,6 +1,7 @@
package com.njcn.rdms.module.project.service.project.execution; package com.njcn.rdms.module.project.service.project.execution;
import com.njcn.rdms.framework.common.exception.ServiceException; 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.framework.test.core.ut.BaseMockitoUnitTest;
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.dataobject.status.ObjectStatusTransitionDO; import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
@@ -10,6 +11,7 @@ import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
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;
import org.mockito.MockedStatic;
import java.util.List; import java.util.List;
@@ -17,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; 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.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest { class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
@@ -29,34 +32,76 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
private ObjectStatusTransitionMapper objectStatusTransitionMapper; private ObjectStatusTransitionMapper objectStatusTransitionMapper;
@Test @Test
void getLifecycle_shouldReturnExecutionStatusMetadataAndActions() { void getLifecycle_whenLoginUserIsOwner_shouldReturnOwnerOnlyAction() {
ObjectStatusModelDO statusModel = new ObjectStatusModelDO(); Long ownerId = 3001L;
statusModel.setObjectType("execution"); ObjectStatusModelDO statusModel = createStatusModel();
statusModel.setStatusCode("active"); ObjectStatusTransitionDO pause = createTransition("pause", "暂停", true);
statusModel.setStatusName("进行中");
statusModel.setTerminalFlag(false);
statusModel.setAllowEdit(true);
ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
transition.setActionCode("pause");
transition.setActionName("暂停");
transition.setNeedReason(true);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "active")) when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "active"))
.thenReturn(statusModel); .thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("execution", "active")) when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("execution", "active"))
.thenReturn(List.of(transition)); .thenReturn(List.of(pause));
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result = try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
projectExecutionStatusViewService.getLifecycle("active"); mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
assertEquals("进行中", result.statusName()); ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
assertFalse(result.terminal()); projectExecutionStatusViewService.getLifecycle("active", ownerId);
assertTrue(result.allowEdit());
assertEquals(1, result.availableActions().size()); assertEquals("进行中", result.statusName());
assertEquals("pause", result.availableActions().get(0).getActionCode()); assertFalse(result.terminal());
assertEquals("暂停", result.availableActions().get(0).getActionName()); assertTrue(result.allowEdit());
assertTrue(result.availableActions().get(0).getNeedReason()); assertEquals(1, result.availableActions().size());
assertEquals("pause", result.availableActions().get(0).getActionCode());
assertEquals("暂停", result.availableActions().get(0).getActionName());
assertTrue(result.availableActions().get(0).getNeedReason());
}
}
@Test
void getLifecycle_whenLoginUserNotOwner_shouldFilterOwnerOnlyActions() {
Long ownerId = 3001L;
Long otherUserId = 3002L;
ObjectStatusModelDO statusModel = createStatusModel();
ObjectStatusTransitionDO pause = createTransition("pause", "暂停", true);
ObjectStatusTransitionDO complete = createTransition("complete", "完成", false);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "active"))
.thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("execution", "active"))
.thenReturn(List.of(pause, complete));
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(otherUserId);
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
projectExecutionStatusViewService.getLifecycle("active", ownerId);
assertTrue(result.availableActions().isEmpty());
}
}
@Test
void getLifecycle_shouldExcludeAutoStartAction() {
Long ownerId = 3001L;
ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
statusModel.setObjectType("execution");
statusModel.setStatusCode("pending");
statusModel.setStatusName("待开始");
statusModel.setTerminalFlag(false);
statusModel.setAllowEdit(true);
ObjectStatusTransitionDO autoStart = createTransition("auto_start", "开始推进", false);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("execution", "pending"))
.thenReturn(List.of(autoStart));
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
projectExecutionStatusViewService.getLifecycle("pending", ownerId);
assertTrue(result.availableActions().isEmpty());
}
} }
@Test @Test
@@ -65,8 +110,26 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
.thenReturn(null); .thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class, ServiceException ex = assertThrows(ServiceException.class,
() -> projectExecutionStatusViewService.getLifecycle("active")); () -> projectExecutionStatusViewService.getLifecycle("active", 3001L));
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED.getCode(), ex.getCode()); assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED.getCode(), ex.getCode());
} }
private ObjectStatusModelDO createStatusModel() {
ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
statusModel.setObjectType("execution");
statusModel.setStatusCode("active");
statusModel.setStatusName("进行中");
statusModel.setTerminalFlag(false);
statusModel.setAllowEdit(true);
return statusModel;
}
private ObjectStatusTransitionDO createTransition(String actionCode, String actionName, boolean needReason) {
ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
transition.setActionCode(actionCode);
transition.setActionName(actionName);
transition.setNeedReason(needReason);
return transition;
}
} }

View File

@@ -0,0 +1,126 @@
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

@@ -5,8 +5,9 @@ import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskSaveReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusActionReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusActionReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO; import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
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.dal.dataobject.project.task.ProjectTaskDO; import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskStatusLogDO; import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskStatusLogDO;
@@ -14,7 +15,7 @@ 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.dataobject.status.ObjectStatusTransitionDO;
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.project.ProjectMapper; import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberMapper; 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.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.project.task.ProjectTaskStatusLogMapper; import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskStatusLogMapper;
@@ -22,8 +23,11 @@ import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper;
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;
import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver;
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.task.assignee.TaskAssigneeService; import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
@@ -38,6 +42,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -51,7 +56,7 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
@Mock @Mock
private ProjectExecutionMapper projectExecutionMapper; private ProjectExecutionMapper projectExecutionMapper;
@Mock @Mock
private ExecutionMemberMapper executionMemberMapper; private ExecutionAssigneeMapper executionAssigneeMapper;
@Mock @Mock
private ProjectTaskMapper projectTaskMapper; private ProjectTaskMapper projectTaskMapper;
@Mock @Mock
@@ -68,6 +73,23 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
private ProjectService projectService; private ProjectService projectService;
@Mock @Mock
private TaskAssigneeService taskAssigneeService; private TaskAssigneeService taskAssigneeService;
@Mock
private AttachmentFileIdResolver attachmentFileIdResolver;
@Mock
private ProjectObjectAuthorizationService projectObjectAuthorizationService;
@Mock
private com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver visibilityScopeResolver;
/**
* 默认让 VisibilityScopeResolver 放行seesAll=true既有测试无需关心 scope。
*/
@BeforeEach
void setupVisibilityScopeAll() {
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());
}
@Test @Test
void createTask_shouldInsertPendingTaskAndAutoStartProject() { void createTask_shouldInsertPendingTaskAndAutoStartProject() {
@@ -85,8 +107,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createStatus("execution", "pending", true)); .thenReturn(createStatus("execution", "pending", true));
when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task")) when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task"))
.thenReturn(createStatus("task", "pending", true)); .thenReturn(createStatus("task", "pending", true));
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L)) when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionMember(executionId, 3002L)); .thenReturn(createExecutionAssignee(executionId, 3002L));
when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(invocation -> { when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(invocation -> {
ProjectTaskDO task = invocation.getArgument(0); ProjectTaskDO task = invocation.getArgument(0);
task.setId(9001L); task.setId(9001L);
@@ -108,6 +130,45 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
verify(bizAuditLogMapper).insert(any(BizAuditLogDO.class)); verify(bizAuditLogMapper).insert(any(BizAuditLogDO.class));
} }
@Test
void createTask_shouldPersistAttachmentFileId() {
Long projectId = 2001L;
Long executionId = 5001L;
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
reqVO.setOwnerId(3002L);
AttachmentItem attachment = new AttachmentItem();
attachment.setId("10001");
attachment.setUrl("http://oss.example.com/task/2026/05/report.txt");
attachment.setName("report.txt");
attachment.setSize(3164L);
attachment.setContentType("text/plain");
reqVO.setAttachments(List.of(attachment));
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
.thenReturn(createEditableExecution(projectId, executionId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createStatus("project", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createStatus("execution", "pending", true));
when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task"))
.thenReturn(createStatus("task", "pending", true));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionAssignee(executionId, 3002L));
when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(invocation -> {
ProjectTaskDO task = invocation.getArgument(0);
task.setId(9001L);
return 1;
});
projectTaskService.createTask(projectId, executionId, reqVO);
ArgumentCaptor<ProjectTaskDO> taskCaptor = ArgumentCaptor.forClass(ProjectTaskDO.class);
verify(projectTaskMapper).insert(taskCaptor.capture());
verify(attachmentFileIdResolver).resolve(reqVO.getAttachments());
assertEquals("10001", taskCaptor.getValue().getAttachments().get(0).getId());
}
@Test @Test
void createSubTask_whenOwnerBlank_shouldInheritParentOwner() { void createSubTask_whenOwnerBlank_shouldInheritParentOwner() {
Long projectId = 2001L; Long projectId = 2001L;
@@ -128,8 +189,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createStatus("execution", "pending", true)); .thenReturn(createStatus("execution", "pending", true));
when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task")) when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task"))
.thenReturn(createStatus("task", "pending", true)); .thenReturn(createStatus("task", "pending", true));
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L)) when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionMember(executionId, 3002L)); .thenReturn(createExecutionAssignee(executionId, 3002L));
when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(invocation -> { when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(invocation -> {
ProjectTaskDO task = invocation.getArgument(0); ProjectTaskDO task = invocation.getArgument(0);
task.setId(9002L); task.setId(9002L);
@@ -145,7 +206,7 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
} }
@Test @Test
void createTask_whenOwnerNotActiveExecutionMember_shouldThrow() { void createTask_whenOwnerNotActiveExecutionAssignee_shouldThrow() {
Long projectId = 2001L; Long projectId = 2001L;
Long executionId = 5001L; Long executionId = 5001L;
ProjectTaskSaveReqVO reqVO = createTaskReqVO(); ProjectTaskSaveReqVO reqVO = createTaskReqVO();
@@ -158,7 +219,7 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createStatus("project", "pending", true)); .thenReturn(createStatus("project", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending")) when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createStatus("execution", "pending", true)); .thenReturn(createStatus("execution", "pending", true));
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3999L)).thenReturn(null); when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3999L)).thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class, ServiceException ex = assertThrows(ServiceException.class,
() -> projectTaskService.createTask(projectId, executionId, reqVO)); () -> projectTaskService.createTask(projectId, executionId, reqVO));
@@ -205,6 +266,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO); projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO);
verify(projectObjectAuthorizationService).checkOwnerOrProjectPermission(projectId, 3002L,
"project:task:status");
verify(projectTaskMapper).updateStatusByIdAndStatus(taskId, "pending", "active", null); verify(projectTaskMapper).updateStatusByIdAndStatus(taskId, "pending", "active", null);
ArgumentCaptor<ProjectTaskStatusLogDO> statusCaptor = ArgumentCaptor.forClass(ProjectTaskStatusLogDO.class); ArgumentCaptor<ProjectTaskStatusLogDO> statusCaptor = ArgumentCaptor.forClass(ProjectTaskStatusLogDO.class);
verify(projectTaskStatusLogMapper).insert(statusCaptor.capture()); verify(projectTaskStatusLogMapper).insert(statusCaptor.capture());
@@ -276,8 +339,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createStatus("execution", "pending", true)); .thenReturn(createStatus("execution", "pending", true));
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId)) when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
.thenReturn(parentTask); .thenReturn(parentTask);
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L)) when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionMember(executionId, 3002L)); .thenReturn(createExecutionAssignee(executionId, 3002L));
when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0); when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0);
ServiceException ex = assertThrows(ServiceException.class, ServiceException ex = assertThrows(ServiceException.class,
@@ -306,8 +369,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createStatus("execution", "pending", true)); .thenReturn(createStatus("execution", "pending", true));
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId)) when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
.thenReturn(parentTask); .thenReturn(parentTask);
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L)) when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionMember(executionId, 3002L)); .thenReturn(createExecutionAssignee(executionId, 3002L));
when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0); when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0);
when(taskWorklogMapper.existsByTaskId(parentTaskId)).thenReturn(true); when(taskWorklogMapper.existsByTaskId(parentTaskId)).thenReturn(true);
@@ -340,8 +403,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createStatus("task", "pending", true)); .thenReturn(createStatus("task", "pending", true));
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId)) when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
.thenReturn(parentTask); .thenReturn(parentTask);
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L)) when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionMember(executionId, 3002L)); .thenReturn(createExecutionAssignee(executionId, 3002L));
// 已有 1 个子任务 → 跳过叶子转父校验 // 已有 1 个子任务 → 跳过叶子转父校验
when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(1); when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(1);
// 插入新任务id=9002父任务下有 2 个子,进度分别 60.00 与 0.00 → AVG 30.00 // 插入新任务id=9002父任务下有 2 个子,进度分别 60.00 与 0.00 → AVG 30.00
@@ -379,7 +442,6 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
ProjectTaskDO grandparent = createTask(projectId, executionId, grandparentId, 3002L); ProjectTaskDO grandparent = createTask(projectId, executionId, grandparentId, 3002L);
grandparent.setProgressRate(new BigDecimal("50.00")); grandparent.setProgressRate(new BigDecimal("50.00"));
ProjectTaskSaveReqVO reqVO = createTaskReqVO(); ProjectTaskSaveReqVO reqVO = createTaskReqVO();
reqVO.setProgressRate(new BigDecimal("80.00"));
reqVO.setParentTaskId(parentTaskId); reqVO.setParentTaskId(parentTaskId);
reqVO.setOwnerId(3002L); reqVO.setOwnerId(3002L);
@@ -394,8 +456,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createStatus("task", "pending", true)); .thenReturn(createStatus("task", "pending", true));
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId)) when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
.thenReturn(parentTask); .thenReturn(parentTask);
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L)) when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionMember(executionId, 3002L)); .thenReturn(createExecutionAssignee(executionId, 3002L));
when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0); when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0);
when(taskWorklogMapper.existsByTaskId(parentTaskId)).thenReturn(false); when(taskWorklogMapper.existsByTaskId(parentTaskId)).thenReturn(false);
when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(inv -> { when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(inv -> {
@@ -423,117 +485,6 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
verify(projectTaskMapper).updateProgressRateById(eq(grandparentId), any(BigDecimal.class)); verify(projectTaskMapper).updateProgressRateById(eq(grandparentId), any(BigDecimal.class));
} }
@Test
void updateTask_whenTaskIsParent_progressManualEditDifferent_shouldThrow() {
Long projectId = 2001L;
Long executionId = 5001L;
Long taskId = 9001L;
ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
task.setProgressRate(new BigDecimal("40.00"));
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
reqVO.setId(taskId);
reqVO.setOwnerId(3002L);
reqVO.setProgressRate(new BigDecimal("90.00"));
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
.thenReturn(createEditableExecution(projectId, executionId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createStatus("project", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createStatus("execution", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "pending"))
.thenReturn(createStatus("task", "pending", true));
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionMember(executionId, 3002L));
// 当前任务有子 → 是父任务
when(projectTaskMapper.countChildrenByParentTaskId(taskId)).thenReturn(2);
ServiceException ex = assertThrows(ServiceException.class,
() -> projectTaskService.updateTask(projectId, executionId, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_TASK_PROGRESS_PARENT_NOT_EDITABLE.getCode(), ex.getCode());
verify(projectTaskMapper, never()).updateById(any(ProjectTaskDO.class));
}
@Test
void updateTask_whenTaskIsParent_progressEqualOriginal_shouldKeepValue() {
Long projectId = 2001L;
Long executionId = 5001L;
Long taskId = 9001L;
ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
task.setProgressRate(new BigDecimal("40.00"));
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
reqVO.setId(taskId);
reqVO.setOwnerId(3002L);
// 前端把当前值原样回传 → 不应抛错,且 progressRate 保留为父任务原值
reqVO.setProgressRate(new BigDecimal("40.00"));
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
.thenReturn(createEditableExecution(projectId, executionId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createStatus("project", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createStatus("execution", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "pending"))
.thenReturn(createStatus("task", "pending", true));
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionMember(executionId, 3002L));
when(projectTaskMapper.countChildrenByParentTaskId(taskId)).thenReturn(2);
projectTaskService.updateTask(projectId, executionId, reqVO);
ArgumentCaptor<ProjectTaskDO> taskCaptor = ArgumentCaptor.forClass(ProjectTaskDO.class);
verify(projectTaskMapper).updateById(taskCaptor.capture());
assertEquals(0, new BigDecimal("40.00").compareTo(taskCaptor.getValue().getProgressRate()));
}
@Test
void updateTask_leafProgressChange_shouldRecalcParent() {
Long projectId = 2001L;
Long executionId = 5001L;
Long taskId = 9001L;
Long parentTaskId = 8001L;
ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
task.setParentTaskId(parentTaskId);
task.setProgressRate(new BigDecimal("20.00"));
ProjectTaskDO parentTask = createTask(projectId, executionId, parentTaskId, 3002L);
parentTask.setProgressRate(new BigDecimal("20.00"));
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
reqVO.setId(taskId);
reqVO.setParentTaskId(parentTaskId);
reqVO.setOwnerId(3002L);
reqVO.setProgressRate(new BigDecimal("60.00"));
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
.thenReturn(createEditableExecution(projectId, executionId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createStatus("project", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createStatus("execution", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "pending"))
.thenReturn(createStatus("task", "pending", true));
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
.thenReturn(parentTask);
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionMember(executionId, 3002L));
// 当前任务无子 → 是叶子
when(projectTaskMapper.countChildrenByParentTaskId(taskId)).thenReturn(0);
when(projectTaskMapper.selectById(parentTaskId)).thenReturn(parentTask);
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId))
.thenReturn(List.of(task));
projectTaskService.updateTask(projectId, executionId, reqVO);
verify(projectTaskMapper).updateProgressRateById(eq(parentTaskId),
argThat(v -> new BigDecimal("60.00").compareTo(v) == 0));
}
@Test @Test
void updateTask_movedToNewParent_shouldValidateAndRecalcBothChains() { void updateTask_movedToNewParent_shouldValidateAndRecalcBothChains() {
Long projectId = 2001L; Long projectId = 2001L;
@@ -552,7 +503,6 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
reqVO.setId(taskId); reqVO.setId(taskId);
reqVO.setParentTaskId(newParentId); reqVO.setParentTaskId(newParentId);
reqVO.setOwnerId(3002L); reqVO.setOwnerId(3002L);
reqVO.setProgressRate(new BigDecimal("70.00"));
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId)); when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)) when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
@@ -566,8 +516,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task); when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, newParentId)) when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, newParentId))
.thenReturn(newParent); .thenReturn(newParent);
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L)) when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionMember(executionId, 3002L)); .thenReturn(createExecutionAssignee(executionId, 3002L));
// 校验"叶子转父":新父 8002 当前是叶子,进度=0无工时 → 通过 // 校验"叶子转父":新父 8002 当前是叶子,进度=0无工时 → 通过
when(projectTaskMapper.countChildrenByParentTaskId(newParentId)).thenReturn(0); when(projectTaskMapper.countChildrenByParentTaskId(newParentId)).thenReturn(0);
when(taskWorklogMapper.existsByTaskId(newParentId)).thenReturn(false); when(taskWorklogMapper.existsByTaskId(newParentId)).thenReturn(false);
@@ -592,7 +542,6 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
private ProjectTaskSaveReqVO createTaskReqVO() { private ProjectTaskSaveReqVO createTaskReqVO() {
ProjectTaskSaveReqVO reqVO = new ProjectTaskSaveReqVO(); ProjectTaskSaveReqVO reqVO = new ProjectTaskSaveReqVO();
reqVO.setTaskTitle("接口联调任务"); reqVO.setTaskTitle("接口联调任务");
reqVO.setProgressRate(BigDecimal.ZERO);
reqVO.setTaskDesc("完成接口联调"); reqVO.setTaskDesc("完成接口联调");
return reqVO; return reqVO;
} }
@@ -626,8 +575,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
return task; return task;
} }
private ExecutionMemberDO createExecutionMember(Long executionId, Long userId) { private ExecutionAssigneeDO createExecutionAssignee(Long executionId, Long userId) {
ExecutionMemberDO member = new ExecutionMemberDO(); ExecutionAssigneeDO member = new ExecutionAssigneeDO();
member.setExecutionId(executionId); member.setExecutionId(executionId);
member.setUserId(userId); member.setUserId(userId);
return member; return member;

View File

@@ -1,6 +1,7 @@
package com.njcn.rdms.module.project.service.project.task; package com.njcn.rdms.module.project.service.project.task;
import com.njcn.rdms.framework.common.exception.ServiceException; 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.framework.test.core.ut.BaseMockitoUnitTest;
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.dataobject.status.ObjectStatusTransitionDO; import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
@@ -10,6 +11,7 @@ import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
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;
import org.mockito.MockedStatic;
import java.util.List; import java.util.List;
@@ -17,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; 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.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest { class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
@@ -29,29 +32,73 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
private ObjectStatusTransitionMapper objectStatusTransitionMapper; private ObjectStatusTransitionMapper objectStatusTransitionMapper;
@Test @Test
void getLifecycle_shouldReturnStatusModelAndAvailableActions() { void getLifecycle_whenLoginUserIsOwner_shouldReturnOwnerOnlyActions() {
ObjectStatusModelDO statusModel = new ObjectStatusModelDO(); Long ownerId = 3001L;
statusModel.setObjectType("task"); ObjectStatusModelDO statusModel = createStatusModel();
statusModel.setStatusCode("active");
statusModel.setStatusName("进行中");
statusModel.setTerminalFlag(false);
statusModel.setAllowEdit(true);
ObjectStatusTransitionDO complete = createTransition("complete", "完成"); ObjectStatusTransitionDO complete = createTransition("complete", "完成");
ObjectStatusTransitionDO block = createTransition("block", "阻塞"); ObjectStatusTransitionDO pause = createTransition("pause", "暂停");
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "active")).thenReturn(statusModel); when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "active")).thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("task", "active")) when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("task", "active"))
.thenReturn(List.of(complete, block)); .thenReturn(List.of(complete, pause));
ProjectTaskStatusViewService.ProjectTaskLifecycleView view = try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
projectTaskStatusViewService.getLifecycle("active"); mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
assertEquals("进行中", view.statusName()); ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
assertFalse(view.terminal()); projectTaskStatusViewService.getLifecycle("active", ownerId);
assertTrue(view.allowEdit());
assertEquals(2, view.availableActions().size()); assertEquals("进行中", view.statusName());
assertEquals("complete", view.availableActions().get(0).getActionCode()); assertFalse(view.terminal());
assertEquals("完成", view.availableActions().get(0).getActionName()); assertTrue(view.allowEdit());
assertEquals(2, view.availableActions().size());
assertEquals("complete", view.availableActions().get(0).getActionCode());
assertEquals("完成", view.availableActions().get(0).getActionName());
}
}
@Test
void getLifecycle_whenLoginUserNotOwner_shouldFilterOwnerOnlyActions() {
Long ownerId = 3001L;
Long otherUserId = 3002L;
ObjectStatusModelDO statusModel = createStatusModel();
ObjectStatusTransitionDO complete = createTransition("complete", "完成");
ObjectStatusTransitionDO pause = createTransition("pause", "暂停");
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "active")).thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("task", "active"))
.thenReturn(List.of(complete, pause));
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(otherUserId);
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
projectTaskStatusViewService.getLifecycle("active", ownerId);
assertTrue(view.availableActions().isEmpty());
}
}
@Test
void getLifecycle_shouldExcludeAutoStartAction() {
Long ownerId = 3001L;
ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
statusModel.setObjectType("task");
statusModel.setStatusCode("pending");
statusModel.setStatusName("待开始");
statusModel.setTerminalFlag(false);
statusModel.setAllowEdit(true);
ObjectStatusTransitionDO autoStart = createTransition("auto_start", "开始推进");
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "pending")).thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("task", "pending"))
.thenReturn(List.of(autoStart));
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
projectTaskStatusViewService.getLifecycle("pending", ownerId);
assertTrue(view.availableActions().isEmpty());
}
} }
@Test @Test
@@ -59,11 +106,21 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "missing")).thenReturn(null); when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "missing")).thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class, ServiceException ex = assertThrows(ServiceException.class,
() -> projectTaskStatusViewService.getLifecycle("missing")); () -> projectTaskStatusViewService.getLifecycle("missing", 3001L));
assertEquals(ErrorCodeConstants.PROJECT_TASK_STATUS_MODEL_NOT_EXISTS_OR_DISABLED.getCode(), ex.getCode()); assertEquals(ErrorCodeConstants.PROJECT_TASK_STATUS_MODEL_NOT_EXISTS_OR_DISABLED.getCode(), ex.getCode());
} }
private ObjectStatusModelDO createStatusModel() {
ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
statusModel.setObjectType("task");
statusModel.setStatusCode("active");
statusModel.setStatusName("进行中");
statusModel.setTerminalFlag(false);
statusModel.setAllowEdit(true);
return statusModel;
}
private ObjectStatusTransitionDO createTransition(String actionCode, String actionName) { private ObjectStatusTransitionDO createTransition(String actionCode, String actionName) {
ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO(); ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
transition.setActionCode(actionCode); transition.setActionCode(actionCode);

View File

@@ -10,7 +10,7 @@ import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.Ta
import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeSaveReqVO; import com.njcn.rdms.module.project.controller.admin.project.task.vo.assignee.TaskAssigneeSaveReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO; import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO; import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberDO; import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
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.dal.dataobject.project.task.ProjectTaskDO; import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO; import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeDO;
@@ -18,7 +18,7 @@ import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskAssigneeLogD
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.audit.BizAuditLogMapper; import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
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.execution.ExecutionMemberMapper; 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.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.project.task.TaskAssigneeLogMapper; import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeLogMapper;
@@ -60,7 +60,7 @@ class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest {
@Mock @Mock
private ProjectTaskMapper projectTaskMapper; private ProjectTaskMapper projectTaskMapper;
@Mock @Mock
private ExecutionMemberMapper executionMemberMapper; private ExecutionAssigneeMapper executionAssigneeMapper;
@Mock @Mock
private TaskAssigneeMapper taskAssigneeMapper; private TaskAssigneeMapper taskAssigneeMapper;
@Mock @Mock
@@ -88,8 +88,8 @@ class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest {
stubEditableContext(); stubEditableContext();
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID)) when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
.thenReturn(createTask(OWNER_ID)); .thenReturn(createTask(OWNER_ID));
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)) when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
.thenReturn(createExecutionMember(EXECUTION_ID, USER_ID)); .thenReturn(createExecutionAssignee(EXECUTION_ID, USER_ID));
when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, USER_ID)).thenReturn(null); when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, USER_ID)).thenReturn(null);
when(taskAssigneeMapper.insert(any(TaskAssigneeDO.class))).thenAnswer(invocation -> { when(taskAssigneeMapper.insert(any(TaskAssigneeDO.class))).thenAnswer(invocation -> {
TaskAssigneeDO assignee = invocation.getArgument(0); TaskAssigneeDO assignee = invocation.getArgument(0);
@@ -131,14 +131,14 @@ class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest {
} }
@Test @Test
void createAssignee_whenUserNotActiveExecutionMember_shouldThrow() { void createAssignee_whenUserNotActiveExecutionAssignee_shouldThrow() {
TaskAssigneeSaveReqVO reqVO = new TaskAssigneeSaveReqVO(); TaskAssigneeSaveReqVO reqVO = new TaskAssigneeSaveReqVO();
reqVO.setUserId(USER_ID); reqVO.setUserId(USER_ID);
stubEditableContext(); stubEditableContext();
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID)) when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
.thenReturn(createTask(OWNER_ID)); .thenReturn(createTask(OWNER_ID));
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)) when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
.thenReturn(null); .thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class, ServiceException ex = assertThrows(ServiceException.class,
@@ -155,8 +155,8 @@ class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest {
stubEditableContext(); stubEditableContext();
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID)) when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
.thenReturn(createTask(OWNER_ID)); .thenReturn(createTask(OWNER_ID));
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)) when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
.thenReturn(createExecutionMember(EXECUTION_ID, USER_ID)); .thenReturn(createExecutionAssignee(EXECUTION_ID, USER_ID));
when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, USER_ID)) when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, USER_ID))
.thenReturn(createActiveAssignee(7001L, TASK_ID, USER_ID)); .thenReturn(createActiveAssignee(7001L, TASK_ID, USER_ID));
@@ -308,10 +308,10 @@ class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest {
void initializeAssignees_shouldDedupAndWriteAll() { void initializeAssignees_shouldDedupAndWriteAll() {
Long userA = 4001L; Long userA = 4001L;
Long userB = 4002L; Long userB = 4002L;
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, userA)) when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, userA))
.thenReturn(createExecutionMember(EXECUTION_ID, userA)); .thenReturn(createExecutionAssignee(EXECUTION_ID, userA));
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, userB)) when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, userB))
.thenReturn(createExecutionMember(EXECUTION_ID, userB)); .thenReturn(createExecutionAssignee(EXECUTION_ID, userB));
when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, userA)).thenReturn(null); when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, userA)).thenReturn(null);
when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, userB)).thenReturn(null); when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, userB)).thenReturn(null);
when(taskAssigneeMapper.insert(any(TaskAssigneeDO.class))).thenReturn(1); when(taskAssigneeMapper.insert(any(TaskAssigneeDO.class))).thenReturn(1);
@@ -370,8 +370,8 @@ class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest {
return task; return task;
} }
private ExecutionMemberDO createExecutionMember(Long executionId, Long userId) { private ExecutionAssigneeDO createExecutionAssignee(Long executionId, Long userId) {
ExecutionMemberDO member = new ExecutionMemberDO(); ExecutionAssigneeDO member = new ExecutionAssigneeDO();
member.setExecutionId(executionId); member.setExecutionId(executionId);
member.setUserId(userId); member.setUserId(userId);
return member; return member;

View File

@@ -1,412 +0,0 @@
package com.njcn.rdms.module.project.service.project.task.worklog;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.worklog.TaskWorklogSaveReqVO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
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.TaskAssigneeDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskWorklogDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
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.project.task.TaskAssigneeMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TaskWorklogServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private TaskWorklogServiceImpl taskWorklogService;
@Mock
private ProjectMapper projectMapper;
@Mock
private ProjectExecutionMapper projectExecutionMapper;
@Mock
private ProjectTaskMapper projectTaskMapper;
@Mock
private TaskAssigneeMapper taskAssigneeMapper;
@Mock
private TaskWorklogMapper taskWorklogMapper;
@Mock
private ObjectStatusModelMapper objectStatusModelMapper;
@Mock
private AdminUserApi adminUserApi;
private static final Long PROJECT_ID = 2001L;
private static final Long EXECUTION_ID = 5001L;
private static final Long TASK_ID = 9001L;
private static final Long OWNER_ID = 3001L;
private static final Long ASSIGNEE_USER_ID = 3002L;
private static final Long OUTSIDER_USER_ID = 3003L;
// -------------------- createWorklog --------------------
@Test
void createWorklog_byOwner_shouldInsertWithLoginUserId() {
TaskWorklogSaveReqVO reqVO = saveReq(LocalDate.of(2026, 5, 8), 150, "完成接口联调");
stubEditableContext();
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
.thenReturn(createTask(OWNER_ID));
when(projectTaskMapper.selectCount(any())).thenReturn(0L);
when(taskWorklogMapper.insert(any(TaskWorklogDO.class))).thenAnswer(inv -> {
TaskWorklogDO worklog = inv.getArgument(0);
worklog.setId(11001L);
return 1;
});
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(OWNER_ID)) {
Long worklogId = taskWorklogService.createWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO);
assertEquals(11001L, worklogId);
}
ArgumentCaptor<TaskWorklogDO> captor = ArgumentCaptor.forClass(TaskWorklogDO.class);
verify(taskWorklogMapper).insert(captor.capture());
assertEquals(TASK_ID, captor.getValue().getTaskId());
assertEquals(OWNER_ID, captor.getValue().getUserId());
assertEquals(150, captor.getValue().getDurationMinutes());
assertEquals(LocalDate.of(2026, 5, 8), captor.getValue().getWorkDate());
assertEquals("完成接口联调", captor.getValue().getWorkContent());
}
@Test
void createWorklog_byActiveAssignee_shouldInsert() {
TaskWorklogSaveReqVO reqVO = saveReq(LocalDate.of(2026, 5, 8), 60, null);
stubEditableContext();
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
.thenReturn(createTask(OWNER_ID));
when(projectTaskMapper.selectCount(any())).thenReturn(0L);
when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, ASSIGNEE_USER_ID))
.thenReturn(activeAssignee(ASSIGNEE_USER_ID));
when(taskWorklogMapper.insert(any(TaskWorklogDO.class))).thenAnswer(inv -> {
TaskWorklogDO worklog = inv.getArgument(0);
worklog.setId(11002L);
return 1;
});
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(ASSIGNEE_USER_ID)) {
Long id = taskWorklogService.createWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO);
assertEquals(11002L, id);
}
ArgumentCaptor<TaskWorklogDO> captor = ArgumentCaptor.forClass(TaskWorklogDO.class);
verify(taskWorklogMapper).insert(captor.capture());
assertEquals(ASSIGNEE_USER_ID, captor.getValue().getUserId());
assertNull(captor.getValue().getWorkContent());
}
@Test
void createWorklog_whenNotLeaf_shouldThrowNotLeafTask() {
TaskWorklogSaveReqVO reqVO = saveReq(LocalDate.of(2026, 5, 8), 60, "x");
stubEditableContext();
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
.thenReturn(createTask(OWNER_ID));
when(projectTaskMapper.selectCount(any())).thenReturn(2L);
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(OWNER_ID)) {
ServiceException ex = assertThrows(ServiceException.class,
() -> taskWorklogService.createWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_LEAF_TASK.getCode(), ex.getCode());
}
verify(taskWorklogMapper, never()).insert(any(TaskWorklogDO.class));
}
@Test
void createWorklog_byOutsider_shouldThrowNotOwnerOrAssignee() {
TaskWorklogSaveReqVO reqVO = saveReq(LocalDate.of(2026, 5, 8), 60, "x");
stubEditableContext();
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
.thenReturn(createTask(OWNER_ID));
when(projectTaskMapper.selectCount(any())).thenReturn(0L);
when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, OUTSIDER_USER_ID)).thenReturn(null);
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(OUTSIDER_USER_ID)) {
ServiceException ex = assertThrows(ServiceException.class,
() -> taskWorklogService.createWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_OWNER_OR_ASSIGNEE.getCode(), ex.getCode());
}
verify(taskWorklogMapper, never()).insert(any(TaskWorklogDO.class));
}
@Test
void createWorklog_whenDurationNotMultipleOf30_shouldThrow() {
TaskWorklogSaveReqVO reqVO = saveReq(LocalDate.of(2026, 5, 8), 45, "x");
stubEditableContext();
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
.thenReturn(createTask(OWNER_ID));
when(projectTaskMapper.selectCount(any())).thenReturn(0L);
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(OWNER_ID)) {
ServiceException ex = assertThrows(ServiceException.class,
() -> taskWorklogService.createWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DURATION_INVALID.getCode(), ex.getCode());
}
verify(taskWorklogMapper, never()).insert(any(TaskWorklogDO.class));
}
// -------------------- updateWorklog --------------------
@Test
void updateWorklog_byOriginalFiler_shouldPatchFields() {
TaskWorklogSaveReqVO reqVO = saveReq(LocalDate.of(2026, 5, 9), 90, "改后的内容");
stubEditableContext();
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
.thenReturn(createTask(OWNER_ID));
when(taskWorklogMapper.selectByIdAndTaskId(11001L, TASK_ID))
.thenReturn(createWorklog(11001L, ASSIGNEE_USER_ID, 60));
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(ASSIGNEE_USER_ID)) {
taskWorklogService.updateWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, 11001L, reqVO);
}
ArgumentCaptor<TaskWorklogDO> captor = ArgumentCaptor.forClass(TaskWorklogDO.class);
verify(taskWorklogMapper).updateById(captor.capture());
assertEquals(11001L, captor.getValue().getId());
assertEquals(LocalDate.of(2026, 5, 9), captor.getValue().getWorkDate());
assertEquals(90, captor.getValue().getDurationMinutes());
assertEquals("改后的内容", captor.getValue().getWorkContent());
}
@Test
void updateWorklog_byNonFiler_shouldThrowEditNotOwn() {
TaskWorklogSaveReqVO reqVO = saveReq(LocalDate.of(2026, 5, 9), 60, "x");
stubEditableContext();
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
.thenReturn(createTask(OWNER_ID));
when(taskWorklogMapper.selectByIdAndTaskId(11001L, TASK_ID))
.thenReturn(createWorklog(11001L, ASSIGNEE_USER_ID, 60));
// Owner 也不允许改协办人的工时
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(OWNER_ID)) {
ServiceException ex = assertThrows(ServiceException.class,
() -> taskWorklogService.updateWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, 11001L, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_TASK_WORKLOG_EDIT_NOT_OWN.getCode(), ex.getCode());
}
verify(taskWorklogMapper, never()).updateById(any(TaskWorklogDO.class));
}
@Test
void updateWorklog_whenWorklogMissing_shouldThrowNotExists() {
TaskWorklogSaveReqVO reqVO = saveReq(LocalDate.of(2026, 5, 9), 60, "x");
stubEditableContext();
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
.thenReturn(createTask(OWNER_ID));
when(taskWorklogMapper.selectByIdAndTaskId(99999L, TASK_ID)).thenReturn(null);
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(OWNER_ID)) {
ServiceException ex = assertThrows(ServiceException.class,
() -> taskWorklogService.updateWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, 99999L, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_EXISTS.getCode(), ex.getCode());
}
}
// -------------------- deleteWorklog --------------------
@Test
void deleteWorklog_byFiler_shouldDelete() {
stubEditableContext();
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
.thenReturn(createTask(OWNER_ID));
when(taskWorklogMapper.selectByIdAndTaskId(11001L, TASK_ID))
.thenReturn(createWorklog(11001L, ASSIGNEE_USER_ID, 60));
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(ASSIGNEE_USER_ID)) {
taskWorklogService.deleteWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, 11001L);
}
verify(taskWorklogMapper).deleteById(11001L);
}
@Test
void deleteWorklog_byOwner_shouldDeleteOthers() {
// 任务负责人删除协办人的工时记录(处理离职/误填)
stubEditableContext();
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
.thenReturn(createTask(OWNER_ID));
when(taskWorklogMapper.selectByIdAndTaskId(11001L, TASK_ID))
.thenReturn(createWorklog(11001L, ASSIGNEE_USER_ID, 60));
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(OWNER_ID)) {
taskWorklogService.deleteWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, 11001L);
}
verify(taskWorklogMapper).deleteById(11001L);
}
@Test
void deleteWorklog_byOutsider_shouldThrowDeleteForbidden() {
stubEditableContext();
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
.thenReturn(createTask(OWNER_ID));
when(taskWorklogMapper.selectByIdAndTaskId(11001L, TASK_ID))
.thenReturn(createWorklog(11001L, ASSIGNEE_USER_ID, 60));
try (MockedStatic<SecurityFrameworkUtils> mocked = mockLoginUser(OUTSIDER_USER_ID)) {
ServiceException ex = assertThrows(ServiceException.class,
() -> taskWorklogService.deleteWorklog(PROJECT_ID, EXECUTION_ID, TASK_ID, 11001L));
assertEquals(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DELETE_FORBIDDEN.getCode(), ex.getCode());
}
verify(taskWorklogMapper, never()).deleteById(any(Long.class));
}
// -------------------- pagination & summary --------------------
@Test
void getWorklogPage_shouldAttachUserNickname() {
TaskWorklogPageReqVO reqVO = new TaskWorklogPageReqVO();
when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID))
.thenReturn(createExecution());
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
.thenReturn(createTask(OWNER_ID));
TaskWorklogDO row = createWorklog(11001L, ASSIGNEE_USER_ID, 90);
when(taskWorklogMapper.selectPageByTaskId(eq(TASK_ID), any()))
.thenReturn(new PageResult<>(List.of(row), 1L));
AdminUserRespDTO user = new AdminUserRespDTO();
user.setId(ASSIGNEE_USER_ID);
user.setNickname("张三");
when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(ASSIGNEE_USER_ID, user));
PageResult<TaskWorklogRespVO> page = taskWorklogService
.getWorklogPage(PROJECT_ID, EXECUTION_ID, TASK_ID, reqVO);
assertEquals(1L, page.getTotal());
assertEquals(1, page.getList().size());
assertEquals("张三", page.getList().get(0).getUserNickname());
assertEquals(90, page.getList().get(0).getDurationMinutes());
}
@Test
void sumDurationGroupedByTaskIds_shouldFoldRowsToMap() {
when(taskWorklogMapper.sumDurationGroupByTaskIds(anyCollection())).thenReturn(List.of(
Map.of("taskId", 9001L, "total", 150L),
Map.of("taskId", 9002L, "total", 30L)
));
Map<Long, Long> result = taskWorklogService.sumDurationGroupedByTaskIds(List.of(9001L, 9002L));
assertEquals(150L, result.get(9001L));
assertEquals(30L, result.get(9002L));
}
@Test
void sumDurationGroupedByTaskIds_whenEmpty_shouldReturnEmptyMap() {
Map<Long, Long> result = taskWorklogService.sumDurationGroupedByTaskIds(Collections.emptyList());
assertEquals(Collections.emptyMap(), result);
verify(taskWorklogMapper, never()).sumDurationGroupByTaskIds(anyCollection());
}
@Test
void sumDurationByTaskId_defaultMethod_shouldFallbackToZero() {
when(taskWorklogMapper.sumDurationGroupByTaskIds(anyCollection())).thenReturn(Collections.emptyList());
assertEquals(0L, taskWorklogService.sumDurationByTaskId(9001L));
verify(taskWorklogMapper, times(1)).sumDurationGroupByTaskIds(anyCollection());
}
// -------------------- helpers --------------------
private void stubEditableContext() {
when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject());
when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID))
.thenReturn(createExecution());
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
.thenReturn(createStatus("project", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createStatus("execution", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "pending"))
.thenReturn(createStatus("task", "pending", true));
}
private MockedStatic<SecurityFrameworkUtils> mockLoginUser(Long loginUserId) {
MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class);
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
return mocked;
}
private TaskWorklogSaveReqVO saveReq(LocalDate workDate, Integer durationMinutes, String workContent) {
TaskWorklogSaveReqVO req = new TaskWorklogSaveReqVO();
req.setWorkDate(workDate);
req.setDurationMinutes(durationMinutes);
req.setWorkContent(workContent);
return req;
}
private ProjectDO createEditableProject() {
ProjectDO project = new ProjectDO();
project.setId(PROJECT_ID);
project.setStatusCode("pending");
return project;
}
private ProjectExecutionDO createExecution() {
ProjectExecutionDO execution = new ProjectExecutionDO();
execution.setId(EXECUTION_ID);
execution.setProjectId(PROJECT_ID);
execution.setStatusCode("pending");
return execution;
}
private ProjectTaskDO createTask(Long ownerId) {
ProjectTaskDO task = new ProjectTaskDO();
task.setId(TASK_ID);
task.setProjectId(PROJECT_ID);
task.setExecutionId(EXECUTION_ID);
task.setOwnerId(ownerId);
task.setStatusCode("pending");
return task;
}
private TaskWorklogDO createWorklog(Long id, Long userId, Integer durationMinutes) {
TaskWorklogDO worklog = new TaskWorklogDO();
worklog.setId(id);
worklog.setTaskId(TASK_ID);
worklog.setUserId(userId);
worklog.setWorkDate(LocalDate.of(2026, 5, 8));
worklog.setDurationMinutes(durationMinutes);
return worklog;
}
private TaskAssigneeDO activeAssignee(Long userId) {
TaskAssigneeDO assignee = new TaskAssigneeDO();
assignee.setId(7001L);
assignee.setTaskId(TASK_ID);
assignee.setUserId(userId);
return assignee;
}
private ObjectStatusModelDO createStatus(String objectType, String statusCode, boolean allowEdit) {
ObjectStatusModelDO status = new ObjectStatusModelDO();
status.setObjectType(objectType);
status.setStatusCode(statusCode);
status.setAllowEdit(allowEdit);
return status;
}
}

View File

@@ -0,0 +1,98 @@
# 文件上传接口改造需求(给后端)
> 提单时间2026-05-11
> 提单方:前端
> 涉及模块:`rdms-system-boot` 文件模块
> 背景:业务表单(如新建/编辑任务)的附件采用「即传即存」交互,但表单可能被用户取消或关闭。前端需要在合适时机调用删除接口清理孤儿文件。当前 `/system/file/upload` 只返回 url前端拿不到 fileId无法触发删除。
---
## 1. 改造:`POST /system/file/upload`
**路径 / 方法 / 入参全部保持不变**,仅修改返回结构。
### 1.1 现状返回
```json
{
"code": 0,
"msg": "",
"data": "http://192.168.1.107:9009/rdms/20260508/xxx.jpg?X-Amz-..."
}
```
### 1.2 目标返回
```json
{
"code": 0,
"msg": "",
"data": {
"id": "10001",
"url": "http://192.168.1.107:9009/rdms/20260508/xxx.jpg?X-Amz-..."
}
}
```
### 1.3 字段说明
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | string | 是 | 即 `infra_file.id`**必须以字符串形式返回**,不要返回 number |
| url | string | 是 | 文件访问 URL含义与现状完全一致私有桶带签名公开桶裸 URL |
### 1.4 关键约束
- **`id` 必须是 string不是 number**。原因:`infra_file.id` 是 Long 类型,超过 JS 安全整数2^53时 JSON number 会精度丢失。前端项目 ID 统一按字符串接收。
- 该改动**不兼容老返回结构**,前端会同步切换。
- App 端 `/app-api/system/file/upload` 前端目前未使用,可暂不改;若希望对称建议一起改。
---
## 2. 确认:`DELETE /system/file/delete`
接口已存在(见 `system-file-api.md` §4.3.5),无需新建,但需确认以下两点:
### 2.1 权限
- 业务用户角色需具备 `system:file:delete` 权限码,**或**该接口允许"删除自己上传的文件"无需该权限码。
- 期望:当前业务用户能直接调通 `DELETE /admin-api/system/file/delete?id=xxx`
### 2.2 幂等性(删不存在的文件)
- 当前文档里删不存在的文件返回错误码 `1001003001 / 文件不存在`
- 期望:**幂等返回 `code: 0`**(更佳),或维持现状(前端会把该错误码当作"已删除"吞掉,也能接受)。
### 2.3 入参
保持现状不变:
```
DELETE /admin-api/system/file/delete?id=10001
```
---
## 3. 不在本次范围(避免误解)
以下事项**本次不做**,属于后续「治本方案」范畴,请勿顺手改动:
- 不需要在 `infra_file` 表加 `status`temp / committed字段
- 不需要加业务引用关系表
- 不需要做孤儿文件定时清理 cron
- 不需要改 `/presigned-url``/create`
- 不需要改 App 端 `/upload` 鉴权策略
---
## 4. 联调要点
1. 改造完成后请提供测试环境,前端会同步发版。
2. 接口返回的 `id` 请确保转为字符串再 JSON 序列化(不要直接序列化 Long
3. 若返回里 `id` 是 number 类型,前端视为联调未通过。
---
## 5. 一句话总结
> `/system/file/upload` 返回结构从 `string` 改成 `{ id: string, url: string }``id` 是 `infra_file.id` 的字符串形式。其它接口不动。

View File

@@ -0,0 +1,24 @@
package com.njcn.rdms.module.system.api.file;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.system.api.file.dto.FileRespDTO;
import com.njcn.rdms.module.system.enums.ApiConstants;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = ApiConstants.NAME)
@Tag(name = "RPC 服务 - 文件")
public interface FileApi {
String PREFIX = ApiConstants.PREFIX + "/file";
@GetMapping(PREFIX + "/get-by-url")
@Operation(summary = "通过文件 URL 查询文件")
@Parameter(name = "url", description = "文件 URL", required = true)
CommonResult<FileRespDTO> getFileByUrl(@RequestParam("url") String url);
}

View File

@@ -0,0 +1,38 @@
package com.njcn.rdms.module.system.api.file.dto;
import lombok.Data;
@Data
public class FileRespDTO {
/**
* 文件编号。
*/
private Long id;
/**
* 文件名称。
*/
private String name;
/**
* 文件路径。
*/
private String path;
/**
* 文件 URL。
*/
private String url;
/**
* 文件类型。
*/
private String type;
/**
* 文件大小。
*/
private Long size;
}

View File

@@ -0,0 +1,29 @@
package com.njcn.rdms.module.system.api.file;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.module.system.api.file.dto.FileRespDTO;
import com.njcn.rdms.module.system.dal.dataobject.file.FileDO;
import com.njcn.rdms.module.system.service.file.FileService;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.annotation.Resource;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RestController;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
@RestController
@Validated
@Hidden
public class FileApiImpl implements FileApi {
@Resource
private FileService fileService;
@Override
public CommonResult<FileRespDTO> getFileByUrl(String url) {
FileDO file = fileService.getFileByUrl(url);
return success(BeanUtils.toBean(file, FileRespDTO.class));
}
}

View File

@@ -21,6 +21,12 @@ public class AuthUserInfoRespVO {
@Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin") @Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin")
private String userName; private String userName;
@Schema(description = "用户昵称", example = "灿能")
private String nickname;
@Schema(description = "用户头像", example = "https://www.iocoder.cn/xx.png")
private String avatar;
@Schema(description = "所属公司", example = "灿能") @Schema(description = "所属公司", example = "灿能")
private String company; private String company;

View File

@@ -46,11 +46,12 @@ public class FileController {
@Operation(summary = "上传文件", description = "模式一:后端上传文件") @Operation(summary = "上传文件", description = "模式一:后端上传文件")
@Parameter(name = "file", description = "文件附件", required = true, @Parameter(name = "file", description = "文件附件", required = true,
schema = @Schema(type = "string", format = "binary")) schema = @Schema(type = "string", format = "binary"))
public CommonResult<String> uploadFile(@Valid FileUploadReqVO uploadReqVO) throws Exception { public CommonResult<FileUploadRespVO> uploadFile(@Valid FileUploadReqVO uploadReqVO) throws Exception {
MultipartFile file = uploadReqVO.getFile(); MultipartFile file = uploadReqVO.getFile();
byte[] content = IoUtil.readBytes(file.getInputStream()); byte[] content = IoUtil.readBytes(file.getInputStream());
return success(fileService.createFile(content, file.getOriginalFilename(), FileDO fileDO = fileService.createFile(content, file.getOriginalFilename(),
uploadReqVO.getDirectory(), file.getContentType())); uploadReqVO.getDirectory(), file.getContentType());
return success(new FileUploadRespVO(String.valueOf(fileDO.getId()), fileDO.getUrl()));
} }
@GetMapping("/presigned-url") @GetMapping("/presigned-url")
@@ -97,6 +98,21 @@ public class FileController {
return success(true); return success(true);
} }
@GetMapping("/download")
@Operation(summary = "下载文件")
@Parameter(name = "id", description = "编号", required = true)
@PreAuthorize("@ss.hasPermission('system:file:query')")
public void downloadFile(@RequestParam("id") Long id, HttpServletResponse response) throws Exception {
FileDO file = fileService.getFile(id);
byte[] content = fileService.getFileContent(file.getConfigId(), file.getPath());
if (content == null) {
log.warn("[downloadFile][id({}) configId({}) path({}) 文件不存在]", id, file.getConfigId(), file.getPath());
response.setStatus(HttpStatus.NOT_FOUND.value());
return;
}
writeAttachment(response, StrUtil.blankToDefault(file.getName(), file.getPath()), content);
}
@GetMapping("/{configId}/get/**") @GetMapping("/{configId}/get/**")
@PermitAll @PermitAll

View File

@@ -0,0 +1,20 @@
package com.njcn.rdms.module.system.controller.admin.file.vo.file;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Schema(description = "管理后台 - 上传文件 Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class FileUploadRespVO {
@Schema(description = "文件编号Long 以字符串返回,避免前端精度丢失", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private String id;
@Schema(description = "文件 URL", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.iocoder.cn/rdms.jpg")
private String url;
}

View File

@@ -4,6 +4,7 @@ import cn.hutool.core.io.IoUtil;
import com.njcn.rdms.framework.common.pojo.CommonResult; import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.system.controller.admin.file.vo.file.FileCreateReqVO; import com.njcn.rdms.module.system.controller.admin.file.vo.file.FileCreateReqVO;
import com.njcn.rdms.module.system.controller.admin.file.vo.file.FilePresignedUrlRespVO; import com.njcn.rdms.module.system.controller.admin.file.vo.file.FilePresignedUrlRespVO;
import com.njcn.rdms.module.system.dal.dataobject.file.FileDO;
import com.njcn.rdms.module.system.controller.app.file.vo.AppFileUploadReqVO; import com.njcn.rdms.module.system.controller.app.file.vo.AppFileUploadReqVO;
import com.njcn.rdms.module.system.service.file.FileService; import com.njcn.rdms.module.system.service.file.FileService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -39,8 +40,9 @@ public class AppFileController {
public CommonResult<String> uploadFile(AppFileUploadReqVO uploadReqVO) throws Exception { public CommonResult<String> uploadFile(AppFileUploadReqVO uploadReqVO) throws Exception {
MultipartFile file = uploadReqVO.getFile(); MultipartFile file = uploadReqVO.getFile();
byte[] content = IoUtil.readBytes(file.getInputStream()); byte[] content = IoUtil.readBytes(file.getInputStream());
return success(fileService.createFile(content, file.getOriginalFilename(), FileDO fileDO = fileService.createFile(content, file.getOriginalFilename(),
uploadReqVO.getDirectory(), file.getContentType())); uploadReqVO.getDirectory(), file.getContentType());
return success(fileDO.getUrl());
} }
@GetMapping("/presigned-url") @GetMapping("/presigned-url")

View File

@@ -57,6 +57,8 @@ public interface AuthConvert {
return AuthUserInfoRespVO.builder() return AuthUserInfoRespVO.builder()
.userId(String.valueOf(user.getId())) .userId(String.valueOf(user.getId()))
.userName(user.getUsername()) .userName(user.getUsername())
.nickname(user.getNickname())
.avatar(user.getAvatar())
.company(user.getCompany()) .company(user.getCompany())
.roles(sortDistinctStrings(convertList(roleList, RoleDO::getCode))) .roles(sortDistinctStrings(convertList(roleList, RoleDO::getCode)))
.buttons(sortDistinctStrings(convertList(menuList, MenuDO::getPermission, .buttons(sortDistinctStrings(convertList(menuList, MenuDO::getPermission,

View File

@@ -7,6 +7,10 @@ import com.njcn.rdms.module.system.controller.admin.file.vo.file.FilePageReqVO;
import com.njcn.rdms.module.system.dal.dataobject.file.FileDO; import com.njcn.rdms.module.system.dal.dataobject.file.FileDO;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import java.util.Objects;
import static com.njcn.rdms.framework.common.util.http.HttpUtils.removeUrlQuery;
/** /**
* 文件操作 Mapper * 文件操作 Mapper
* *
@@ -23,4 +27,18 @@ public interface FileMapper extends BaseMapperX<FileDO> {
.orderByDesc(FileDO::getId)); .orderByDesc(FileDO::getId));
} }
default FileDO selectByUrl(String url) {
FileDO file = selectFirstOne(FileDO::getUrl, url);
String urlWithoutQuery = removeUrlQuery(url);
if (file == null && !Objects.equals(urlWithoutQuery, url)) {
file = selectOne(new LambdaQueryWrapperX<FileDO>()
.likeRight(FileDO::getUrl, urlWithoutQuery)
.last("LIMIT 1"));
}
if (file == null && !Objects.equals(urlWithoutQuery, url)) {
file = selectFirstOne(FileDO::getUrl, urlWithoutQuery);
}
return file;
}
} }

View File

@@ -25,15 +25,15 @@ public interface FileService {
PageResult<FileDO> getFilePage(FilePageReqVO pageReqVO); PageResult<FileDO> getFilePage(FilePageReqVO pageReqVO);
/** /**
* 保存文件,并返回文件的访问路径 * 保存文件,并返回文件记录
* *
* @param content 文件内容 * @param content 文件内容
* @param name 文件名称,允许空 * @param name 文件名称,允许空
* @param directory 目录,允许空 * @param directory 目录,允许空
* @param type 文件的 MIME 类型,允许空 * @param type 文件的 MIME 类型,允许空
* @return 文件路径 * @return 文件记录
*/ */
String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content, FileDO createFile(@NotEmpty(message = "文件内容不能为空") byte[] content,
String name, String directory, String type); String name, String directory, String type);
/** /**
@@ -63,6 +63,14 @@ public interface FileService {
Long createFile(FileCreateReqVO createReqVO); Long createFile(FileCreateReqVO createReqVO);
FileDO getFile(Long id); FileDO getFile(Long id);
/**
* 通过文件 URL 获得文件。
*
* @param url 文件 URL
* @return 文件记录,不存在返回 null
*/
FileDO getFileByUrl(String url);
/** /**
* 删除文件 * 删除文件
* *

View File

@@ -61,7 +61,7 @@ public class FileServiceImpl implements FileService {
@Override @Override
@SneakyThrows @SneakyThrows
public String createFile(byte[] content, String name, String directory, String type) { public FileDO createFile(byte[] content, String name, String directory, String type) {
// 1.1 处理 type 为空的情况 // 1.1 处理 type 为空的情况
if (StrUtil.isEmpty(type)) { if (StrUtil.isEmpty(type)) {
type = FileTypeUtils.getMineType(content, name); type = FileTypeUtils.getMineType(content, name);
@@ -86,10 +86,11 @@ public class FileServiceImpl implements FileService {
String url = client.upload(content, path, type); String url = client.upload(content, path, type);
// 3. 保存到数据库 // 3. 保存到数据库
fileMapper.insert(new FileDO().setConfigId(client.getId()) FileDO file = new FileDO().setConfigId(client.getId())
.setName(name).setPath(path).setUrl(url) .setName(name).setPath(path).setUrl(url)
.setType(type).setSize((long) content.length)); .setType(type).setSize((long) content.length);
return url; fileMapper.insert(file);
return file;
} }
@VisibleForTesting @VisibleForTesting
@@ -157,6 +158,14 @@ public class FileServiceImpl implements FileService {
return validateFileExists(id); return validateFileExists(id);
} }
@Override
public FileDO getFileByUrl(String url) {
if (StrUtil.isBlank(url)) {
return null;
}
return fileMapper.selectByUrl(url);
}
@Override @Override
public void deleteFile(Long id) throws Exception { public void deleteFile(Long id) throws Exception {
// 校验存在 // 校验存在

View File

@@ -0,0 +1,46 @@
package com.njcn.rdms.module.system.controller.admin.file;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.system.dal.dataobject.file.FileDO;
import com.njcn.rdms.module.system.service.file.FileService;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.mock.web.MockHttpServletResponse;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FileControllerTest extends BaseMockitoUnitTest {
@Mock
private FileService fileService;
@InjectMocks
private FileController fileController;
@Test
void downloadFile_shouldReadContentByFileIdAndWriteAttachment() throws Exception {
Long fileId = 10001L;
FileDO file = new FileDO()
.setId(fileId)
.setConfigId(100L)
.setPath("task/20260509/report_1778307118261.txt")
.setName("report.txt");
byte[] content = "hello".getBytes(StandardCharsets.UTF_8);
when(fileService.getFile(fileId)).thenReturn(file);
when(fileService.getFileContent(file.getConfigId(), file.getPath())).thenReturn(content);
MockHttpServletResponse response = new MockHttpServletResponse();
fileController.downloadFile(fileId, response);
verify(fileService).getFile(fileId);
verify(fileService).getFileContent(100L, "task/20260509/report_1778307118261.txt");
assertEquals("hello", response.getContentAsString(StandardCharsets.UTF_8));
assertEquals("attachment;filename=report.txt", response.getHeader("Content-Disposition"));
}
}

View File

@@ -0,0 +1,30 @@
package com.njcn.rdms.module.system.dal.mysql.file;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.njcn.rdms.module.system.dal.dataobject.file.FileDO;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class FileMapperTest {
@Test
void selectByUrl_whenSignedUrl_shouldTryUnsignedPrefix() {
FileMapper mapper = mock(FileMapper.class, invocation -> invocation.callRealMethod());
String signedUrl = "http://oss.example.com/rdms/task/test.txt?X-Amz-Signature=abc";
doReturn(null).when(mapper).selectFirstOne(any(), eq(signedUrl));
doReturn(new FileDO().setId(10001L)).when(mapper).selectOne(any(Wrapper.class));
FileDO file = mapper.selectByUrl(signedUrl);
assertEquals(10001L, file.getId());
verify(mapper).selectFirstOne(any(), eq(signedUrl));
verify(mapper).selectOne(any(Wrapper.class));
}
}