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 \"-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。
- 接口级权限走 `@PreAuthorize("@ss.hasPermission(...)")`,不要绕开。
- 跨模块/跨服务必须通过 `*-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
- 新表 DO 复用现有 `BaseDO` / 审计字段风格,不要再引一套审计基类(除非该表本身明确不需要逻辑删除)。

View File

@@ -27,9 +27,8 @@ public class PageParam implements Serializable {
@Min(value = 1, message = "页码最小值为 1")
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 = "每页条数不能为空")
@Min(value = 1, message = "每页条数最小值为 1")
@Max(value = 200, message = "每页条数最大值为 200")
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_STATUS_CONCURRENT_MODIFIED = new ErrorCode(1_008_001_022, "产品状态已发生变化,请刷新后重试");
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 ==========
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_MEMBER_NOT_ALLOW_DOWNGRADE = new ErrorCode(1_008_002_027, "当前项目经理不能直接调整为非经理角色,请先完成经理转交");
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 ==========
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_OWNER_INVALID = new ErrorCode(1_008_003_002, "执行负责人必须是当前项目的有效成员");
ErrorCode PROJECT_EXECUTION_MEMBER_INVALID = new ErrorCode(1_008_003_003, "执行成员必须是当前项目的有效成员");
ErrorCode PROJECT_EXECUTION_MEMBER_ALREADY_EXISTS = new ErrorCode(1_008_003_004, "该用户已是当前执行的有效成员");
ErrorCode PROJECT_EXECUTION_MEMBER_NOT_EXISTS = new ErrorCode(1_008_003_005, "执行成员不存在");
ErrorCode PROJECT_EXECUTION_MEMBER_NOT_ACTIVE = new ErrorCode(1_008_003_006, "当前执行成员已失效");
ErrorCode PROJECT_EXECUTION_ASSIGNEE_INVALID = new ErrorCode(1_008_003_003, "执行协办人必须是当前项目的有效成员");
ErrorCode PROJECT_EXECUTION_ASSIGNEE_ALREADY_EXISTS = new ErrorCode(1_008_003_004, "该用户已是当前执行的有效协办人");
ErrorCode PROJECT_EXECUTION_ASSIGNEE_NOT_EXISTS = new ErrorCode(1_008_003_005, "执行协办人不存在");
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_NOT_ALLOW_EDIT = new ErrorCode(1_008_003_008, "当前项目状态不允许维护执行");
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_NOT_ALLOW_EDIT = new ErrorCode(1_008_003_014, "当前执行状态不允许维护执行");
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 ==========
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_NOT_ALLOW_EDIT = new ErrorCode(1_008_004_008, "当前任务状态不允许维护任务");
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_WORKLOG = new ErrorCode(1_008_004_013, "拆子任务前请先删除父任务下已填的工时记录");
@@ -135,10 +152,20 @@ public interface ErrorCodeConstants {
// ========== 任务工时 1_008_006_xxx ==========
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_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_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_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 ==========
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_TASK = "create_task";
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";
@@ -57,21 +73,23 @@ public final class ObjectActivityConstants {
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_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_MEMBER_ACTION_ADD = "add_execution_member";
public static final String EXECUTION_MEMBER_ACTION_REMOVE = "remove_execution_member";
public static final String EXECUTION_ASSIGNEE_ACTION_ADD = "add_execution_assignee";
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_UPDATE = "update_task_entity";
public static final String TASK_ACTION_DELETE = "delete_task_entity";
// ========== 任务协办人事件类型B 模型 - 多行周期记录) ==========
public static final String TASK_ASSIGNEE_ACTION_JOIN = "join";
public static final String TASK_ASSIGNEE_ACTION_INACTIVE = "inactive";
// ========== 执行成员事件类型B 模型 - 多行周期记录) ==========
public static final String EXECUTION_MEMBER_LOG_ACTION_JOIN = "join";
public static final String EXECUTION_MEMBER_LOG_ACTION_INACTIVE = "inactive";
public static final String EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_IN = "owner_transfer_in";
public static final String EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_OUT = "owner_transfer_out";
// ========== 执行协办人事件类型B 模型 - 多行周期记录) ==========
public static final String EXECUTION_ASSIGNEE_LOG_ACTION_JOIN = "join";
public static final String EXECUTION_ASSIGNEE_LOG_ACTION_INACTIVE = "inactive";
public static final String EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN = "owner_transfer_in";
public static final String EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_OUT = "owner_transfer_out";
public static final List<String> STATUS_ACTION_TYPES = List.of(
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_UPDATE -> "更新";
case PRODUCT_ACTION_DELETE -> "删除";
case PROJECT_ACTION_AUTO_START -> "自动进入进行中";
case PROJECT_ACTION_AUTO_START -> "开始推进";
case PROJECT_TRIGGER_CREATE_EXECUTION -> "创建执行";
case PROJECT_TRIGGER_CREATE_TASK -> "创建任务";
case PROJECT_TRIGGER_SCHEDULE_REQUIREMENT -> "项目需求排期";
case PROJECT_TRIGGER_EXECUTION_AUTO_START -> "执行自动开始";
case EXECUTION_ACTION_CREATE -> "创建执行";
case EXECUTION_ACTION_UPDATE -> "更新执行";
case EXECUTION_ACTION_DELETE -> "删除执行";
case EXECUTION_ACTION_CHANGE_OWNER -> "变更执行负责人";
case EXECUTION_MEMBER_ACTION_ADD -> "新增执行成员";
case EXECUTION_MEMBER_ACTION_REMOVE -> "移出执行成员";
case EXECUTION_ASSIGNEE_ACTION_ADD -> "新增执行协办人";
case EXECUTION_ASSIGNEE_ACTION_REMOVE -> "移出执行协办人";
case TASK_ACTION_CREATE -> "创建任务";
case TASK_ACTION_UPDATE -> "更新任务";
case TASK_ACTION_DELETE -> "删除任务";
case TASK_ASSIGNEE_ACTION_JOIN -> "加入";
case TASK_ASSIGNEE_ACTION_INACTIVE -> "退出";
case EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_IN -> "转入负责人";
case EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_OUT -> "转出负责人";
case EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN -> "转入负责人";
case EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_OUT -> "转出负责人";
case "start" -> "开始";
case "block" -> "阻塞";
case "complete" -> "完成";

View File

@@ -1,5 +1,7 @@
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_MEMBER = "project:execution:member";
public static final String PERMISSION_ASSIGNEE = "project:execution:assignee";
/**
* 执行状态动作权限码。
*/
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(
ObjectActivityConstants.PROJECT_TRIGGER_CREATE_EXECUTION,
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;
import java.util.Set;
/**
* 任务对象常量。
*/
@@ -43,4 +45,18 @@ public final class ProjectTaskConstants {
*/
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.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.ProductCreateWithTeamReqVO;
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.ProductPageReqVO;
@@ -39,6 +40,13 @@ public class ProductController {
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")
@Operation(summary = "更新产品")
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.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.ProjectCreateWithTeamReqVO;
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.ProjectPageReqVO;
@@ -41,6 +42,13 @@ public class ProjectController {
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")
@Operation(summary = "更新项目")
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.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.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.service.project.ProjectStatusBoardService;
import com.njcn.rdms.module.project.service.project.execution.ProjectExecutionService;
@@ -34,7 +36,7 @@ public class ProjectExecutionController {
@PostMapping
@Operation(summary = "创建执行")
public CommonResult<Long> createExecution(@PathVariable("projectId") Long projectId,
@Valid @RequestBody ProjectExecutionSaveReqVO reqVO) {
@Valid @RequestBody ProjectExecutionCreateReqVO reqVO) {
return success(projectExecutionService.createExecution(projectId, reqVO));
}
@@ -42,7 +44,7 @@ public class ProjectExecutionController {
@Operation(summary = "编辑执行")
public CommonResult<Boolean> updateExecution(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@Valid @RequestBody ProjectExecutionSaveReqVO reqVO) {
@Valid @RequestBody ProjectExecutionUpdateReqVO reqVO) {
reqVO.setId(executionId);
projectExecutionService.updateExecution(projectId, reqVO);
return success(true);
@@ -87,4 +89,13 @@ public class ProjectExecutionController {
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 jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - 执行成员失效 Request VO")
@Schema(description = "管理后台 - 执行协办人失效 Request VO")
@Data
public class ExecutionMemberInactiveReqVO {
public class ExecutionAssigneeInactiveReqVO {
@Schema(description = "失效原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "阶段性退出")
@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 io.swagger.v3.oas.annotations.media.Schema;
@@ -8,16 +8,16 @@ import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - 执行成员变更历史分页 Request VO")
@Schema(description = "管理后台 - 执行协办人变更历史分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class ExecutionMemberLogPageReqVO extends PageParam {
public class ExecutionAssigneeLogPageReqVO extends PageParam {
@Schema(description = "事件类型多选;不传表示全部",
example = "[\"join\",\"inactive\",\"owner_transfer_in\",\"owner_transfer_out\"]")
private List<String> actionTypes;
@Schema(description = "成员用户编号;不传表示全部")
@Schema(description = "协办人用户编号;不传表示全部")
private Long userId;
@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 lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - 执行成员变更历史 Response VO")
@Schema(description = "管理后台 - 执行协办人变更历史 Response VO")
@Data
public class ExecutionMemberLogRespVO {
public class ExecutionAssigneeLogRespVO {
@Schema(description = "日志编号", example = "12001")
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 lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - 执行成员 Response VO")
@Schema(description = "管理后台 - 执行协办人 Response VO")
@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;
@Schema(description = "所属执行编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001")
private Long executionId;
@Schema(description = "成员用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3002")
@Schema(description = "协办人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3002")
private Long userId;
@Schema(description = "成员用户昵称")
@Schema(description = "协办人用户昵称")
private String userNickname;
@Schema(description = "加入时间", requiredMode = Schema.RequiredMode.REQUIRED)
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.util.List;
@Schema(description = "管理后台 - 执行保存 Request VO")
/**
* 创建执行 Request VO
* <p>
* ownerId必填+ assigneeUserIds创建时同步装配协办人
* 后续编辑主数据走 PUT + {@link ProjectExecutionUpdateReqVO}不含 ownerId / 协办人字段避免越权裂缝
*/
@Schema(description = "管理后台 - 执行创建 Request VO")
@Data
public class ProjectExecutionSaveReqVO {
@Schema(description = "执行编号", example = "5001")
private Long id;
public class ProjectExecutionCreateReqVO {
@Schema(description = "执行名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "后端接口联调")
@NotBlank(message = "执行名称不能为空")
@@ -44,7 +47,7 @@ public class ProjectExecutionSaveReqVO {
@Size(max = 200000, message = "执行说明长度不能超过200000个字符")
private String executionDesc;
@Schema(description = "创建执行时同步写入的成员用户编号列表;编辑执行主数据时不维护成员", example = "[3002,3003]")
private List<Long> memberUserIds;
@Schema(description = "创建执行时同步写入的协办人用户编号列表", example = "[3002,3003]")
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;
@Schema(description = "实际结束日期")
private LocalDate actualEndDate;
@Schema(description = "执行进度缓存值")
@Schema(description = "执行进度")
private BigDecimal progressRate;
@Schema(description = "执行说明")
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.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.ProjectTaskStatusBoardReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardRespVO;
@@ -83,4 +84,14 @@ public class ProjectTaskController {
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 jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -44,7 +43,6 @@ public class TaskAssigneeController {
@PostMapping("/assignees")
@Operation(summary = "加入任务协办人")
@PreAuthorize("@ss.hasPermission('project:task:assignee')")
public CommonResult<Long> createAssignee(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@PathVariable("taskId") Long taskId,
@@ -54,7 +52,6 @@ public class TaskAssigneeController {
@PostMapping("/assignees/{assigneeId}/inactive")
@Operation(summary = "退出任务协办人")
@PreAuthorize("@ss.hasPermission('project:task:assignee')")
public CommonResult<Boolean> inactiveAssignee(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@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 jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -43,7 +42,6 @@ public class TaskWorklogController {
@PostMapping("/worklogs")
@Operation(summary = "新增任务工时")
@PreAuthorize("@ss.hasPermission('project:task:worklog')")
public CommonResult<Long> createWorklog(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@PathVariable("taskId") Long taskId,
@@ -53,7 +51,6 @@ public class TaskWorklogController {
@PutMapping("/worklogs/{worklogId}")
@Operation(summary = "修改任务工时(仅自己)")
@PreAuthorize("@ss.hasPermission('project:task:worklog')")
public CommonResult<Boolean> updateWorklog(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@PathVariable("taskId") Long taskId,
@@ -65,7 +62,6 @@ public class TaskWorklogController {
@DeleteMapping("/worklogs/{worklogId}")
@Operation(summary = "删除任务工时(自己或任务负责人)")
@PreAuthorize("@ss.hasPermission('project:task:worklog')")
public CommonResult<Boolean> deleteWorklog(@PathVariable("projectId") Long projectId,
@PathVariable("executionId") Long executionId,
@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;
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -8,6 +9,8 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - 任务 Response VO")
@Data
public class ProjectTaskRespVO {
@@ -20,6 +23,12 @@ public class ProjectTaskRespVO {
private Long executionId;
@Schema(description = "父任务编号")
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 = "接口联调任务")
private String taskTitle;
@Schema(description = "任务负责人用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3002")
@@ -52,9 +61,11 @@ public class ProjectTaskRespVO {
private String lastStatusReason;
@Schema(description = "当前活跃协办人列表;详细变更历史见 assignee-logs 接口")
private List<TaskAssigneeView> assignees;
@Schema(description = "已填报工时合计(分钟);逻辑删除的工时记录不计入。无记录默认为 0",
example = "300")
private Long totalSpentMinutes;
@Schema(description = "已填报工时合计(小时0.5 颗粒);逻辑删除的工时记录不计入。无记录默认为 0",
example = "8.0")
private BigDecimal totalSpentHours;
@Schema(description = "附件列表")
private List<AttachmentItem> attachments;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;
@Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED)

View File

@@ -1,13 +1,12 @@
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 jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@@ -29,11 +28,6 @@ public class ProjectTaskSaveReqVO {
@Schema(description = "任务负责人用户编号;子任务不传时继承父任务负责人", example = "3002")
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 = "计划开始日期")
private LocalDate plannedStartDate;
@@ -49,5 +43,9 @@ public class ProjectTaskSaveReqVO {
+ "协办人通过独立接口管理,详见 /tasks/{id}/assignees")
private List<Long> assigneeUserIds;
@Schema(description = "附件列表;规则与限制详见 AttachmentValidator数量上限、扩展名白/黑名单、URL 协议)")
@Valid
private List<AttachmentItem> attachments;
}

View File

@@ -9,7 +9,7 @@ import lombok.Data;
@Data
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 = "动作编码不能为空")
@Size(max = 32, message = "动作编码长度不能超过32个字符")
private String actionCode;

View File

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

View File

@@ -1,10 +1,13 @@
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 lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - 任务工时 Response VO")
@Data
@@ -22,15 +25,24 @@ public class TaskWorklogRespVO {
@Schema(description = "填报人昵称", example = "张三")
private String userNickname;
@Schema(description = "工作日期", example = "2026-05-08")
private LocalDate workDate;
@Schema(description = "段起始日期(含),单天=与 endDate 相等", example = "2026-05-04")
private LocalDate startDate;
@Schema(description = "时长(分钟)", example = "150")
private Integer durationMinutes;
@Schema(description = "段结束日期(含),单天=与 startDate 相等", example = "2026-05-08")
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 = "工作内容描述")
private String workContent;
@Schema(description = "附件列表")
private List<AttachmentItem> attachments;
@Schema(description = "创建时间")
private LocalDateTime createTime;

View File

@@ -1,33 +1,57 @@
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 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.Size;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/**
* 任务工时新增/更新请求。同表共用updateWorklog 不接受 taskId / userId 切换,前端无需也无法传。
* 时长颗粒30 分钟整数倍)由 Service 层校验。
* 段语义startDate / endDate 必填,单天 = 二者相等;同人同任务下日期范围禁止重叠(Service 层校验
* 颗粒durationHours 必须 > 0 且为 0.5 的整数倍Service 层校验)。
*/
@Schema(description = "管理后台 - 任务工时 Save Request VO")
@Data
public class TaskWorklogSaveReqVO {
@Schema(description = "工作日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-05-08")
@NotNull(message = "工作日期不能为空")
private LocalDate workDate;
@Schema(description = "段起始日期(含),单天=与 endDate 相等",
requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-05-04")
@NotNull(message = "段起始日期不能为空")
private LocalDate startDate;
@Schema(description = "时长(分钟),> 0 且必须为 30 的整数倍",
requiredMode = Schema.RequiredMode.REQUIRED, example = "150")
@NotNull(message = "工时时长不能为空")
@Min(value = 30, message = "工时时长必须大于 0 且为 30 分钟的整数倍")
private Integer durationMinutes;
@Schema(description = "段结束日期(含),单天=与 startDate 相等",
requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-05-08")
@NotNull(message = "段结束日期不能为空")
private LocalDate endDate;
@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 = "完成接口联调与冒烟测试")
@Size(max = 2000, message = "工作内容长度不能超过 2000 个字符")
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;
/**
* 执行成员关系表
* 执行协办人关系表
*/
@TableName("rdms_execution_member")
@TableName("rdms_execution_assignee")
@Data
@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 userId;
/**

View File

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

View File

@@ -1,18 +1,22 @@
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.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/**
* 项目任务主表。
*/
@TableName("rdms_task")
@TableName(value = "rdms_task", autoResultMap = true)
@Data
@EqualsAndHashCode(callSuper = true)
public class ProjectTaskDO extends BaseDO {
@@ -74,5 +78,11 @@ public class ProjectTaskDO extends BaseDO {
* 最近一次状态动作原因
*/
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;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/**
* 任务工时记录表。仅挂在叶子任务上;同一 user × task × work_date 允许多条
* 时长按分钟存duration_minutes 必须 > 0 且为 30 的整数倍),前端展示为小时
* 任务工时记录表。仅挂在叶子任务上;按段记录start_date/end_date 必填,单天=二者相等)
* 同人同任务下日期范围禁止重叠
* 颗粒duration_hours 必须 > 0 且为 0.5 的整数倍。
*/
@TableName("rdms_task_worklog")
@TableName(value = "rdms_task_worklog", autoResultMap = true)
@Data
@EqualsAndHashCode(callSuper = true)
public class TaskWorklogDO extends BaseDO {
@@ -31,16 +38,37 @@ public class TaskWorklogDO extends BaseDO {
*/
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;
/**
* 附件列表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.ProjectExecutionPageReqVO;
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.springframework.util.StringUtils;
@@ -27,21 +28,44 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
.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>()
.eq(ProjectExecutionDO::getProjectId, projectId)
.eqIfPresent(ProjectExecutionDO::getExecutionType, reqVO.getExecutionType())
.eqIfPresent(ProjectExecutionDO::getOwnerId, reqVO.getOwnerId())
.eqIfPresent(ProjectExecutionDO::getStatusCode, reqVO.getStatusCode())
.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime())
.orderByDesc(BaseDO::getUpdateTime)
.orderByDesc(BaseDO::getCreateTime)
.orderByDesc(ProjectExecutionDO::getId);
if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectExecutionDO::getExecutionName, reqVO.getKeyword()));
}
if (!scope.seesAll()) {
queryWrapper.in(ProjectExecutionDO::getId, scope.executionIds());
}
return selectPage(reqVO, queryWrapper);
}
/**
* 查 userId 在指定项目下,作为 owner 的所有执行 ID。
* 用于 VisibilityScopeResolver 收集"我是执行负责人"的 scope 来源。
*/
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) {
LambdaQueryWrapperX<ProjectExecutionDO> queryWrapper = new LambdaQueryWrapperX<ProjectExecutionDO>()
.eq(ProjectExecutionDO::getProjectId, projectId)
@@ -52,7 +76,14 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
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>()
.eq(ProjectExecutionDO::getProjectId, projectId)
.eq(ProjectExecutionDO::getStatusCode, statusCode)
@@ -62,6 +93,9 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectExecutionDO::getExecutionName, reqVO.getKeyword()));
}
if (!scope.seesAll()) {
queryWrapper.in(ProjectExecutionDO::getId, scope.executionIds());
}
return Math.toIntExact(selectCount(queryWrapper));
}
@@ -74,4 +108,14 @@ public interface ProjectExecutionMapper extends BaseMapperX<ProjectExecutionDO>
.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.ProjectTaskPageReqVO;
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.Param;
import org.apache.ibatis.annotations.Select;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@Mapper
public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
@@ -25,7 +30,13 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
.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<>();
queryWrapper.eq(ProjectTaskDO::getProjectId, projectId);
queryWrapper.eq(ProjectTaskDO::getExecutionId, executionId);
@@ -34,11 +45,14 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
queryWrapper.eqIfPresent(ProjectTaskDO::getStatusCode, reqVO.getStatusCode());
queryWrapper.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime());
queryWrapper.orderByAsc(ProjectTaskDO::getParentTaskId);
queryWrapper.orderByDesc(BaseDO::getUpdateTime);
queryWrapper.orderByDesc(BaseDO::getCreateTime);
queryWrapper.orderByDesc(ProjectTaskDO::getId);
if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectTaskDO::getTaskTitle, reqVO.getKeyword()));
}
if (!scope.seesAll()) {
queryWrapper.in(ProjectTaskDO::getId, scope.taskIds());
}
return selectPage(reqVO, queryWrapper);
}
@@ -51,6 +65,16 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
.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 不会被覆盖。
*/
@@ -74,6 +98,18 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
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));
}
/**
* 执行详情进度:按当前执行下一级任务 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 等)。
*/
@@ -108,9 +177,62 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
.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,
VisibilityScope scope,
ProjectTaskStatusBoardReqVO reqVO,
String statusCode) {
// 可见性短路:非 seesAll 且无任何可见任务 → 0
if (!scope.seesAll() && scope.taskIds().isEmpty()) {
return 0;
}
LambdaQueryWrapperX<ProjectTaskDO> queryWrapper = new LambdaQueryWrapperX<ProjectTaskDO>()
.eq(ProjectTaskDO::getProjectId, projectId)
.eq(ProjectTaskDO::getExecutionId, executionId)
@@ -121,6 +243,9 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
if (StringUtils.hasText(reqVO.getKeyword())) {
queryWrapper.and(wrapper -> wrapper.like(ProjectTaskDO::getTaskTitle, reqVO.getKeyword()));
}
if (!scope.seesAll()) {
queryWrapper.in(ProjectTaskDO::getId, scope.taskIds());
}
return Math.toIntExact(selectCount(queryWrapper));
}

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.module.project.dal.dataobject.project.task.TaskAssigneeDO;
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.Collections;
@@ -48,6 +50,41 @@ public interface TaskAssigneeMapper extends BaseMapperX<TaskAssigneeDO> {
.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由调用方判断。
*/

View File

@@ -9,6 +9,8 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
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) {
LambdaQueryWrapperX<TaskWorklogDO> queryWrapper = new LambdaQueryWrapperX<TaskWorklogDO>()
.eq(TaskWorklogDO::getTaskId, taskId)
.eqIfPresent(TaskWorklogDO::getUserId, reqVO.getUserId())
.geIfPresent(TaskWorklogDO::getWorkDate, reqVO.getStartDate())
.leIfPresent(TaskWorklogDO::getWorkDate, reqVO.getEndDate())
.orderByDesc(TaskWorklogDO::getWorkDate)
.eqIfPresent(TaskWorklogDO::getUserId, reqVO.getUserId());
if (reqVO.getEndDate() != null) {
queryWrapper.le(TaskWorklogDO::getStartDate, reqVO.getEndDate());
}
if (reqVO.getStartDate() != null) {
queryWrapper.ge(TaskWorklogDO::getEndDate, reqVO.getStartDate());
}
queryWrapper.orderByDesc(TaskWorklogDO::getEndDate)
.orderByDesc(TaskWorklogDO::getId);
return selectPage(reqVO, queryWrapper);
}
/**
* 单任务工时汇总(分钟)。无记录时返回 0逻辑删除的记录不参与汇总。
* 单任务工时小时数汇总。无记录时返回 0逻辑删除的记录不参与汇总。
*/
@Select("""
SELECT COALESCE(SUM(duration_minutes), 0)
SELECT COALESCE(SUM(duration_hours), 0)
FROM rdms_task_worklog
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("""
<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
WHERE deleted = b'0' AND task_id IN
<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;
}
/**
* 取指定用户在该任务下最新的一条工时(按 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;
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.user.AdminUserApi;
import org.springframework.cloud.openfeign.EnableFeignClients;
@@ -10,6 +11,6 @@ import org.springframework.context.annotation.Configuration;
* Project 模块的 RPC 配置
*/
@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 {
}

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.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.ProductOverviewSummaryRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
@@ -23,6 +24,22 @@ public interface ProductService {
*/
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.ObjectRoleConstants;
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.ProductContextProductRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRoleRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO;
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductCreateWithTeamReqVO;
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.ProductPageReqVO;
@@ -103,6 +105,109 @@ public class ProductServiceImpl implements ProductService {
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
@Transactional(rollbackFor = Exception.class)
@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.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.ProjectOverviewSummaryRespVO;
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
@@ -19,6 +20,26 @@ public interface ProjectService {
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);
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.ObjectRoleConstants;
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.ProjectContextProjectRespVO;
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.ProjectCreateWithTeamReqVO;
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.ProjectPageReqVO;
@@ -57,10 +59,12 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -71,7 +75,8 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
* 项目 Service 实现类
*/
@Service
public class ProjectServiceImpl implements ProjectService {
public
class ProjectServiceImpl implements ProjectService {
@Resource
private ProjectMapper projectMapper;
@@ -133,6 +138,155 @@ public class ProjectServiceImpl implements ProjectService {
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
@Transactional(rollbackFor = Exception.class)
@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;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
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.ProjectExecutionStatusBoardRespVO;
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.dal.dataobject.project.execution.ProjectExecutionDO;
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.task.ProjectTaskMapper;
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 org.springframework.stereotype.Service;
import java.util.List;
import java.util.Objects;
@Service
public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService {
@@ -24,20 +29,35 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
private ProjectExecutionMapper projectExecutionMapper;
@Resource
private ProjectTaskMapper projectTaskMapper;
@Resource
private VisibilityScopeResolver visibilityScopeResolver;
@Override
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);
return buildExecutionStatusBoard(projectId, reqVO, statusModels);
return buildExecutionStatusBoard(projectId, scope, reqVO, statusModels);
}
@Override
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);
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) {
ProjectExecutionStatusBoardRespVO respVO = new ProjectExecutionStatusBoardRespVO();
List<ProjectExecutionStatusBoardRespVO.ProjectStatusBoardItemVO> items = statusModels.stream().map(statusModel -> {
@@ -45,7 +65,7 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
new ProjectExecutionStatusBoardRespVO.ProjectStatusBoardItemVO();
item.setStatusCode(statusModel.getStatusCode());
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.setTerminal(statusModel.getTerminalFlag());
return item;
@@ -55,14 +75,16 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
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) {
ProjectTaskStatusBoardRespVO respVO = new ProjectTaskStatusBoardRespVO();
List<ProjectTaskStatusBoardRespVO.ProjectStatusBoardItemVO> items = statusModels.stream().map(statusModel -> {
ProjectTaskStatusBoardRespVO.ProjectStatusBoardItemVO item = new ProjectTaskStatusBoardRespVO.ProjectStatusBoardItemVO();
item.setStatusCode(statusModel.getStatusCode());
item.setStatusName(statusModel.getStatusName());
item.setCount(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(projectId, executionId, reqVO,
item.setCount(projectTaskMapper.countByProjectIdAndExecutionIdAndStatusCode(projectId, executionId, scope, reqVO,
statusModel.getStatusCode()).longValue());
item.setSort(statusModel.getSort());
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.ProjectExecutionConstants;
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.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.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.dal.dataobject.audit.BizAuditLogDO;
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.execution.ExecutionMemberDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberLogDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
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.status.ObjectStatusModelDO;
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.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberLogMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeLogMapper;
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.status.ObjectStatusModelMapper;
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;
/**
* 执行成员 Service 实现类B 模型 - 多行周期记录
* 执行协办人 Service 实现类B 模型 - 多行周期记录
* <p>
* userId 在同执行内任意时刻只允许一段未失效重新加入新插一条独立段
* 旧段的 removed_reason 永久保留不再覆盖每次状态变更同步落 rdms_execution_member_log
* 旧段的 removed_reason 永久保留不再覆盖每次状态变更同步落 rdms_execution_assignee_log
*/
@Service
public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMemberService {
public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssigneeService {
@Resource
private ProjectMapper projectMapper;
@Resource
private ProjectExecutionMapper projectExecutionMapper;
@Resource
private ExecutionMemberMapper executionMemberMapper;
private ExecutionAssigneeMapper executionAssigneeMapper;
@Resource
private ExecutionMemberLogMapper executionMemberLogMapper;
private ExecutionAssigneeLogMapper executionAssigneeLogMapper;
@Resource
private UserObjectRoleMapper userObjectRoleMapper;
@Resource
@@ -78,20 +78,20 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
private AdminUserApi adminUserApi;
@Override
public List<ExecutionMemberRespVO> getExecutionMemberList(Long projectId, Long executionId) {
public List<ExecutionAssigneeRespVO> getExecutionAssigneeList(Long projectId, Long executionId) {
validateProjectExists(projectId);
validateExecutionExists(projectId, executionId);
// 仅返当前活跃段removed_at IS NULLB 模型下同 userId 至多一段
List<ExecutionMemberDO> activeList = executionMemberMapper.selectActiveListByExecutionId(executionId);
List<ExecutionAssigneeDO> activeList = executionAssigneeMapper.selectActiveListByExecutionId(executionId);
if (activeList.isEmpty()) {
return Collections.emptyList();
}
// 批量回查昵称避免 N+1
Map<Long, String> nicknameMap = loadUserNicknameMap(activeList.stream()
.map(ExecutionMemberDO::getUserId).collect(Collectors.toCollection(LinkedHashSet::new)));
return activeList.stream().map(member -> {
ExecutionMemberRespVO respVO = buildMemberRespVO(member);
respVO.setUserNickname(nicknameMap.get(member.getUserId()));
.map(ExecutionAssigneeDO::getUserId).collect(Collectors.toCollection(LinkedHashSet::new)));
return activeList.stream().map(assignee -> {
ExecutionAssigneeRespVO respVO = buildAssigneeRespVO(assignee);
respVO.setUserNickname(nicknameMap.get(assignee.getUserId()));
return respVO;
}).collect(Collectors.toList());
}
@@ -99,70 +99,70 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_MEMBER)
public Long createExecutionMember(Long projectId, Long executionId, ExecutionMemberSaveReqVO reqVO) {
permission = ProjectExecutionConstants.PERMISSION_ASSIGNEE)
public Long createExecutionAssignee(Long projectId, Long executionId, ExecutionAssigneeSaveReqVO reqVO) {
validateEditableExecution(projectId, executionId);
validateProjectMember(projectId, reqVO.getUserId());
// B 模型只看是否有当前活跃段旧的失效段允许"重新加入"新插一行
ExecutionMemberDO active = executionMemberMapper
ExecutionAssigneeDO active = executionAssigneeMapper
.selectActiveByExecutionIdAndUserId(executionId, reqVO.getUserId());
if (active != null) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_ALREADY_EXISTS);
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_ALREADY_EXISTS);
}
LocalDateTime now = LocalDateTime.now();
ExecutionMemberDO member = new ExecutionMemberDO();
member.setExecutionId(executionId);
member.setUserId(reqVO.getUserId());
member.setJoinedAt(now);
member.setRemovedAt(null);
member.setRemovedReason(null);
executionMemberMapper.insert(member);
ExecutionAssigneeDO assignee = new ExecutionAssigneeDO();
assignee.setExecutionId(executionId);
assignee.setUserId(reqVO.getUserId());
assignee.setJoinedAt(now);
assignee.setRemovedAt(null);
assignee.setRemovedReason(null);
executionAssigneeMapper.insert(assignee);
// 双写 BizAuditLog 通用审计 + rdms_execution_member_log 业务事件流
writeExecutionMemberAuditLog(executionId, ObjectActivityConstants.EXECUTION_MEMBER_ACTION_ADD, null, member, null);
writeMemberLogInternal(executionId, reqVO.getUserId(),
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_JOIN, null, now);
return member.getId();
// 双写 BizAuditLog 通用审计 + rdms_execution_assignee_log 业务事件流
writeExecutionAssigneeAuditLog(executionId, ObjectActivityConstants.EXECUTION_ASSIGNEE_ACTION_ADD, null, assignee, null);
writeAssigneeLogInternal(executionId, reqVO.getUserId(),
ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_JOIN, null, now);
return assignee.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_MEMBER)
public void inactiveExecutionMember(Long projectId, Long executionId, Long memberId, ExecutionMemberInactiveReqVO reqVO) {
permission = ProjectExecutionConstants.PERMISSION_ASSIGNEE)
public void inactiveExecutionAssignee(Long projectId, Long executionId, Long assigneeId, ExecutionAssigneeInactiveReqVO reqVO) {
validateEditableExecution(projectId, executionId);
ExecutionMemberDO member = validateExecutionMemberExists(executionId, memberId);
if (member.getRemovedAt() != null) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_NOT_ACTIVE);
ExecutionAssigneeDO assignee = validateExecutionAssigneeExists(executionId, assigneeId);
if (assignee.getRemovedAt() != null) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_NOT_ACTIVE);
}
ExecutionMemberDO before = cloneExecutionMember(member);
ExecutionAssigneeDO before = cloneExecutionAssignee(assignee);
LocalDateTime now = LocalDateTime.now();
String reason = normalizeNullableText(reqVO.getReason());
member.setRemovedAt(now);
member.setRemovedReason(reason);
executionMemberMapper.updateById(member);
assignee.setRemovedAt(now);
assignee.setRemovedReason(reason);
executionAssigneeMapper.updateById(assignee);
writeExecutionMemberAuditLog(executionId, ObjectActivityConstants.EXECUTION_MEMBER_ACTION_REMOVE,
before, member, reason);
writeMemberLogInternal(executionId, member.getUserId(),
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_INACTIVE, reason, now);
writeExecutionAssigneeAuditLog(executionId, ObjectActivityConstants.EXECUTION_ASSIGNEE_ACTION_REMOVE,
before, assignee, reason);
writeAssigneeLogInternal(executionId, assignee.getUserId(),
ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_INACTIVE, reason, now);
}
@Override
public PageResult<ExecutionMemberLogRespVO> getExecutionMemberLogPage(Long projectId, Long executionId,
ExecutionMemberLogPageReqVO reqVO) {
public PageResult<ExecutionAssigneeLogRespVO> getExecutionAssigneeLogPage(Long projectId, Long executionId,
ExecutionAssigneeLogPageReqVO reqVO) {
validateProjectExists(projectId);
validateExecutionExists(projectId, executionId);
PageResult<ExecutionMemberLogDO> page = executionMemberLogMapper.selectPageByExecutionId(executionId, reqVO);
PageResult<ExecutionMemberLogRespVO> result = BeanUtils.toBean(page, ExecutionMemberLogRespVO.class);
fillMemberLogNicknames(result.getList());
PageResult<ExecutionAssigneeLogDO> page = executionAssigneeLogMapper.selectPageByExecutionId(executionId, reqVO);
PageResult<ExecutionAssigneeLogRespVO> result = BeanUtils.toBean(page, ExecutionAssigneeLogRespVO.class);
fillAssigneeLogNicknames(result.getList());
return result;
}
@Override
public void writeMemberLog(Long executionId, Long userId, String actionType, String reason) {
writeMemberLogInternal(executionId, userId, actionType, reason, LocalDateTime.now());
public void writeAssigneeLog(Long executionId, Long userId, String actionType, String reason) {
writeAssigneeLogInternal(executionId, userId, actionType, reason, LocalDateTime.now());
}
@VisibleForTesting
@@ -207,10 +207,10 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
@VisibleForTesting
void validateProjectMember(Long projectId, Long userId) {
UserObjectRoleDO member = userObjectRoleMapper
UserObjectRoleDO projectMember = userObjectRoleMapper
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId);
if (member == null) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_INVALID);
if (projectMember == null) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_INVALID);
}
}
@@ -227,22 +227,22 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
}
@VisibleForTesting
ExecutionMemberDO validateExecutionMemberExists(Long executionId, Long memberId) {
ExecutionMemberDO member = executionMemberMapper.selectByIdAndExecutionId(memberId, executionId);
if (member == null) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_NOT_EXISTS);
ExecutionAssigneeDO validateExecutionAssigneeExists(Long executionId, Long assigneeId) {
ExecutionAssigneeDO assignee = executionAssigneeMapper.selectByIdAndExecutionId(assigneeId, executionId);
if (assignee == null) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_NOT_EXISTS);
}
return member;
return assignee;
}
private ExecutionMemberRespVO buildMemberRespVO(ExecutionMemberDO member) {
ExecutionMemberRespVO respVO = new ExecutionMemberRespVO();
respVO.setId(member.getId());
respVO.setExecutionId(member.getExecutionId());
respVO.setUserId(member.getUserId());
respVO.setJoinedAt(member.getJoinedAt());
respVO.setRemovedAt(member.getRemovedAt());
respVO.setRemovedReason(member.getRemovedReason());
private ExecutionAssigneeRespVO buildAssigneeRespVO(ExecutionAssigneeDO assignee) {
ExecutionAssigneeRespVO respVO = new ExecutionAssigneeRespVO();
respVO.setId(assignee.getId());
respVO.setExecutionId(assignee.getExecutionId());
respVO.setUserId(assignee.getUserId());
respVO.setJoinedAt(assignee.getJoinedAt());
respVO.setRemovedAt(assignee.getRemovedAt());
respVO.setRemovedReason(assignee.getRemovedReason());
return respVO;
}
@@ -251,12 +251,12 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
* <p>
* 这里只用 userId / operatorUserId 查当前昵称不依赖历史快照避免用户改名后前后展示不一致
*/
private void fillMemberLogNicknames(List<ExecutionMemberLogRespVO> logs) {
private void fillAssigneeLogNicknames(List<ExecutionAssigneeLogRespVO> logs) {
if (logs == null || logs.isEmpty()) {
return;
}
Set<Long> userIds = new LinkedHashSet<>();
for (ExecutionMemberLogRespVO log : logs) {
for (ExecutionAssigneeLogRespVO log : logs) {
if (log == null) {
continue;
}
@@ -271,7 +271,7 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
return;
}
Map<Long, String> nicknameMap = loadUserNicknameMap(userIds);
for (ExecutionMemberLogRespVO log : logs) {
for (ExecutionAssigneeLogRespVO log : logs) {
if (log == null) {
continue;
}
@@ -280,16 +280,16 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
}
}
private void writeExecutionMemberAuditLog(Long executionId,
String actionType,
ExecutionMemberDO before,
ExecutionMemberDO after,
String reason) {
private void writeExecutionAssigneeAuditLog(Long executionId,
String actionType,
ExecutionAssigneeDO before,
ExecutionAssigneeDO after,
String reason) {
BizAuditLogDO auditLog = new BizAuditLogDO();
auditLog.setBizType(ProjectExecutionConstants.BIZ_TYPE);
auditLog.setBizId(executionId);
auditLog.setActionType(actionType);
auditLog.setFieldChanges(buildExecutionMemberFieldChanges(before, after));
auditLog.setFieldChanges(buildExecutionAssigneeFieldChanges(before, after));
auditLog.setReason(reason);
auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
@@ -297,19 +297,19 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
}
/**
* 写一条 rdms_execution_member_log 事件
* 写一条 rdms_execution_assignee_log 事件
* 只写 ID 和事件语义字段昵称展示由查询阶段按当前系统用户信息回填
*/
private void writeMemberLogInternal(Long executionId, Long userId,
String actionType, String reason, LocalDateTime when) {
ExecutionMemberLogDO log = new ExecutionMemberLogDO();
private void writeAssigneeLogInternal(Long executionId, Long userId,
String actionType, String reason, LocalDateTime when) {
ExecutionAssigneeLogDO log = new ExecutionAssigneeLogDO();
log.setExecutionId(executionId);
log.setUserId(userId);
log.setActionType(actionType);
log.setReason(reason);
log.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
log.setActionTime(when);
executionMemberLogMapper.insert(log);
executionAssigneeLogMapper.insert(log);
}
private Map<Long, String> loadUserNicknameMap(Collection<Long> userIds) {
@@ -325,23 +325,23 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
return nicknameMap;
}
private String buildExecutionMemberFieldChanges(ExecutionMemberDO before, ExecutionMemberDO after) {
private String buildExecutionAssigneeFieldChanges(ExecutionAssigneeDO before, ExecutionAssigneeDO after) {
Map<String, Object> fieldChanges = new LinkedHashMap<>();
appendFieldChange(fieldChanges, "executionId", valueOf(before, ExecutionMemberDO::getExecutionId),
valueOf(after, ExecutionMemberDO::getExecutionId));
appendFieldChange(fieldChanges, "userId", valueOf(before, ExecutionMemberDO::getUserId),
valueOf(after, ExecutionMemberDO::getUserId));
appendFieldChange(fieldChanges, "joinedAt", valueOf(before, ExecutionMemberDO::getJoinedAt),
valueOf(after, ExecutionMemberDO::getJoinedAt));
appendFieldChange(fieldChanges, "removedAt", valueOf(before, ExecutionMemberDO::getRemovedAt),
valueOf(after, ExecutionMemberDO::getRemovedAt));
appendFieldChange(fieldChanges, "removedReason", valueOf(before, ExecutionMemberDO::getRemovedReason),
valueOf(after, ExecutionMemberDO::getRemovedReason));
appendFieldChange(fieldChanges, "executionId", valueOf(before, ExecutionAssigneeDO::getExecutionId),
valueOf(after, ExecutionAssigneeDO::getExecutionId));
appendFieldChange(fieldChanges, "userId", valueOf(before, ExecutionAssigneeDO::getUserId),
valueOf(after, ExecutionAssigneeDO::getUserId));
appendFieldChange(fieldChanges, "joinedAt", valueOf(before, ExecutionAssigneeDO::getJoinedAt),
valueOf(after, ExecutionAssigneeDO::getJoinedAt));
appendFieldChange(fieldChanges, "removedAt", valueOf(before, ExecutionAssigneeDO::getRemovedAt),
valueOf(after, ExecutionAssigneeDO::getRemovedAt));
appendFieldChange(fieldChanges, "removedReason", valueOf(before, ExecutionAssigneeDO::getRemovedReason),
valueOf(after, ExecutionAssigneeDO::getRemovedReason));
return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges);
}
private ExecutionMemberDO cloneExecutionMember(ExecutionMemberDO source) {
ExecutionMemberDO target = new ExecutionMemberDO();
private ExecutionAssigneeDO cloneExecutionAssignee(ExecutionAssigneeDO source) {
ExecutionAssigneeDO target = new ExecutionAssigneeDO();
target.setId(source.getId());
target.setExecutionId(source.getExecutionId());
target.setUserId(source.getUserId());
@@ -351,8 +351,8 @@ public class ProjectExecutionMemberServiceImpl implements ProjectExecutionMember
return target;
}
private <T> T valueOf(ExecutionMemberDO member, Function<ExecutionMemberDO, T> getter) {
return member == null ? null : getter.apply(member);
private <T> T valueOf(ExecutionAssigneeDO assignee, Function<ExecutionAssigneeDO, T> getter) {
return assignee == null ? null : getter.apply(assignee);
}
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.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.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.dal.dataobject.project.execution.ProjectExecutionDO;
@@ -13,9 +15,9 @@ import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExec
*/
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);
@@ -33,6 +35,23 @@ public interface ProjectExecutionService {
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);
/**
* 任务状态变更回调(事件驱动)。
* 任务侧在 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.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.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.dal.dataobject.audit.BizAuditLogDO;
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.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.ProjectExecutionStatusLogDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.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.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.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.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
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.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Collection;
@@ -63,6 +74,7 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
* 执行主数据 Service 实现类。
*/
@Service
@Slf4j
public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@Resource
@@ -70,7 +82,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@Resource
private ProjectExecutionMapper projectExecutionMapper;
@Resource
private ExecutionMemberMapper executionMemberMapper;
private ExecutionAssigneeMapper executionAssigneeMapper;
@Resource
private UserObjectRoleMapper userObjectRoleMapper;
@Resource
@@ -86,15 +98,28 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@Resource
private ProjectExecutionStatusViewService projectExecutionStatusViewService;
@Resource
private ProjectExecutionMemberService projectExecutionMemberService;
private ProjectExecutionAssigneeService projectExecutionAssigneeService;
@Resource
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
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_CREATE)
public Long createExecution(Long projectId, ProjectExecutionSaveReqVO reqVO) {
public Long createExecution(Long projectId, ProjectExecutionCreateReqVO reqVO) {
validateEditableProject(projectId);
validateRequirementIdPhaseOne(reqVO.getProjectRequirementId());
String executionName = normalizeRequiredName(reqVO.getExecutionName());
@@ -102,7 +127,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
validateOwnerIsActiveProjectMember(projectId, reqVO.getOwnerId());
String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType());
validateExecutionType(executionType);
Set<Long> memberUserIds = normalizeRequiredMemberUserIds(reqVO.getMemberUserIds());
Set<Long> assigneeUserIds = normalizeRequiredAssigneeUserIds(reqVO.getAssigneeUserIds());
validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
ProjectExecutionDO execution = new ProjectExecutionDO();
@@ -123,7 +148,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_CREATE, null,
initialStatusCode, buildExecutionFieldChanges(null, execution), null);
createExecutionMembers(execution.getId(), projectId, memberUserIds);
createExecutionAssignees(execution.getId(), projectId, assigneeUserIds);
return execution.getId();
}
@@ -131,7 +156,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_UPDATE)
public void updateExecution(Long projectId, ProjectExecutionSaveReqVO reqVO) {
public void updateExecution(Long projectId, ProjectExecutionUpdateReqVO reqVO) {
if (reqVO.getId() == null) {
throw invalidParamException("执行编号不能为空");
}
@@ -141,15 +166,14 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
validateRequirementIdPhaseOne(reqVO.getProjectRequirementId());
String executionName = normalizeRequiredName(reqVO.getExecutionName());
validateExecutionNameUnique(projectId, execution.getId(), executionName);
validateOwnerIsActiveProjectMember(projectId, reqVO.getOwnerId());
String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType());
validateExecutionType(executionType);
validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
// 不再处理 ownerId换负责人必须走 /change-owner 端点spec §6.3 契约收口)
ProjectExecutionDO before = cloneExecution(execution);
execution.setExecutionName(executionName);
execution.setExecutionType(executionType);
execution.setOwnerId(reqVO.getOwnerId());
execution.setProjectRequirementId(null);
execution.setPlannedStartDate(reqVO.getPlannedStartDate());
execution.setPlannedEndDate(reqVO.getPlannedEndDate());
@@ -169,13 +193,24 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@Override
public PageResult<ProjectExecutionDO> getExecutionPage(Long projectId, ProjectExecutionPageReqVO reqVO) {
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
public ProjectExecutionRespVO getExecutionRespVO(Long projectId, Long 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);
respVO.setProgressRate(loadExecutionProgress(projectId, executionId));
applyLifecycle(respVO);
respVO.setOwnerNickname(loadOwnerNickname(execution.getOwnerId()));
return respVO;
@@ -189,13 +224,24 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
if (list == null || list.isEmpty()) {
return voPageResult;
}
// 批量补负责人昵称,避免 N+1lifecycle 字段保持原行为不在分页装配)
fillExecutionProgress(projectId, list);
// 批量补负责人昵称,避免 N+1
Set<Long> ownerIds = list.stream()
.map(ProjectExecutionRespVO::getOwnerId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
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;
}
@@ -220,11 +266,44 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
execution.getStatusCode(), buildExecutionFieldChanges(before, execution), reason);
// 双向写入成员变更历史:原 owner 转出 / 新 owner 转入oldOwnerId 为 null 时跳过转出(首次设负责人场景)
if (oldOwnerId != null) {
projectExecutionMemberService.writeMemberLog(executionId, oldOwnerId,
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_OUT, reason);
projectExecutionAssigneeService.writeAssigneeLog(executionId, oldOwnerId,
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
@@ -236,9 +315,15 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
String actionCode = normalizeRequiredActionCode(reqVO.getActionCode());
String reason = normalizeNullableText(reqVO.getReason());
// 负责人独占动作complete / cancel / pause / resume 必须由执行负责人触发
validateOwnerForAction(execution, actionCode);
String fromStatus = execution.getStatusCode();
ObjectStatusTransitionDO transition = validateExecutionTransition(fromStatus, actionCode, reason);
String toStatus = transition.getToStatusCode();
// complete 前置:执行下所有任务必须终态
if ("complete".equals(actionCode)) {
validateAllTasksTerminal(executionId);
}
int updateCount = projectExecutionMapper.updateStatusByIdAndStatus(executionId, fromStatus, toStatus, reason);
if (updateCount != 1) {
@@ -246,10 +331,18 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
}
execution.setStatusCode(toStatus);
execution.setLastStatusReason(reason);
applyExecutionActualDateByStatusAction(execution, actionCode, toStatus);
writeExecutionStatusLog(execution, actionCode, fromStatus, toStatus, reason);
writeExecutionAuditLog(execution, actionCode, fromStatus, toStatus, null, reason);
maybeFillActualDates(execution, fromStatus, toStatus);
// 完成动作:兜底把执行进度刷到 100%
if ("complete".equals(actionCode)) {
forceCompleteProgress(execution);
}
// 取消 / 暂停 / 恢复:级联执行下任务
cascadeTasksIfNeeded(executionId, actionCode, reason);
}
@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();
for (Long memberUserId : memberUserIds) {
validateExecutionMemberProjectScope(projectId, memberUserId);
ExecutionMemberDO member = new ExecutionMemberDO();
for (Long assigneeUserId : assigneeUserIds) {
validateExecutionAssigneeProjectScope(projectId, assigneeUserId);
ExecutionAssigneeDO member = new ExecutionAssigneeDO();
member.setExecutionId(executionId);
member.setUserId(memberUserId);
member.setUserId(assigneeUserId);
member.setJoinedAt(now);
member.setRemovedAt(null);
member.setRemovedReason(null);
executionMemberMapper.insert(member);
writeExecutionAuditLogByBizId(executionId, ObjectActivityConstants.EXECUTION_MEMBER_ACTION_ADD,
null, null, buildExecutionMemberFieldChanges(null, member), null);
executionAssigneeMapper.insert(member);
writeExecutionAuditLogByBizId(executionId, ObjectActivityConstants.EXECUTION_ASSIGNEE_ACTION_ADD,
null, null, buildExecutionAssigneeFieldChanges(null, member), null);
// B 模型:每次创建活跃段同步写一条 join 事件,确保活跃成员都能在变更历史中追溯加入时间
projectExecutionMemberService.writeMemberLog(executionId, memberUserId,
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_JOIN, null);
projectExecutionAssigneeService.writeAssigneeLog(executionId, assigneeUserId,
ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_JOIN, null);
}
}
private void validateExecutionMemberProjectScope(Long projectId, Long userId) {
private void validateExecutionAssigneeProjectScope(Long projectId, Long userId) {
UserObjectRoleDO member = userObjectRoleMapper
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId);
if (member == null) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_INVALID);
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_INVALID);
}
}
private Set<Long> normalizeMemberUserIds(List<Long> memberUserIds) {
if (memberUserIds == null || memberUserIds.isEmpty()) {
private Set<Long> normalizeAssigneeUserIds(List<Long> assigneeUserIds) {
if (assigneeUserIds == null || assigneeUserIds.isEmpty()) {
return Collections.emptySet();
}
Set<Long> normalizedUserIds = new LinkedHashSet<>();
for (Long memberUserId : memberUserIds) {
if (memberUserId != null) {
normalizedUserIds.add(memberUserId);
for (Long assigneeUserId : assigneeUserIds) {
if (assigneeUserId != null) {
normalizedUserIds.add(assigneeUserId);
}
}
return normalizedUserIds;
}
private Set<Long> normalizeRequiredMemberUserIds(List<Long> memberUserIds) {
Set<Long> normalizedUserIds = normalizeMemberUserIds(memberUserIds);
private Set<Long> normalizeRequiredAssigneeUserIds(List<Long> assigneeUserIds) {
Set<Long> normalizedUserIds = normalizeAssigneeUserIds(assigneeUserIds);
if (normalizedUserIds.isEmpty()) {
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_REQUIRED);
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_REQUIRED);
}
return normalizedUserIds;
}
@@ -512,18 +605,18 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
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<>();
appendFieldChange(fieldChanges, "executionId", valueOf(before, ExecutionMemberDO::getExecutionId),
valueOf(after, ExecutionMemberDO::getExecutionId));
appendFieldChange(fieldChanges, "userId", valueOf(before, ExecutionMemberDO::getUserId),
valueOf(after, ExecutionMemberDO::getUserId));
appendFieldChange(fieldChanges, "joinedAt", valueOf(before, ExecutionMemberDO::getJoinedAt),
valueOf(after, ExecutionMemberDO::getJoinedAt));
appendFieldChange(fieldChanges, "removedAt", valueOf(before, ExecutionMemberDO::getRemovedAt),
valueOf(after, ExecutionMemberDO::getRemovedAt));
appendFieldChange(fieldChanges, "removedReason", valueOf(before, ExecutionMemberDO::getRemovedReason),
valueOf(after, ExecutionMemberDO::getRemovedReason));
appendFieldChange(fieldChanges, "executionId", valueOf(before, ExecutionAssigneeDO::getExecutionId),
valueOf(after, ExecutionAssigneeDO::getExecutionId));
appendFieldChange(fieldChanges, "userId", valueOf(before, ExecutionAssigneeDO::getUserId),
valueOf(after, ExecutionAssigneeDO::getUserId));
appendFieldChange(fieldChanges, "joinedAt", valueOf(before, ExecutionAssigneeDO::getJoinedAt),
valueOf(after, ExecutionAssigneeDO::getJoinedAt));
appendFieldChange(fieldChanges, "removedAt", valueOf(before, ExecutionAssigneeDO::getRemovedAt),
valueOf(after, ExecutionAssigneeDO::getRemovedAt));
appendFieldChange(fieldChanges, "removedReason", valueOf(before, ExecutionAssigneeDO::getRemovedReason),
valueOf(after, ExecutionAssigneeDO::getRemovedReason));
return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges);
}
@@ -531,7 +624,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
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);
}
@@ -578,8 +671,9 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
}
private void applyLifecycle(ProjectExecutionRespVO respVO) {
// 传入 ownerId 用于 availableActions 的 owner-only 字段硬卡过滤spec §7.1
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView lifecycle =
projectExecutionStatusViewService.getLifecycle(respVO.getStatusCode());
projectExecutionStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId());
respVO.setStatusName(lifecycle.statusName());
respVO.setTerminal(lifecycle.terminal());
respVO.setAllowEdit(lifecycle.allowEdit());
@@ -595,6 +689,76 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
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) {
if (ownerIds == null || ownerIds.isEmpty()) {
return Collections.emptyMap();
@@ -608,20 +772,173 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
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();
boolean changed = false;
if ("start".equals(actionCode) && execution.getActualStartDate() == null) {
execution.setActualStartDate(today);
changed = true;
if (execution.getActualStartDate() == null) {
ObjectStatusModelDO fromModel = objectStatusModelMapper
.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) {
execution.setActualEndDate(today);
changed = true;
if (execution.getActualEndDate() == null) {
ObjectStatusModelDO toModel = objectStatusModelMapper
.selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, toStatus);
if (toModel != null && Boolean.TRUE.equals(toModel.getTerminalFlag())) {
execution.setActualEndDate(today);
changed = true;
}
}
if (changed) {
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;
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.controller.admin.project.execution.vo.execution.ProjectExecutionLifecycleActionRespVO;
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.List;
import java.util.Objects;
import java.util.Set;
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
public class ProjectExecutionStatusViewService {
/**
* 状态推进动作中要求 owner-only 字段硬卡的集合,与 ProjectExecutionServiceImpl#validateOwnerForAction 一致。
*/
private static final Set<String> OWNER_ONLY_ACTIONS = Set.of("complete", "cancel", "pause", "resume");
@Resource
private ObjectStatusModelMapper objectStatusModelMapper;
@Resource
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
public ProjectExecutionLifecycleView getLifecycle(String statusCode) {
public ProjectExecutionLifecycleView getLifecycle(String statusCode, Long ownerId) {
ObjectStatusModelDO statusModel = objectStatusModelMapper
.selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, statusCode);
if (statusModel == null) {
@@ -33,23 +55,31 @@ public class ProjectExecutionStatusViewService {
statusModel.getStatusName(),
statusModel.getTerminalFlag(),
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
.selectListByObjectTypeAndFromStatus(ProjectExecutionConstants.OBJECT_TYPE, statusCode);
if (transitions == null || transitions.isEmpty()) {
return Collections.emptyList();
}
return transitions.stream().map(transition -> {
ProjectExecutionLifecycleActionRespVO action = new ProjectExecutionLifecycleActionRespVO();
action.setActionCode(transition.getActionCode());
action.setActionName(transition.getActionName());
action.setNeedReason(transition.getNeedReason());
return action;
}).toList();
Long currentUserId = SecurityFrameworkUtils.getLoginUserId();
return transitions.stream()
// 剔除系统级动作 auto_start由后端业务事件触发不暴露给前端按钮
.filter(transition -> !ObjectActivityConstants.PROJECT_ACTION_AUTO_START.equals(transition.getActionCode()))
// owner-only 字段硬卡:非负责人本人时不返回 complete / cancel / pause / resume 动作
.filter(transition -> !OWNER_ONLY_ACTIONS.contains(transition.getActionCode())
|| (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,

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;
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.ProjectTaskRespVO;
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);
/**
* 删除任务(软删 + 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.util.json.JsonUtils;
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.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
import com.njcn.rdms.module.project.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.ProjectTaskRespVO;
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.ProjectTaskStatusLogDO;
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.ObjectStatusTransitionDO;
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.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.task.ProjectTaskMapper;
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.ObjectStatusTransitionMapper;
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.service.ProjectObjectAuthorizationService;
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.worklog.TaskWorklogService;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -65,6 +75,7 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
* 项目任务 Service 实现类。
*/
@Service
@Slf4j
public class ProjectTaskServiceImpl implements ProjectTaskService {
@Resource
@@ -72,7 +83,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Resource
private ProjectExecutionMapper projectExecutionMapper;
@Resource
private ExecutionMemberMapper executionMemberMapper;
private ExecutionAssigneeMapper executionAssigneeMapper;
@Resource
private ProjectTaskMapper projectTaskMapper;
@Resource
@@ -88,6 +99,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Resource
private ProjectService projectService;
@Resource
private ProjectExecutionService projectExecutionService;
@Resource
private ProjectTaskStatusViewService projectTaskStatusViewService;
@Resource
private TaskAssigneeService taskAssigneeService;
@@ -95,21 +108,31 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
private TaskWorklogService taskWorklogService;
@Resource
private AdminUserApi adminUserApi;
@Resource
private AttachmentFileIdResolver attachmentFileIdResolver;
@Resource
private ProjectObjectAuthorizationService projectObjectAuthorizationService;
@Resource
private VisibilityScopeResolver visibilityScopeResolver;
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_CREATE)
public Long createTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) {
validateEditableProject(projectId);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
validateExecutionAllowEdit(execution);
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);
validateOwnerIsActiveExecutionMember(executionId, ownerId);
validateOwnerIsActiveExecutionAssignee(executionId, ownerId);
validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
// 叶子转父校验:当前 parentTask 还是叶子(无任何子任务)时,要求其进度=0 且无工时记录
validateLeafToParentSplit(parentTask);
AttachmentValidator.validate(reqVO.getAttachments());
attachmentFileIdResolver.resolve(reqVO.getAttachments());
ProjectTaskDO task = new ProjectTaskDO();
task.setProjectId(projectId);
@@ -118,12 +141,13 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
task.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle()));
task.setOwnerId(ownerId);
task.setStatusCode(getInitialTaskStatusCode());
// 新建任务自身一定是叶子(尚无子任务),按 owner 手填值落库(默认 0
task.setProgressRate(normalizeProgress(reqVO.getProgressRate()));
// 任务进度统一由 worklog 驱动;新建任务强制为 0
task.setProgressRate(BigDecimal.ZERO);
task.setPlannedStartDate(reqVO.getPlannedStartDate());
task.setPlannedEndDate(reqVO.getPlannedEndDate());
// 实际开始/结束日期不允许人工填写,由 changeTaskStatus 在状态流转时推导
task.setTaskDesc(normalizeNullableText(reqVO.getTaskDesc()));
task.setAttachments(reqVO.getAttachments());
projectTaskMapper.insert(task);
// 创建任务时初始化协办人列表(同事务,任一项失败整笔回滚;列表为空跳过)
@@ -142,8 +166,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Override
@Transactional(rollbackFor = Exception.class)
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_UPDATE)
public void updateTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) {
if (reqVO.getId() == null) {
throw invalidParamException("任务编号不能为空");
@@ -153,12 +175,19 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
validateExecutionAllowEdit(execution);
ProjectTaskDO task = validateTaskExists(projectId, executionId, reqVO.getId());
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());
if (parentTask != null && Objects.equals(parentTask.getId(), task.getId())) {
throw exception(ErrorCodeConstants.PROJECT_TASK_PARENT_INVALID);
}
Long ownerId = resolveTaskOwner(reqVO.getOwnerId(), parentTask);
validateOwnerIsActiveExecutionMember(executionId, ownerId);
validateOwnerIsActiveExecutionAssignee(executionId, ownerId);
validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
// 父任务变更校验:迁到新父时,新父若仍是叶子(即将变父),要求新父进度=0 且无工时
Long oldParentId = task.getParentTaskId();
@@ -166,18 +195,19 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
if (!Objects.equals(oldParentId, newParentId)) {
validateLeafToParentSplit(parentTask);
}
// 进度入参根据"当前任务是否已是父任务"决定:父任务拒绝手填变更,叶子任务沿用原规则
BigDecimal resolvedProgress = resolveProgressRateOnUpdate(task, reqVO.getProgressRate());
AttachmentValidator.validate(reqVO.getAttachments());
attachmentFileIdResolver.resolve(reqVO.getAttachments());
// 任务进度由 worklog 驱动owner 填工时回写 + 父任务 AVG 汇总),编辑任务接口不接受进度入参
ProjectTaskDO before = cloneTask(task);
task.setParentTaskId(newParentId);
task.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle()));
task.setOwnerId(ownerId);
task.setProgressRate(resolvedProgress);
task.setPlannedStartDate(reqVO.getPlannedStartDate());
task.setPlannedEndDate(reqVO.getPlannedEndDate());
// 实际开始/结束日期不允许人工编辑,保留原值由状态流转维护
task.setTaskDesc(normalizeNullableText(reqVO.getTaskDesc()));
task.setAttachments(reqVO.getAttachments());
projectTaskMapper.updateById(task);
// 进度联动:旧父链 + 新父链都需要重算(父任务不可手填进度,由本逻辑统一刷新)
@@ -194,6 +224,122 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
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
public ProjectTaskDO getTask(Long projectId, Long executionId, Long taskId) {
validateExecutionExists(projectId, executionId);
@@ -202,19 +348,54 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Override
public PageResult<ProjectTaskDO> getTaskPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) {
validateExecutionExists(projectId, executionId);
return projectTaskMapper.selectPageByExecutionId(projectId, executionId, reqVO);
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
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
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);
applyLifecycle(respVO);
respVO.setOwnerNickname(loadOwnerNickname(task.getOwnerId()));
respVO.setAssignees(buildAssigneeViews(taskAssigneeService
.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;
}
@@ -231,20 +412,42 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, List<TaskAssigneeDO>> assigneeMap = taskAssigneeService
.loadActiveAssigneesGroupedByTaskId(taskIds);
Map<Long, Long> spentMinutesMap = taskWorklogService.sumDurationGroupedByTaskIds(taskIds);
Map<Long, BigDecimal> spentHoursMap = taskWorklogService.sumDurationGroupedByTaskIds(taskIds);
Set<Long> userIdsToResolve = list.stream()
.map(ProjectTaskRespVO::getOwnerId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
assigneeMap.values().forEach(items -> items.forEach(a -> userIdsToResolve.add(a.getUserId())));
Map<Long, String> nicknameMap = loadOwnerNicknameMap(userIdsToResolve);
// 批量查父任务 owner避免按 list 循环 N+1
Set<Long> parentTaskIds = list.stream().map(ProjectTaskRespVO::getParentTaskId)
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, Long> parentTaskOwnerMap = parentTaskIds.isEmpty()
? Map.of()
: projectTaskMapper.selectBatchIds(parentTaskIds).stream()
.collect(Collectors.toMap(ProjectTaskDO::getId, ProjectTaskDO::getOwnerId));
// 执行 owner 单条查询整页共享URL 路径定 executionId
ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
Long executionOwnerId = execution == null ? null : execution.getOwnerId();
list.forEach(vo -> {
vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId()));
List<TaskAssigneeDO> activeList = assigneeMap.getOrDefault(vo.getId(), List.of());
vo.setAssignees(activeList.stream()
.map(a -> toAssigneeView(a, nicknameMap.get(a.getUserId())))
.collect(Collectors.toList()));
vo.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;
}
@@ -277,21 +480,41 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
String actionCode = normalizeRequiredActionCode(reqVO.getActionCode());
String reason = normalizeNullableText(reqVO.getReason());
String fromStatus = task.getStatusCode();
ObjectStatusTransitionDO transition = validateTaskTransition(fromStatus, actionCode, reason);
String toStatus = transition.getToStatusCode();
validateBeforeStatusChange(task, toStatus);
// owner-only 字段硬卡cancel / pause / resume / complete 必须任务负责人本人触发(与执行同款,不接受角色权限码兜底)
validateOwnerForAction(task, actionCode);
doInternalChangeTaskStatus(task, actionCode, reason);
}
int updateCount = projectTaskMapper.updateStatusByIdAndStatus(taskId, fromStatus, toStatus, reason);
if (updateCount != 1) {
throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_CONCURRENT_MODIFIED);
/**
* 负责人独占动作校验complete / cancel / pause / resume 必须由任务负责人触发。
* 系统级别的 internalAutoStartByWorklog 不经此校验。
*/
private void validateOwnerForAction(ProjectTaskDO task, String actionCode) {
if (!isOwnerOnlyAction(actionCode)) {
return;
}
task.setStatusCode(toStatus);
task.setLastStatusReason(reason);
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
if (!Objects.equals(loginUserId, task.getOwnerId())) {
throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_OWNER_ONLY,
resolveActionDisplayName(actionCode));
}
}
writeTaskStatusLog(task, actionCode, fromStatus, toStatus, reason);
writeTaskAuditLog(task, actionCode, fromStatus, toStatus, null, reason);
maybeFillActualDates(task, fromStatus, toStatus);
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;
};
}
/**
@@ -408,8 +631,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
}
}
private void validateOwnerIsActiveExecutionMember(Long executionId, Long ownerId) {
if (ownerId == null || executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, ownerId) == null) {
private void validateOwnerIsActiveExecutionAssignee(Long executionId, Long ownerId) {
if (ownerId == null || executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, ownerId) == null) {
throw exception(ErrorCodeConstants.PROJECT_TASK_OWNER_INVALID);
}
}
@@ -439,9 +662,14 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
/**
* 进入终态前置校验当目标状态属于终态terminalFlag=true要求所有子任务已进入终态
* 避免父任务关闭后子任务仍处于未结束状态。判定基于通用语义位,不识别具体 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
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, toStatus);
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() {
ObjectStatusModelDO statusModel = objectStatusModelMapper
.selectInitialByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
@@ -574,25 +996,6 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
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 当前还是叶子(无任何子任务)时校验
* 自身进度=0 且没有任何工时记录。已是父任务则直接放行。
@@ -680,8 +1083,9 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
}
private void applyLifecycle(ProjectTaskRespVO respVO) {
// 传入 ownerId 用于 availableActions 的 owner-only 字段硬卡过滤spec §7.1
ProjectTaskStatusViewService.ProjectTaskLifecycleView lifecycle =
projectTaskStatusViewService.getLifecycle(respVO.getStatusCode());
projectTaskStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId());
respVO.setStatusName(lifecycle.statusName());
respVO.setTerminal(lifecycle.terminal());
respVO.setAllowEdit(lifecycle.allowEdit());

View File

@@ -1,5 +1,7 @@
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.controller.admin.project.task.vo.ProjectTaskLifecycleActionRespVO;
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.List;
import java.util.Objects;
import java.util.Set;
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
public class ProjectTaskStatusViewService {
/**
* 状态推进动作中要求 owner-only 字段硬卡的集合,与 ProjectTaskServiceImpl#validateOwnerForAction 一致。
*/
private static final Set<String> OWNER_ONLY_ACTIONS = Set.of("complete", "cancel", "pause", "resume");
@Resource
private ObjectStatusModelMapper objectStatusModelMapper;
@Resource
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
public ProjectTaskLifecycleView getLifecycle(String statusCode) {
public ProjectTaskLifecycleView getLifecycle(String statusCode, Long ownerId) {
ObjectStatusModelDO statusModel = objectStatusModelMapper
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, statusCode);
if (statusModel == null) {
@@ -33,23 +54,31 @@ public class ProjectTaskStatusViewService {
statusModel.getStatusName(),
statusModel.getTerminalFlag(),
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
.selectListByObjectTypeAndFromStatus(ProjectTaskConstants.OBJECT_TYPE, statusCode);
if (transitions == null || transitions.isEmpty()) {
return Collections.emptyList();
}
return transitions.stream().map(transition -> {
ProjectTaskLifecycleActionRespVO action = new ProjectTaskLifecycleActionRespVO();
action.setActionCode(transition.getActionCode());
action.setActionName(transition.getActionName());
action.setNeedReason(transition.getNeedReason());
return action;
}).toList();
Long currentUserId = SecurityFrameworkUtils.getLoginUserId();
return transitions.stream()
// 剔除系统级动作 auto_start由工时填报触发不暴露给前端按钮
.filter(transition -> !ObjectActivityConstants.TASK_ACTION_AUTO_START.equals(transition.getActionCode()))
// owner-only 字段硬卡:非负责人本人时不返回 complete / cancel / pause / resume 动作
.filter(transition -> !OWNER_ONLY_ACTIONS.contains(transition.getActionCode())
|| (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,

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.status.ObjectStatusModelDO;
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.task.ProjectTaskMapper;
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.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
@@ -57,7 +58,7 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
@Resource
private ProjectTaskMapper projectTaskMapper;
@Resource
private ExecutionMemberMapper executionMemberMapper;
private ExecutionAssigneeMapper executionAssigneeMapper;
@Resource
private TaskAssigneeMapper taskAssigneeMapper;
@Resource
@@ -85,6 +86,8 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
@Override
@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) {
ProjectTaskDO task = validateEditableContext(projectId, executionId, taskId);
Long userId = reqVO.getUserId();
@@ -94,6 +97,8 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
@Override
@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,
TaskAssigneeInactiveReqVO reqVO) {
validateEditableContext(projectId, executionId, taskId);
@@ -218,7 +223,7 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
if (Objects.equals(userId, ownerId)) {
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);
}
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.TaskWorklogSaveReqVO;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* 任务工时 Service。
* <p>
* 决策来源docs/任务工时与进度模型_业内标杆调研.md。
* 核心约束:仅叶子任务可挂工时;填报人限制为 owner + 在岗协办人;时长按分钟存30 分钟整数倍);
* 同一 user × task × work_date 允许多条;改/删自己的owner 可删别人的。
* 决策来源docs/项目/2026-05-09-task-worklog-design.md。
* 核心约束:仅叶子任务可挂工时;填报人限制为 owner + 在岗协办人;
* 按段记录startDate/endDate 必填,单天=二者相等);同人同任务下日期范围禁止重叠;
* 颗粒durationHours 必须 > 0 且为 0.5 的整数倍;
* 进度owner 填报触发任务进度同步(按 endDate 排序取本人最新一条),协作人填报仅本人自评。
*/
public interface TaskWorklogService {
/**
* 任务工时分页(按 workDate DESC, id DESC
* 支持按填报人 / 日期区间筛选;上下文校验仅要求项目/执行/任务存在,不要求允许编辑。
* 任务工时分页(按 endDate DESC, id DESC
* 支持按填报人 / 段相交过滤;上下文校验仅要求项目/执行/任务存在,不要求允许编辑。
*/
PageResult<TaskWorklogRespVO> getWorklogPage(Long projectId, Long executionId, Long taskId,
TaskWorklogPageReqVO reqVO);
/**
* 新增工时记录。校验:上下文可编辑、任务为叶子、登录人是 owner 或在岗协办人、时长合法。
* 新增工时记录。校验:上下文可编辑、任务为叶子、登录人是 owner 或在岗协办人、
* 段日期合法、与已有记录不重叠、时长合法。
* 填报人 userId 取登录用户,前端不传。返回新建记录编号。
*/
Long createWorklog(Long projectId, Long executionId, Long taskId, TaskWorklogSaveReqVO reqVO);
/**
* 修改工时记录(含工作日期/时长/工作内容)。校验:记录归属任务、登录人是该记录原填报人、时长合法。
* 修改工时记录(含起止日期/时长/进度/工作内容/附件)。校验:记录归属任务、登录人是该记录原填报人、
* 段日期合法、与该用户其他记录不重叠(排除自身)、时长合法。
* 不允许修改 taskId / userId前端不可传
*/
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);
/**
* 批量任务工时汇总(分钟),用于详情/分页装配 totalSpentMinutes避免 N+1。
* 批量任务工时小时数汇总,用于详情/分页装配 totalSpentHours避免 N+1。
* 任务无任何工时时不会出现在结果中(调用方需用默认 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) {
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.status.ObjectStatusModelMapper;
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.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
@@ -44,9 +51,9 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
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
private ProjectMapper projectMapper;
@@ -62,6 +69,15 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
private ObjectStatusModelMapper objectStatusModelMapper;
@Resource
private AdminUserApi adminUserApi;
@Resource
private AttachmentFileIdResolver attachmentFileIdResolver;
/**
* 与 ProjectTaskService 互相依赖ProjectTaskService 也注入本类),用 @Lazy 打破循环。
* 仅用于 owner 工时变更后同步任务自身进度并触发父任务汇总。
*/
@Resource
@Lazy
private ProjectTaskService projectTaskService;
@Override
public PageResult<TaskWorklogRespVO> getWorklogPage(Long projectId, Long executionId, Long taskId,
@@ -84,46 +100,73 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
@Override
@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) {
ProjectTaskDO task = validateEditableContext(projectId, executionId, taskId);
validateLeafTask(taskId);
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
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();
worklog.setTaskId(taskId);
worklog.setUserId(loginUserId);
worklog.setWorkDate(reqVO.getWorkDate());
worklog.setDurationMinutes(reqVO.getDurationMinutes());
worklog.setWorkContent(normalizeNullableText(reqVO.getWorkContent()));
TaskWorklogDO worklog = buildWorklog(taskId, loginUserId, reqVO);
taskWorklogMapper.insert(worklog);
// 任意填报人owner / 协办人)都触发任务自动开始(仅当任务仍处初始态时生效),并写入 actualStartDate。
// 任务"是否开始"是客观事实,协办人开工同样代表任务已开始,不应等 owner 补工时才反映。
projectTaskService.internalAutoStartByWorklog(task);
// 仅 owner 填报触发任务进度同步:任务整体进度以 owner 本人最新一条工时为权威源,
// 协办人填报的是个体进度,不参与任务整体进度计算。
if (Objects.equals(loginUserId, task.getOwnerId())) {
projectTaskService.syncOwnerProgressFromLatestWorklog(taskId);
}
return worklog.getId();
}
@Override
@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,
TaskWorklogSaveReqVO reqVO) {
validateEditableContext(projectId, executionId, taskId);
ProjectTaskDO task = validateEditableContext(projectId, executionId, taskId);
TaskWorklogDO worklog = loadWorklog(worklogId, taskId);
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
// 仅原填报人可改;任务负责人也不能改协办人的记录(避免争议)
if (!Objects.equals(worklog.getUserId(), loginUserId)) {
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();
update.setId(worklog.getId());
update.setWorkDate(reqVO.getWorkDate());
update.setDurationMinutes(reqVO.getDurationMinutes());
update.setStartDate(reqVO.getStartDate());
update.setEndDate(reqVO.getEndDate());
update.setDurationHours(reqVO.getDurationHours());
update.setProgressRate(reqVO.getProgressRate());
update.setWorkContent(normalizeNullableText(reqVO.getWorkContent()));
update.setAttachments(reqVO.getAttachments());
taskWorklogMapper.updateById(update);
// owner 改自己工时:按"owner 最新一条"重算任务进度
if (Objects.equals(worklog.getUserId(), task.getOwnerId())) {
projectTaskService.syncOwnerProgressFromLatestWorklog(taskId);
}
}
@Override
@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) {
ProjectTaskDO task = validateEditableContext(projectId, executionId, taskId);
TaskWorklogDO worklog = loadWorklog(worklogId, taskId);
@@ -134,10 +177,15 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_DELETE_FORBIDDEN);
}
taskWorklogMapper.deleteById(worklog.getId());
// 删的是 owner 自己的工时:按"owner 剩余最新一条"重算任务进度;
// 若 owner 已无任何工时syncOwnerProgressFromLatestWorklog 内部会保留原值不动(不归零)
if (Objects.equals(worklog.getUserId(), task.getOwnerId())) {
projectTaskService.syncOwnerProgressFromLatestWorklog(taskId);
}
}
@Override
public Map<Long, Long> sumDurationGroupedByTaskIds(Collection<Long> taskIds) {
public Map<Long, BigDecimal> sumDurationGroupedByTaskIds(Collection<Long> taskIds) {
if (taskIds == null || taskIds.isEmpty()) {
return Collections.emptyMap();
}
@@ -145,12 +193,14 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
if (rows == null || rows.isEmpty()) {
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) {
Object idValue = row.getOrDefault("taskId", row.get("task_id"));
Object totalValue = row.get("total");
if (idValue instanceof Number idNum && totalValue instanceof Number totalNum) {
result.put(idNum.longValue(), totalNum.longValue());
if (idValue instanceof Number idNum && totalValue != null) {
BigDecimal totalBd = totalValue instanceof BigDecimal bd
? bd : new BigDecimal(totalValue.toString());
result.put(idNum.longValue(), totalBd);
}
}
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 同口径。
*/
@@ -167,10 +230,26 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
validateAllowEdit(ProjectExecutionConstants.OBJECT_TYPE, execution.getStatusCode());
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;
}
/**
* 是否处于"任务已完成、协办人维护工时"的放行场景。
*/
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) {
validateExecutionExists(projectId, executionId);
validateTaskExists(projectId, executionId, taskId);
@@ -244,14 +323,59 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
throw exception(ErrorCodeConstants.PROJECT_TASK_WORKLOG_NOT_OWNER_OR_ASSIGNEE);
}
private void validateDurationGranularity(Integer durationMinutes) {
if (durationMinutes == null
|| durationMinutes <= 0
|| durationMinutes % DURATION_GRANULARITY_MINUTES != 0) {
/**
* 校验段日期startDate 不能晚于 endDateNotNull 已由 Bean Validation 保证)。
*/
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);
}
}
/**
* 校验本次提交的段与该 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) {
if (worklogId == null) {
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.task.ProjectTaskMapper;
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.mockito.InjectMocks;
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.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
@@ -33,6 +37,17 @@ class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
private ProjectExecutionMapper projectExecutionMapper;
@Mock
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
void getExecutionStatusBoard_shouldReturnEnabledStatusesInSortOrderAndSumCounts() {
@@ -44,16 +59,16 @@ class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
createStatus("cancelled", "已取消", 50, true),
createStatus("disabled", "已停用", 60, false, 1)
));
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(ProjectExecutionStatusBoardReqVO.class), eq("pending")))
.thenReturn(3);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(ProjectExecutionStatusBoardReqVO.class), eq("active")))
.thenReturn(8);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(ProjectExecutionStatusBoardReqVO.class), eq("paused")))
.thenReturn(2);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(ProjectExecutionStatusBoardReqVO.class), eq("completed")))
.thenReturn(4);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(ProjectExecutionStatusBoardReqVO.class), eq("cancelled")))
.thenReturn(1);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
any(ProjectExecutionStatusBoardReqVO.class), eq("pending"))).thenReturn(3);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
any(ProjectExecutionStatusBoardReqVO.class), eq("active"))).thenReturn(8);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
any(ProjectExecutionStatusBoardReqVO.class), eq("paused"))).thenReturn(2);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
any(ProjectExecutionStatusBoardReqVO.class), eq("completed"))).thenReturn(4);
when(projectExecutionMapper.countByProjectIdAndStatusCode(eq(2001L), any(VisibilityScope.class),
any(ProjectExecutionStatusBoardReqVO.class), eq("cancelled"))).thenReturn(1);
ProjectExecutionStatusBoardReqVO reqVO = new ProjectExecutionStatusBoardReqVO();
reqVO.setKeyword("接口");
@@ -80,20 +95,20 @@ class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectListByObjectTypeEnabled("task")).thenReturn(List.of(
createStatus("pending", "待开始", 10, false),
createStatus("active", "进行中", 20, false),
createStatus("blocked", "阻塞", 30, false),
createStatus("paused", "暂停", 30, false),
createStatus("completed", "已完成", 40, true),
createStatus("cancelled", "已取消", 50, true)
));
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),
any(ProjectTaskStatusBoardReqVO.class), eq("active"))).thenReturn(12);
any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("active"))).thenReturn(12);
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),
any(ProjectTaskStatusBoardReqVO.class), eq("completed"))).thenReturn(4);
any(VisibilityScope.class), any(ProjectTaskStatusBoardReqVO.class), eq("completed"))).thenReturn(4);
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();
reqVO.setKeyword("任务");
@@ -107,7 +122,7 @@ class ProjectStatusBoardServiceTest extends BaseMockitoUnitTest {
assertEquals(5, result.getItems().size());
assertEquals("pending", result.getItems().get(0).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("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.pojo.PageResult;
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.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.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.dal.dataobject.audit.BizAuditLogDO;
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.execution.ExecutionMemberDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionMemberLogDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
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.status.ObjectStatusModelDO;
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.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberLogMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionMemberMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeLogMapper;
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.status.ObjectStatusModelMapper;
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.when;
class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
class ProjectExecutionAssigneeServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ProjectExecutionMemberServiceImpl projectExecutionMemberService;
private ProjectExecutionAssigneeServiceImpl projectExecutionAssigneeService;
@Mock
private ProjectMapper projectMapper;
@Mock
private ProjectExecutionMapper projectExecutionMapper;
@Mock
private ExecutionMemberMapper executionMemberMapper;
private ExecutionAssigneeMapper executionAssigneeMapper;
@Mock
private ExecutionMemberLogMapper executionMemberLogMapper;
private ExecutionAssigneeLogMapper executionAssigneeLogMapper;
@Mock
private UserObjectRoleMapper userObjectRoleMapper;
@Mock
@@ -72,51 +72,51 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
private static final Long OWNER_ID = 3001L;
private static final Long USER_ID = 3002L;
// -------------------- getExecutionMemberList --------------------
// -------------------- getExecutionAssigneeList --------------------
@Test
void getExecutionMemberList_shouldReturnOnlyActiveSegments() {
void getExecutionAssigneeList_shouldReturnOnlyActiveSegments() {
when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject());
when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID))
.thenReturn(createExecution());
when(executionMemberMapper.selectActiveListByExecutionId(EXECUTION_ID))
when(executionAssigneeMapper.selectActiveListByExecutionId(EXECUTION_ID))
.thenReturn(List.of(createMember(7001L, USER_ID, null)));
List<ExecutionMemberRespVO> respVOList = projectExecutionMemberService
.getExecutionMemberList(PROJECT_ID, EXECUTION_ID);
List<ExecutionAssigneeRespVO> respVOList = projectExecutionAssigneeService
.getExecutionAssigneeList(PROJECT_ID, EXECUTION_ID);
assertEquals(1, respVOList.size());
assertEquals(USER_ID, respVOList.get(0).getUserId());
assertNull(respVOList.get(0).getRemovedAt());
verify(executionMemberMapper, never()).selectListByExecutionId(any());
verify(executionAssigneeMapper, never()).selectListByExecutionId(any());
}
// -------------------- createExecutionMember --------------------
// -------------------- createExecutionAssignee --------------------
@Test
void createExecutionMember_byUserNeverJoined_shouldInsertActiveAndWriteJoinLog() {
void createExecutionAssignee_byUserNeverJoined_shouldInsertActiveAndWriteJoinLog() {
stubEditableContext();
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
.thenReturn(createProjectMember());
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
when(executionMemberMapper.insert(any(ExecutionMemberDO.class))).thenAnswer(inv -> {
ExecutionMemberDO m = inv.getArgument(0);
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
when(executionAssigneeMapper.insert(any(ExecutionAssigneeDO.class))).thenAnswer(inv -> {
ExecutionAssigneeDO m = inv.getArgument(0);
m.setId(7001L);
return 1;
});
ExecutionMemberSaveReqVO reqVO = new ExecutionMemberSaveReqVO();
ExecutionAssigneeSaveReqVO reqVO = new ExecutionAssigneeSaveReqVO();
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);
ArgumentCaptor<ExecutionMemberDO> activeCaptor = ArgumentCaptor.forClass(ExecutionMemberDO.class);
verify(executionMemberMapper).insert(activeCaptor.capture());
ArgumentCaptor<ExecutionAssigneeDO> activeCaptor = ArgumentCaptor.forClass(ExecutionAssigneeDO.class);
verify(executionAssigneeMapper).insert(activeCaptor.capture());
assertNull(activeCaptor.getValue().getRemovedAt());
assertNull(activeCaptor.getValue().getRemovedReason());
ArgumentCaptor<ExecutionMemberLogDO> logCaptor = ArgumentCaptor.forClass(ExecutionMemberLogDO.class);
verify(executionMemberLogMapper).insert(logCaptor.capture());
ArgumentCaptor<ExecutionAssigneeLogDO> logCaptor = ArgumentCaptor.forClass(ExecutionAssigneeLogDO.class);
verify(executionAssigneeLogMapper).insert(logCaptor.capture());
assertEquals("join", logCaptor.getValue().getActionType());
assertEquals(USER_ID, logCaptor.getValue().getUserId());
assertNull(logCaptor.getValue().getReason());
@@ -125,49 +125,49 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
}
@Test
void createExecutionMember_whenInactiveSegmentExists_shouldInsertNewSegmentAndPreserveHistoricalReason() {
void createExecutionAssignee_whenInactiveSegmentExists_shouldInsertNewSegmentAndPreserveHistoricalReason() {
// B 模型用户曾失效重新加入新插一段旧段不动
stubEditableContext();
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
.thenReturn(createProjectMember());
// 当前没有活跃段旧段已失效通过 active-only 校验
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
when(executionMemberMapper.insert(any(ExecutionMemberDO.class))).thenAnswer(inv -> {
ExecutionMemberDO m = inv.getArgument(0);
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
when(executionAssigneeMapper.insert(any(ExecutionAssigneeDO.class))).thenAnswer(inv -> {
ExecutionAssigneeDO m = inv.getArgument(0);
m.setId(7002L);
return 1;
});
ExecutionMemberSaveReqVO reqVO = new ExecutionMemberSaveReqVO();
ExecutionAssigneeSaveReqVO reqVO = new ExecutionAssigneeSaveReqVO();
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);
// 不应触碰旧段updateById 不应被调用
verify(executionMemberMapper, never()).updateById(any(ExecutionMemberDO.class));
verify(executionMemberMapper).insert(any(ExecutionMemberDO.class));
verify(executionAssigneeMapper, never()).updateById(any(ExecutionAssigneeDO.class));
verify(executionAssigneeMapper).insert(any(ExecutionAssigneeDO.class));
}
@Test
void createExecutionMember_whenAlreadyActive_shouldThrowAlreadyExists() {
void createExecutionAssignee_whenAlreadyActive_shouldThrowAlreadyExists() {
stubEditableContext();
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
.thenReturn(createProjectMember());
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
.thenReturn(createMember(7001L, USER_ID, null));
ExecutionMemberSaveReqVO reqVO = new ExecutionMemberSaveReqVO();
ExecutionAssigneeSaveReqVO reqVO = new ExecutionAssigneeSaveReqVO();
reqVO.setUserId(USER_ID);
ServiceException ex = assertThrows(ServiceException.class,
() -> projectExecutionMemberService.createExecutionMember(PROJECT_ID, EXECUTION_ID, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_ALREADY_EXISTS.getCode(), ex.getCode());
verify(executionMemberMapper, never()).insert(any(ExecutionMemberDO.class));
() -> projectExecutionAssigneeService.createExecutionAssignee(PROJECT_ID, EXECUTION_ID, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_ALREADY_EXISTS.getCode(), ex.getCode());
verify(executionAssigneeMapper, never()).insert(any(ExecutionAssigneeDO.class));
}
@Test
void createExecutionMember_whenExecutionPaused_shouldThrowNotAllowEdit() {
void createExecutionAssignee_whenExecutionPaused_shouldThrowNotAllowEdit() {
ProjectExecutionDO execution = createExecution();
execution.setStatusCode("paused");
when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject());
@@ -177,32 +177,32 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "paused"))
.thenReturn(createStatus("execution", "paused", false));
ExecutionMemberSaveReqVO reqVO = new ExecutionMemberSaveReqVO();
ExecutionAssigneeSaveReqVO reqVO = new ExecutionAssigneeSaveReqVO();
reqVO.setUserId(USER_ID);
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());
}
// -------------------- inactiveExecutionMember --------------------
// -------------------- inactiveExecutionAssignee --------------------
@Test
void inactiveExecutionMember_shouldSetRemovedAtAndWriteInactiveLog() {
ExecutionMemberDO member = createMember(7001L, USER_ID, null);
void inactiveExecutionAssignee_shouldSetRemovedAtAndWriteInactiveLog() {
ExecutionAssigneeDO member = createMember(7001L, USER_ID, null);
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("阶段性退出");
projectExecutionMemberService.inactiveExecutionMember(PROJECT_ID, EXECUTION_ID, 7001L, reqVO);
projectExecutionAssigneeService.inactiveExecutionAssignee(PROJECT_ID, EXECUTION_ID, 7001L, reqVO);
assertNotNull(member.getRemovedAt());
assertEquals("阶段性退出", member.getRemovedReason());
verify(executionMemberMapper).updateById(member);
ArgumentCaptor<ExecutionMemberLogDO> logCaptor = ArgumentCaptor.forClass(ExecutionMemberLogDO.class);
verify(executionMemberLogMapper).insert(logCaptor.capture());
verify(executionAssigneeMapper).updateById(member);
ArgumentCaptor<ExecutionAssigneeLogDO> logCaptor = ArgumentCaptor.forClass(ExecutionAssigneeLogDO.class);
verify(executionAssigneeLogMapper).insert(logCaptor.capture());
assertEquals("inactive", logCaptor.getValue().getActionType());
assertEquals(USER_ID, logCaptor.getValue().getUserId());
assertEquals("阶段性退出", logCaptor.getValue().getReason());
@@ -210,22 +210,22 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
}
@Test
void inactiveExecutionMember_whenAlreadyInactive_shouldThrowNotActive() {
ExecutionMemberDO inactiveMember = createMember(7001L, USER_ID, LocalDateTime.now().minusDays(1));
void inactiveExecutionAssignee_whenAlreadyInactive_shouldThrowNotActive() {
ExecutionAssigneeDO inactiveMember = createMember(7001L, USER_ID, LocalDateTime.now().minusDays(1));
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("再次失效");
ServiceException ex = assertThrows(ServiceException.class,
() -> projectExecutionMemberService.inactiveExecutionMember(PROJECT_ID, EXECUTION_ID, 7001L, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_NOT_ACTIVE.getCode(), ex.getCode());
verify(executionMemberMapper, never()).updateById(any(ExecutionMemberDO.class));
() -> projectExecutionAssigneeService.inactiveExecutionAssignee(PROJECT_ID, EXECUTION_ID, 7001L, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_NOT_ACTIVE.getCode(), ex.getCode());
verify(executionAssigneeMapper, never()).updateById(any(ExecutionAssigneeDO.class));
}
@Test
void inactiveExecutionMember_whenExecutionPaused_shouldThrowNotAllowEdit() {
void inactiveExecutionAssignee_whenExecutionPaused_shouldThrowNotAllowEdit() {
ProjectExecutionDO execution = createExecution();
execution.setStatusCode("paused");
when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject());
@@ -235,33 +235,33 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "paused"))
.thenReturn(createStatus("execution", "paused", false));
ExecutionMemberInactiveReqVO reqVO = new ExecutionMemberInactiveReqVO();
ExecutionAssigneeInactiveReqVO reqVO = new ExecutionAssigneeInactiveReqVO();
reqVO.setReason("暂停态退出");
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());
}
// -------------------- getExecutionMemberLogPage --------------------
// -------------------- getExecutionAssigneeLogPage --------------------
@Test
void getExecutionMemberLogPage_shouldDelegateToMapperAndConvertVO() {
void getExecutionAssigneeLogPage_shouldDelegateToMapperAndConvertVO() {
when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject());
when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID))
.thenReturn(createExecution());
ExecutionMemberLogDO logRow = new ExecutionMemberLogDO();
ExecutionAssigneeLogDO logRow = new ExecutionAssigneeLogDO();
logRow.setId(12001L);
logRow.setExecutionId(EXECUTION_ID);
logRow.setUserId(USER_ID);
logRow.setActionType("join");
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));
ExecutionMemberLogPageReqVO reqVO = new ExecutionMemberLogPageReqVO();
PageResult<ExecutionMemberLogRespVO> page = projectExecutionMemberService
.getExecutionMemberLogPage(PROJECT_ID, EXECUTION_ID, reqVO);
ExecutionAssigneeLogPageReqVO reqVO = new ExecutionAssigneeLogPageReqVO();
PageResult<ExecutionAssigneeLogRespVO> page = projectExecutionAssigneeService
.getExecutionAssigneeLogPage(PROJECT_ID, EXECUTION_ID, reqVO);
assertEquals(1L, page.getTotal());
assertEquals("join", page.getList().get(0).getActionType());
@@ -269,11 +269,11 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
}
@Test
void getExecutionMemberLogPage_whenSnapshotExists_shouldUseCurrentNicknamesByAdminUserApi() {
void getExecutionAssigneeLogPage_whenSnapshotExists_shouldUseCurrentNicknamesByAdminUserApi() {
when(projectMapper.selectById(PROJECT_ID)).thenReturn(createEditableProject());
when(projectExecutionMapper.selectByProjectIdAndId(PROJECT_ID, EXECUTION_ID))
.thenReturn(createExecution());
ExecutionMemberLogDO logRow = new ExecutionMemberLogDO();
ExecutionAssigneeLogDO logRow = new ExecutionAssigneeLogDO();
logRow.setId(12002L);
logRow.setExecutionId(EXECUTION_ID);
logRow.setUserId(USER_ID);
@@ -282,29 +282,29 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
logRow.setOperatorUserId(OWNER_ID);
logRow.setOperatorNicknameSnapshot("旧操作人名");
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));
when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(
USER_ID, createUser("陈道飞"),
OWNER_ID, createUser("灿能源码")));
ExecutionMemberLogPageReqVO reqVO = new ExecutionMemberLogPageReqVO();
PageResult<ExecutionMemberLogRespVO> page = projectExecutionMemberService
.getExecutionMemberLogPage(PROJECT_ID, EXECUTION_ID, reqVO);
ExecutionAssigneeLogPageReqVO reqVO = new ExecutionAssigneeLogPageReqVO();
PageResult<ExecutionAssigneeLogRespVO> page = projectExecutionAssigneeService
.getExecutionAssigneeLogPage(PROJECT_ID, EXECUTION_ID, reqVO);
assertEquals("陈道飞", page.getList().get(0).getUserNicknameSnapshot());
assertEquals("灿能源码", page.getList().get(0).getOperatorNicknameSnapshot());
verify(adminUserApi).getUserMap(anyCollection());
}
// -------------------- writeMemberLog 直接调用 --------------------
// -------------------- writeAssigneeLog 直接调用 --------------------
@Test
void writeMemberLog_shouldInsertWithProvidedFields() {
projectExecutionMemberService.writeMemberLog(EXECUTION_ID, USER_ID, "owner_transfer_in", "test reason");
void writeAssigneeLog_shouldInsertWithProvidedFields() {
projectExecutionAssigneeService.writeAssigneeLog(EXECUTION_ID, USER_ID, "owner_transfer_in", "test reason");
ArgumentCaptor<ExecutionMemberLogDO> captor = ArgumentCaptor.forClass(ExecutionMemberLogDO.class);
verify(executionMemberLogMapper).insert(captor.capture());
ArgumentCaptor<ExecutionAssigneeLogDO> captor = ArgumentCaptor.forClass(ExecutionAssigneeLogDO.class);
verify(executionAssigneeLogMapper).insert(captor.capture());
assertEquals(EXECUTION_ID, captor.getValue().getExecutionId());
assertEquals(USER_ID, captor.getValue().getUserId());
assertEquals("owner_transfer_in", captor.getValue().getActionType());
@@ -346,8 +346,8 @@ class ProjectExecutionMemberServiceImplTest extends BaseMockitoUnitTest {
return execution;
}
private ExecutionMemberDO createMember(Long id, Long userId, LocalDateTime removedAt) {
ExecutionMemberDO member = new ExecutionMemberDO();
private ExecutionAssigneeDO createMember(Long id, Long userId, LocalDateTime removedAt) {
ExecutionAssigneeDO member = new ExecutionAssigneeDO();
member.setId(id);
member.setExecutionId(EXECUTION_ID);
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.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.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.dal.dataobject.audit.BizAuditLogDO;
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.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.ProjectExecutionStatusLogDO;
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.member.UserObjectRoleMapper;
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.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.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
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.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
@@ -34,7 +39,11 @@ import org.mockito.Mock;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
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.assertThrows;
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.lenient;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -56,7 +68,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
@Mock
private ProjectExecutionMapper projectExecutionMapper;
@Mock
private ExecutionMemberMapper executionMemberMapper;
private ExecutionAssigneeMapper executionAssigneeMapper;
@Mock
private UserObjectRoleMapper userObjectRoleMapper;
@Mock
@@ -68,19 +80,39 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
@Mock
private ProjectExecutionStatusLogMapper projectExecutionStatusLogMapper;
@Mock
private ProjectTaskMapper projectTaskMapper;
@Mock
private DictDataApi dictDataApi;
@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
void createExecution_shouldInsertPendingExecutionAndMembers() {
Long projectId = 2001L;
ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO();
ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
reqVO.setExecutionName("后端接口联调");
reqVO.setExecutionType("feature");
reqVO.setOwnerId(3001L);
reqVO.setProjectRequirementId(null);
reqVO.setMemberUserIds(List.of(3002L, 3003L));
reqVO.setAssigneeUserIds(List.of(3002L, 3003L));
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
@@ -113,14 +145,14 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
assertNull(executionCaptor.getValue().getProjectRequirementId());
assertNull(executionCaptor.getValue().getActualStartDate());
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));
}
@Test
void createExecution_whenRequirementIdNotNull_shouldThrowNotReady() {
Long projectId = 2001L;
ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO();
ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
reqVO.setExecutionName("后端接口联调");
reqVO.setOwnerId(3001L);
reqVO.setProjectRequirementId(9001L);
@@ -138,11 +170,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
@Test
void createExecution_whenExecutionTypeInvalid_shouldThrow() {
Long projectId = 2001L;
ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO();
ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
reqVO.setExecutionName("后端接口联调");
reqVO.setExecutionType("unknown");
reqVO.setOwnerId(3001L);
reqVO.setMemberUserIds(List.of(3002L));
reqVO.setAssigneeUserIds(List.of(3002L));
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
@@ -162,11 +194,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
@Test
void createExecution_whenMemberListEmpty_shouldThrowRequired() {
Long projectId = 2001L;
ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO();
ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
reqVO.setExecutionName("后端接口联调");
reqVO.setExecutionType("feature");
reqVO.setOwnerId(3001L);
reqVO.setMemberUserIds(List.of());
reqVO.setAssigneeUserIds(List.of());
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
@@ -180,7 +212,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
ServiceException ex = assertThrows(ServiceException.class,
() -> projectExecutionService.createExecution(projectId, reqVO));
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_MEMBER_REQUIRED.getCode(), ex.getCode());
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_REQUIRED.getCode(), ex.getCode());
}
@Test
@@ -188,11 +220,10 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
Long projectId = 2001L;
Long executionId = 5001L;
ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO();
ProjectExecutionUpdateReqVO reqVO = new ProjectExecutionUpdateReqVO();
reqVO.setId(executionId);
reqVO.setExecutionName("接口联调-修订");
reqVO.setExecutionType("feature");
reqVO.setOwnerId(3001L);
reqVO.setProjectRequirementId(null);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
@@ -237,10 +268,10 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
verify(projectExecutionMapper).updateById(execution);
verify(bizAuditLogMapper).insert(any(BizAuditLogDO.class));
// 双向写入成员变更历史:原 owner 转出 + 新 owner 转入
verify(projectExecutionMemberService).writeMemberLog(executionId, 3001L,
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_OUT, "负责人调整");
verify(projectExecutionMemberService).writeMemberLog(executionId, 3002L,
ObjectActivityConstants.EXECUTION_MEMBER_LOG_ACTION_OWNER_TRANSFER_IN, "负责人调整");
verify(projectExecutionAssigneeService).writeAssigneeLog(executionId, 3001L,
ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_OUT, "负责人调整");
verify(projectExecutionAssigneeService).writeAssigneeLog(executionId, 3002L,
ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN, "负责人调整");
}
@Test
@@ -338,10 +369,9 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
Long executionId = 5001L;
ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
execution.setStatusCode("paused");
ProjectExecutionSaveReqVO reqVO = new ProjectExecutionSaveReqVO();
ProjectExecutionUpdateReqVO reqVO = new ProjectExecutionUpdateReqVO();
reqVO.setId(executionId);
reqVO.setExecutionName("接口联调-修订");
reqVO.setOwnerId(3001L);
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
@@ -378,6 +408,104 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
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
void getExecutionPage_shouldDelegateMapper() {
Long projectId = 2001L;
@@ -385,7 +513,7 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
reqVO.setPageNo(1);
reqVO.setPageSize(20);
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));
PageResult<ProjectExecutionDO> result = projectExecutionService.getExecutionPage(projectId, reqVO);
@@ -428,6 +556,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
return transition;
}
private ProjectExecutionStatusViewService.ProjectExecutionLifecycleView createLifecycleView() {
return new ProjectExecutionStatusViewService.ProjectExecutionLifecycleView(
"待处理", false, true, Collections.emptyList());
}
private UserObjectRoleDO createProjectMember(Long projectId, Long userId, Long roleId) {
UserObjectRoleDO member = new UserObjectRoleDO();
member.setId(projectId + userId);

View File

@@ -1,6 +1,7 @@
package com.njcn.rdms.module.project.service.project.execution;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
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.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
@@ -29,34 +32,76 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
@Test
void getLifecycle_shouldReturnExecutionStatusMetadataAndActions() {
ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
statusModel.setObjectType("execution");
statusModel.setStatusCode("active");
statusModel.setStatusName("进行中");
statusModel.setTerminalFlag(false);
statusModel.setAllowEdit(true);
ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
transition.setActionCode("pause");
transition.setActionName("暂停");
transition.setNeedReason(true);
void getLifecycle_whenLoginUserIsOwner_shouldReturnOwnerOnlyAction() {
Long ownerId = 3001L;
ObjectStatusModelDO statusModel = createStatusModel();
ObjectStatusTransitionDO pause = createTransition("pause", "暂停", true);
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "active"))
.thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("execution", "active"))
.thenReturn(List.of(transition));
.thenReturn(List.of(pause));
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
projectExecutionStatusViewService.getLifecycle("active");
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
assertEquals("进行中", result.statusName());
assertFalse(result.terminal());
assertTrue(result.allowEdit());
assertEquals(1, result.availableActions().size());
assertEquals("pause", result.availableActions().get(0).getActionCode());
assertEquals("暂停", result.availableActions().get(0).getActionName());
assertTrue(result.availableActions().get(0).getNeedReason());
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
projectExecutionStatusViewService.getLifecycle("active", ownerId);
assertEquals("进行中", result.statusName());
assertFalse(result.terminal());
assertTrue(result.allowEdit());
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
@@ -65,8 +110,26 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
.thenReturn(null);
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());
}
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.ProjectTaskStatusActionReqVO;
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.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.task.ProjectTaskDO;
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.mysql.audit.BizAuditLogMapper;
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.task.ProjectTaskMapper;
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.ObjectStatusTransitionMapper;
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.task.assignee.TaskAssigneeService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
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.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -51,7 +56,7 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
@Mock
private ProjectExecutionMapper projectExecutionMapper;
@Mock
private ExecutionMemberMapper executionMemberMapper;
private ExecutionAssigneeMapper executionAssigneeMapper;
@Mock
private ProjectTaskMapper projectTaskMapper;
@Mock
@@ -68,6 +73,23 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
private ProjectService projectService;
@Mock
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
void createTask_shouldInsertPendingTaskAndAutoStartProject() {
@@ -85,8 +107,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createStatus("execution", "pending", true));
when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task"))
.thenReturn(createStatus("task", "pending", true));
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionMember(executionId, 3002L));
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);
@@ -108,6 +130,45 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
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
void createSubTask_whenOwnerBlank_shouldInheritParentOwner() {
Long projectId = 2001L;
@@ -128,8 +189,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createStatus("execution", "pending", true));
when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task"))
.thenReturn(createStatus("task", "pending", true));
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionMember(executionId, 3002L));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionAssignee(executionId, 3002L));
when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(invocation -> {
ProjectTaskDO task = invocation.getArgument(0);
task.setId(9002L);
@@ -145,7 +206,7 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
}
@Test
void createTask_whenOwnerNotActiveExecutionMember_shouldThrow() {
void createTask_whenOwnerNotActiveExecutionAssignee_shouldThrow() {
Long projectId = 2001L;
Long executionId = 5001L;
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
@@ -158,7 +219,7 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createStatus("project", "pending", true));
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
.thenReturn(createStatus("execution", "pending", true));
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3999L)).thenReturn(null);
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3999L)).thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
() -> projectTaskService.createTask(projectId, executionId, reqVO));
@@ -205,6 +266,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO);
verify(projectObjectAuthorizationService).checkOwnerOrProjectPermission(projectId, 3002L,
"project:task:status");
verify(projectTaskMapper).updateStatusByIdAndStatus(taskId, "pending", "active", null);
ArgumentCaptor<ProjectTaskStatusLogDO> statusCaptor = ArgumentCaptor.forClass(ProjectTaskStatusLogDO.class);
verify(projectTaskStatusLogMapper).insert(statusCaptor.capture());
@@ -276,8 +339,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createStatus("execution", "pending", true));
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
.thenReturn(parentTask);
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionMember(executionId, 3002L));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionAssignee(executionId, 3002L));
when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0);
ServiceException ex = assertThrows(ServiceException.class,
@@ -306,8 +369,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createStatus("execution", "pending", true));
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
.thenReturn(parentTask);
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionMember(executionId, 3002L));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionAssignee(executionId, 3002L));
when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0);
when(taskWorklogMapper.existsByTaskId(parentTaskId)).thenReturn(true);
@@ -340,8 +403,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createStatus("task", "pending", true));
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
.thenReturn(parentTask);
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionMember(executionId, 3002L));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionAssignee(executionId, 3002L));
// 已有 1 个子任务 → 跳过叶子转父校验
when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(1);
// 插入新任务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);
grandparent.setProgressRate(new BigDecimal("50.00"));
ProjectTaskSaveReqVO reqVO = createTaskReqVO();
reqVO.setProgressRate(new BigDecimal("80.00"));
reqVO.setParentTaskId(parentTaskId);
reqVO.setOwnerId(3002L);
@@ -394,8 +456,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
.thenReturn(createStatus("task", "pending", true));
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
.thenReturn(parentTask);
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionMember(executionId, 3002L));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionAssignee(executionId, 3002L));
when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0);
when(taskWorklogMapper.existsByTaskId(parentTaskId)).thenReturn(false);
when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(inv -> {
@@ -423,117 +485,6 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
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
void updateTask_movedToNewParent_shouldValidateAndRecalcBothChains() {
Long projectId = 2001L;
@@ -552,7 +503,6 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
reqVO.setId(taskId);
reqVO.setParentTaskId(newParentId);
reqVO.setOwnerId(3002L);
reqVO.setProgressRate(new BigDecimal("70.00"));
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
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, newParentId))
.thenReturn(newParent);
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionMember(executionId, 3002L));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
.thenReturn(createExecutionAssignee(executionId, 3002L));
// 校验"叶子转父":新父 8002 当前是叶子,进度=0无工时 → 通过
when(projectTaskMapper.countChildrenByParentTaskId(newParentId)).thenReturn(0);
when(taskWorklogMapper.existsByTaskId(newParentId)).thenReturn(false);
@@ -592,7 +542,6 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
private ProjectTaskSaveReqVO createTaskReqVO() {
ProjectTaskSaveReqVO reqVO = new ProjectTaskSaveReqVO();
reqVO.setTaskTitle("接口联调任务");
reqVO.setProgressRate(BigDecimal.ZERO);
reqVO.setTaskDesc("完成接口联调");
return reqVO;
}
@@ -626,8 +575,8 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
return task;
}
private ExecutionMemberDO createExecutionMember(Long executionId, Long userId) {
ExecutionMemberDO member = new ExecutionMemberDO();
private ExecutionAssigneeDO createExecutionAssignee(Long executionId, Long userId) {
ExecutionAssigneeDO member = new ExecutionAssigneeDO();
member.setExecutionId(executionId);
member.setUserId(userId);
return member;

View File

@@ -1,6 +1,7 @@
package com.njcn.rdms.module.project.service.project.task;
import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
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.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
@@ -29,29 +32,73 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
@Test
void getLifecycle_shouldReturnStatusModelAndAvailableActions() {
ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
statusModel.setObjectType("task");
statusModel.setStatusCode("active");
statusModel.setStatusName("进行中");
statusModel.setTerminalFlag(false);
statusModel.setAllowEdit(true);
void getLifecycle_whenLoginUserIsOwner_shouldReturnOwnerOnlyActions() {
Long ownerId = 3001L;
ObjectStatusModelDO statusModel = createStatusModel();
ObjectStatusTransitionDO complete = createTransition("complete", "完成");
ObjectStatusTransitionDO block = createTransition("block", "阻塞");
ObjectStatusTransitionDO pause = createTransition("pause", "暂停");
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "active")).thenReturn(statusModel);
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("task", "active"))
.thenReturn(List.of(complete, block));
.thenReturn(List.of(complete, pause));
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
projectTaskStatusViewService.getLifecycle("active");
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
assertEquals("进行中", view.statusName());
assertFalse(view.terminal());
assertTrue(view.allowEdit());
assertEquals(2, view.availableActions().size());
assertEquals("complete", view.availableActions().get(0).getActionCode());
assertEquals("完成", view.availableActions().get(0).getActionName());
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
projectTaskStatusViewService.getLifecycle("active", ownerId);
assertEquals("进行中", view.statusName());
assertFalse(view.terminal());
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
@@ -59,11 +106,21 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "missing")).thenReturn(null);
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());
}
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) {
ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
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.dal.dataobject.audit.BizAuditLogDO;
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.task.ProjectTaskDO;
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.mysql.audit.BizAuditLogMapper;
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.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeLogMapper;
@@ -60,7 +60,7 @@ class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest {
@Mock
private ProjectTaskMapper projectTaskMapper;
@Mock
private ExecutionMemberMapper executionMemberMapper;
private ExecutionAssigneeMapper executionAssigneeMapper;
@Mock
private TaskAssigneeMapper taskAssigneeMapper;
@Mock
@@ -88,8 +88,8 @@ class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest {
stubEditableContext();
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
.thenReturn(createTask(OWNER_ID));
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
.thenReturn(createExecutionMember(EXECUTION_ID, USER_ID));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
.thenReturn(createExecutionAssignee(EXECUTION_ID, USER_ID));
when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, USER_ID)).thenReturn(null);
when(taskAssigneeMapper.insert(any(TaskAssigneeDO.class))).thenAnswer(invocation -> {
TaskAssigneeDO assignee = invocation.getArgument(0);
@@ -131,14 +131,14 @@ class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest {
}
@Test
void createAssignee_whenUserNotActiveExecutionMember_shouldThrow() {
void createAssignee_whenUserNotActiveExecutionAssignee_shouldThrow() {
TaskAssigneeSaveReqVO reqVO = new TaskAssigneeSaveReqVO();
reqVO.setUserId(USER_ID);
stubEditableContext();
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
.thenReturn(createTask(OWNER_ID));
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
.thenReturn(null);
ServiceException ex = assertThrows(ServiceException.class,
@@ -155,8 +155,8 @@ class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest {
stubEditableContext();
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(PROJECT_ID, EXECUTION_ID, TASK_ID))
.thenReturn(createTask(OWNER_ID));
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
.thenReturn(createExecutionMember(EXECUTION_ID, USER_ID));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
.thenReturn(createExecutionAssignee(EXECUTION_ID, USER_ID));
when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, USER_ID))
.thenReturn(createActiveAssignee(7001L, TASK_ID, USER_ID));
@@ -308,10 +308,10 @@ class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest {
void initializeAssignees_shouldDedupAndWriteAll() {
Long userA = 4001L;
Long userB = 4002L;
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, userA))
.thenReturn(createExecutionMember(EXECUTION_ID, userA));
when(executionMemberMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, userB))
.thenReturn(createExecutionMember(EXECUTION_ID, userB));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, userA))
.thenReturn(createExecutionAssignee(EXECUTION_ID, userA));
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, userB))
.thenReturn(createExecutionAssignee(EXECUTION_ID, userB));
when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, userA)).thenReturn(null);
when(taskAssigneeMapper.selectActiveByTaskIdAndUserId(TASK_ID, userB)).thenReturn(null);
when(taskAssigneeMapper.insert(any(TaskAssigneeDO.class))).thenReturn(1);
@@ -370,8 +370,8 @@ class TaskAssigneeServiceImplTest extends BaseMockitoUnitTest {
return task;
}
private ExecutionMemberDO createExecutionMember(Long executionId, Long userId) {
ExecutionMemberDO member = new ExecutionMemberDO();
private ExecutionAssigneeDO createExecutionAssignee(Long executionId, Long userId) {
ExecutionAssigneeDO member = new ExecutionAssigneeDO();
member.setExecutionId(executionId);
member.setUserId(userId);
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")
private String userName;
@Schema(description = "用户昵称", example = "灿能")
private String nickname;
@Schema(description = "用户头像", example = "https://www.iocoder.cn/xx.png")
private String avatar;
@Schema(description = "所属公司", example = "灿能")
private String company;

View File

@@ -46,11 +46,12 @@ public class FileController {
@Operation(summary = "上传文件", description = "模式一:后端上传文件")
@Parameter(name = "file", description = "文件附件", required = true,
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();
byte[] content = IoUtil.readBytes(file.getInputStream());
return success(fileService.createFile(content, file.getOriginalFilename(),
uploadReqVO.getDirectory(), file.getContentType()));
FileDO fileDO = fileService.createFile(content, file.getOriginalFilename(),
uploadReqVO.getDirectory(), file.getContentType());
return success(new FileUploadRespVO(String.valueOf(fileDO.getId()), fileDO.getUrl()));
}
@GetMapping("/presigned-url")
@@ -97,6 +98,21 @@ public class FileController {
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/**")
@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.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.dal.dataobject.file.FileDO;
import com.njcn.rdms.module.system.controller.app.file.vo.AppFileUploadReqVO;
import com.njcn.rdms.module.system.service.file.FileService;
import io.swagger.v3.oas.annotations.Operation;
@@ -39,8 +40,9 @@ public class AppFileController {
public CommonResult<String> uploadFile(AppFileUploadReqVO uploadReqVO) throws Exception {
MultipartFile file = uploadReqVO.getFile();
byte[] content = IoUtil.readBytes(file.getInputStream());
return success(fileService.createFile(content, file.getOriginalFilename(),
uploadReqVO.getDirectory(), file.getContentType()));
FileDO fileDO = fileService.createFile(content, file.getOriginalFilename(),
uploadReqVO.getDirectory(), file.getContentType());
return success(fileDO.getUrl());
}
@GetMapping("/presigned-url")

View File

@@ -57,6 +57,8 @@ public interface AuthConvert {
return AuthUserInfoRespVO.builder()
.userId(String.valueOf(user.getId()))
.userName(user.getUsername())
.nickname(user.getNickname())
.avatar(user.getAvatar())
.company(user.getCompany())
.roles(sortDistinctStrings(convertList(roleList, RoleDO::getCode)))
.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 org.apache.ibatis.annotations.Mapper;
import java.util.Objects;
import static com.njcn.rdms.framework.common.util.http.HttpUtils.removeUrlQuery;
/**
* 文件操作 Mapper
*
@@ -23,4 +27,18 @@ public interface FileMapper extends BaseMapperX<FileDO> {
.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);
/**
* 保存文件,并返回文件的访问路径
* 保存文件,并返回文件记录
*
* @param content 文件内容
* @param name 文件名称,允许空
* @param directory 目录,允许空
* @param type 文件的 MIME 类型,允许空
* @return 文件路径
* @return 文件记录
*/
String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content,
FileDO createFile(@NotEmpty(message = "文件内容不能为空") byte[] content,
String name, String directory, String type);
/**
@@ -63,6 +63,14 @@ public interface FileService {
Long createFile(FileCreateReqVO createReqVO);
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
@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 为空的情况
if (StrUtil.isEmpty(type)) {
type = FileTypeUtils.getMineType(content, name);
@@ -86,10 +86,11 @@ public class FileServiceImpl implements FileService {
String url = client.upload(content, path, type);
// 3. 保存到数据库
fileMapper.insert(new FileDO().setConfigId(client.getId())
FileDO file = new FileDO().setConfigId(client.getId())
.setName(name).setPath(path).setUrl(url)
.setType(type).setSize((long) content.length));
return url;
.setType(type).setSize((long) content.length);
fileMapper.insert(file);
return file;
}
@VisibleForTesting
@@ -157,6 +158,14 @@ public class FileServiceImpl implements FileService {
return validateFileExists(id);
}
@Override
public FileDO getFileByUrl(String url) {
if (StrUtil.isBlank(url)) {
return null;
}
return fileMapper.selectByUrl(url);
}
@Override
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));
}
}