feat(system): 扩展用户部门权限功能
- 在 AdminUserService 中新增 listEnabledUserIdsByDeptIds 方法获取指定部门集合下启用且未离职的用户 ID 集合 - 在 DeptService 中新增 listDescendantDeptIds 方法获得指定部门集合及其所有子孙部门的 ID 集合 - 在 DeptService 中新增 listCodesByIds 方法按 id 集合批量查询部门 code 集合 - 在 OrgLeaderRelationService 中新增 listEffectiveDeptIdsByUserId 方法查询指定用户当前生效的负责人关系所对应的 dept_id 集合 - 在 PermissionApi 中新增 isSuperAdmin 接口判断用户是否超管 - 在 ObjectPermissionApi 中新增 getObjectRolePermissionDetailMerged 接口按 roleId 列表聚合菜单 + 权限码 - 扩展 ProductContextRoleRespVO 添加多角色场景的附加角色名称列表 - 扩展 ProductCreateWithTeamReqVO 支持创建时添加关心人用户 ID 列表 - 优化 ProductMemberServiceImpl 支持同一用户多角色显示,区分主角色和附加角色 - 新增 MEMBER_ACTION_REACTIVATE 复活动作类型用于处理 INACTIVE 成员行重新激活场景 - 在 ObjectStatusModelDO 中新增 progressExcludedFlag 字段控制是否参与上层进度统计 - 更新 AGENTS.md 和 CLAUDE.md 添加 Git 操作纪律规范 - 在 rdms-project-api 中新增多个错误码常量支持角色转移和内置角色配置验证
This commit is contained in:
@@ -36,7 +36,9 @@
|
|||||||
"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 '-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 '-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 -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)"
|
"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)",
|
||||||
|
"Skill(code-review:code-review)",
|
||||||
|
"Bash(Test-Path *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
AGENTS.md
22
AGENTS.md
@@ -227,6 +227,28 @@ rdms-xxx
|
|||||||
8. 修改跨模块使用的 API 时,需要同时更新提供方实现和对应的 `rdms-system-api` 或对应 `rdms-xxx-api` 契约。
|
8. 修改跨模块使用的 API 时,需要同时更新提供方实现和对应的 `rdms-system-api` 或对应 `rdms-xxx-api` 契约。
|
||||||
9. 除非用户明确要求,否则不执行任何编译、构建、测试、打包或其他会实际运行项目的命令,包括但不限于 `mvn`、启动命令和脚本。
|
9. 除非用户明确要求,否则不执行任何编译、构建、测试、打包或其他会实际运行项目的命令,包括但不限于 `mvn`、启动命令和脚本。
|
||||||
|
|
||||||
|
## Git 操作纪律
|
||||||
|
|
||||||
|
### 默认不引导分支管理(**首要**)
|
||||||
|
|
||||||
|
用户在本仓库长期固定在 `main` 上工作。开发流程中:
|
||||||
|
|
||||||
|
- **不要主动建议建 feature 分支**(`git checkout -b feat/xxx`、`git switch -c ...`)。
|
||||||
|
- **不要把"先切到 xxx 分支再操作"作为方案前置步骤**。
|
||||||
|
- 一切围绕 `main` 展开:直接在 `main` 上改、`main` 上提交、`main` 上推。
|
||||||
|
- 例外:用户明确要求建分支、或涉及多人协作 / PR 评审 / 大规模重构(此时仍只是"提一句作为可选",不强推)。
|
||||||
|
|
||||||
|
理由:曾出现一次"误把文件名当分支名建出怪分支 `用户行动清单.md`,后续 `git branch -D` 删分支时险些丢用户 3 天工作"的事故。事故根因就是分支管理本身——少走分支 = 少埋雷。
|
||||||
|
|
||||||
|
### 破坏性 git 命令必须先核实
|
||||||
|
|
||||||
|
任何**会丢工作**的 git 命令——`branch -D`、`reset --hard`、`clean -fd`、`push --force` / `--force-with-lease`、`checkout` / `switch` 带未提交改动、`rebase` 在已推送分支上、直接动 `.git/` 内部文件(`refs/`、`HEAD`、`packed-refs`)——**给出建议前必须先核实**,不得凭"看起来安全"就甩命令:
|
||||||
|
|
||||||
|
1. **目标 ref 上是否有未推送 / 未合并 commit**:让用户跑 `git log --oneline -5 <ref>` 或 `git log <主线>..<ref>` 把输出贴回来。
|
||||||
|
2. **工作区是否干净**:`git status`。
|
||||||
|
3. **先挂救生圈**:建议用 `git tag backup-xxx <sha>` 锁定当前 SHA,**再执行**破坏性命令。
|
||||||
|
4. **明示翻车回滚路径**:例如"如果不对,`git reset --hard backup-xxx` 即可回到此处"。
|
||||||
|
|
||||||
## 测试指引
|
## 测试指引
|
||||||
|
|
||||||
先定义验证方式,再实施修改。默认通过以下方式验证:
|
先定义验证方式,再实施修改。默认通过以下方式验证:
|
||||||
|
|||||||
24
CLAUDE.md
24
CLAUDE.md
@@ -126,6 +126,30 @@
|
|||||||
7. 改跨模块 API 时,提供方实现与对应 `*-api` 契约同步更新。
|
7. 改跨模块 API 时,提供方实现与对应 `*-api` 契约同步更新。
|
||||||
8. **未经用户明确同意,不执行任何 `mvn`、启动命令、脚本等会实际运行项目的命令。**
|
8. **未经用户明确同意,不执行任何 `mvn`、启动命令、脚本等会实际运行项目的命令。**
|
||||||
|
|
||||||
|
## Git 操作纪律
|
||||||
|
|
||||||
|
### 默认不引导分支管理(**首要**)
|
||||||
|
|
||||||
|
用户在本仓库长期固定在 `main` 上工作。开发流程中:
|
||||||
|
|
||||||
|
- **不要主动建议建 feature 分支**(`git checkout -b feat/xxx`、`git switch -c ...`)。
|
||||||
|
- **不要把"先切到 xxx 分支再操作"作为方案前置步骤**。
|
||||||
|
- 一切围绕 `main` 展开:直接在 `main` 上改、`main` 上提交、`main` 上推。
|
||||||
|
- 例外:用户明确要求建分支、或涉及多人协作 / PR 评审 / 大规模重构(此时仍只是"提一句作为可选",不强推)。
|
||||||
|
|
||||||
|
**理由**:这次差点丢用户 3 天工作的事故,根因就是分支管理本身——某次操作意外把文件名当成分支名(建出 `用户行动清单.md` 分支),后续"切回 main + `git branch -D` 删怪分支"流程里就把未推送 commit `8bad989` 干掉了。**少走分支 = 少埋雷**。
|
||||||
|
|
||||||
|
### 破坏性 git 命令必须先核实
|
||||||
|
|
||||||
|
任何**会丢工作**的 git 命令——`branch -D`、`reset --hard`、`clean -fd`、`push --force` / `--force-with-lease`、`checkout` / `switch` 带未提交改动、`rebase` 在已推送分支上、直接动 `.git/` 内部文件(`refs/`、`HEAD`、`packed-refs`)——**给出建议前必须先核实**,不得凭"看起来安全"就甩命令:
|
||||||
|
|
||||||
|
1. **目标 ref 上是否有未推送 / 未合并 commit**:让用户跑 `git log --oneline -5 <ref>` 或 `git log <主线>..<ref>` 把输出贴回来。
|
||||||
|
2. **工作区是否干净**:`git status`。
|
||||||
|
3. **先挂救生圈**:建议用 `git tag backup-xxx <sha>` 锁定当前 SHA,**再执行**破坏性命令。
|
||||||
|
4. **明示翻车回滚路径**:例如"如果不对,`git reset --hard backup-xxx` 即可回到此处"。
|
||||||
|
|
||||||
|
与 memory `feedback_no_git_commands.md`、`feedback_main_branch_workflow.md` 衔接:用户已要求"不主动跑 git 子命令(含只读)"+"在 main 上工作";本节进一步约束——**即使是让用户执行的建议命令**,也必须先满足上述核实清单。
|
||||||
|
|
||||||
## 验证默认动作
|
## 验证默认动作
|
||||||
|
|
||||||
先定义验证方式,再做修改。默认静态验证:
|
先定义验证方式,再做修改。默认静态验证:
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ public interface ErrorCodeConstants {
|
|||||||
ErrorCode PRODUCT_INITIAL_TEAM_MANAGER_REQUIRED = new ErrorCode(1_008_001_024, "初始团队必须包含产品经理");
|
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_MEMBER_DUPLICATE = new ErrorCode(1_008_001_025, "初始团队成员存在重复");
|
||||||
ErrorCode PRODUCT_INITIAL_TEAM_ROLE_INVALID = new ErrorCode(1_008_001_026, "初始团队中存在非法角色");
|
ErrorCode PRODUCT_INITIAL_TEAM_ROLE_INVALID = new ErrorCode(1_008_001_026, "初始团队中存在非法角色");
|
||||||
|
ErrorCode PRODUCT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE = new ErrorCode(1_008_001_027, "原产品经理在该产品已持有目标角色【{}】(含历史失效行),不能直接转交,请先清理后重试");
|
||||||
|
ErrorCode PRODUCT_INTERNAL_ROLE_NOT_CONFIGURED = new ErrorCode(1_008_001_028, "内置产品角色【{}】未在 system_role 找到,请联系管理员");
|
||||||
|
|
||||||
// ========== 产品需求 1-008-002-000 ==========
|
// ========== 产品需求 1-008-002-000 ==========
|
||||||
ErrorCode REQUIREMENT_NOT_EXISTS = new ErrorCode(1_008_002_000, "产品需求不存在");
|
ErrorCode REQUIREMENT_NOT_EXISTS = new ErrorCode(1_008_002_000, "产品需求不存在");
|
||||||
@@ -98,6 +100,8 @@ public interface ErrorCodeConstants {
|
|||||||
ErrorCode PROJECT_INITIAL_TEAM_MEMBER_DUPLICATE = new ErrorCode(1_008_002_030, "初始团队成员存在重复");
|
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_INITIAL_TEAM_ROLE_INVALID = new ErrorCode(1_008_002_031, "初始团队中存在非法角色");
|
||||||
ErrorCode PROJECT_DIRECTION_NOT_MATCH_PRODUCT = new ErrorCode(1_008_002_032, "项目方向与所属产品方向不一致");
|
ErrorCode PROJECT_DIRECTION_NOT_MATCH_PRODUCT = new ErrorCode(1_008_002_032, "项目方向与所属产品方向不一致");
|
||||||
|
ErrorCode PROJECT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE = new ErrorCode(1_008_002_033, "原项目经理在该项目已持有目标角色【{}】(含历史失效行),不能直接转交,请先清理后重试");
|
||||||
|
ErrorCode PROJECT_INTERNAL_ROLE_NOT_CONFIGURED = new ErrorCode(1_008_002_034, "内置项目角色【{}】未在 system_role 找到,请联系管理员");
|
||||||
|
|
||||||
// ========== 执行管理 1-008-003-000 ==========
|
// ========== 执行管理 1-008-003-000 ==========
|
||||||
ErrorCode PROJECT_EXECUTION_NOT_EXISTS = new ErrorCode(1_008_003_000, "执行不存在");
|
ErrorCode PROJECT_EXECUTION_NOT_EXISTS = new ErrorCode(1_008_003_000, "执行不存在");
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ public final class ObjectActivityConstants {
|
|||||||
public static final String MEMBER_ACTION_ADD = "add_member";
|
public static final String MEMBER_ACTION_ADD = "add_member";
|
||||||
public static final String MEMBER_ACTION_UPDATE = "update_member";
|
public static final String MEMBER_ACTION_UPDATE = "update_member";
|
||||||
public static final String MEMBER_ACTION_REMOVE = "remove_member";
|
public static final String MEMBER_ACTION_REMOVE = "remove_member";
|
||||||
|
/**
|
||||||
|
* 复活动作:原 INACTIVE 成员行被重新激活(status: 1 → 0),用于把"再次新增 / update 改 role 命中老 INACTIVE 行"路径
|
||||||
|
* 跟物理"新增 / 更新"的 audit 语义区分开。createXxxMember 命中 INACTIVE 三元组复活老行时使用本动作,避免 ADD 语义误用。
|
||||||
|
*/
|
||||||
|
public static final String MEMBER_ACTION_REACTIVATE = "reactivate_member";
|
||||||
public static final String EXECUTION_ACTION_CREATE = "create_execution_entity";
|
public static final String EXECUTION_ACTION_CREATE = "create_execution_entity";
|
||||||
public static final String EXECUTION_ACTION_UPDATE = "update_execution_entity";
|
public static final String EXECUTION_ACTION_UPDATE = "update_execution_entity";
|
||||||
public static final String EXECUTION_ACTION_DELETE = "delete_execution_entity";
|
public static final String EXECUTION_ACTION_DELETE = "delete_execution_entity";
|
||||||
@@ -98,7 +103,7 @@ public final class ObjectActivityConstants {
|
|||||||
PRODUCT_ACTION_CREATE, PRODUCT_ACTION_CHANGE_MANAGER);
|
PRODUCT_ACTION_CREATE, PRODUCT_ACTION_CHANGE_MANAGER);
|
||||||
|
|
||||||
public static final List<String> MEMBER_TIMELINE_ACTION_TYPES = List.of(
|
public static final List<String> MEMBER_TIMELINE_ACTION_TYPES = List.of(
|
||||||
MEMBER_ACTION_ADD, MEMBER_ACTION_UPDATE, MEMBER_ACTION_REMOVE);
|
MEMBER_ACTION_ADD, MEMBER_ACTION_UPDATE, MEMBER_ACTION_REMOVE, MEMBER_ACTION_REACTIVATE);
|
||||||
|
|
||||||
private static final Set<String> STATUS_ACTION_TYPE_SET = Set.copyOf(STATUS_ACTION_TYPES);
|
private static final Set<String> STATUS_ACTION_TYPE_SET = Set.copyOf(STATUS_ACTION_TYPES);
|
||||||
|
|
||||||
@@ -145,6 +150,7 @@ public final class ObjectActivityConstants {
|
|||||||
case MEMBER_ACTION_ADD -> "新增成员";
|
case MEMBER_ACTION_ADD -> "新增成员";
|
||||||
case MEMBER_ACTION_UPDATE -> "调整成员";
|
case MEMBER_ACTION_UPDATE -> "调整成员";
|
||||||
case MEMBER_ACTION_REMOVE -> "移出成员";
|
case MEMBER_ACTION_REMOVE -> "移出成员";
|
||||||
|
case MEMBER_ACTION_REACTIVATE -> "重新激活成员";
|
||||||
default -> normalizedActionType;
|
default -> normalizedActionType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ public final class ProjectTaskConstants {
|
|||||||
*/
|
*/
|
||||||
public static final String OBJECT_TYPE = "task";
|
public static final String OBJECT_TYPE = "task";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务"已完成"状态码,对应 rdms_object_status_model 中 object_type='task' 且 status_code='completed' 的状态。
|
||||||
|
* 用于 execution 的 complete 按钮可见性判定:要求根任务在排除排除集后全部为该状态。
|
||||||
|
*/
|
||||||
|
public static final String STATUS_COMPLETED = "completed";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 任务业务类型。
|
* 任务业务类型。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.njcn.rdms.module.project.controller.admin.product.vo.member;
|
package com.njcn.rdms.module.project.controller.admin.product.vo.member;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Schema(description = "管理后台 - 产品团队成员 Response VO")
|
@Schema(description = "管理后台 - 产品团队成员 Response VO")
|
||||||
@Data
|
@Data
|
||||||
@@ -42,4 +45,7 @@ public class ProductMemberRespVO {
|
|||||||
@Schema(description = "备注", example = "当前负责需求收敛")
|
@Schema(description = "备注", example = "当前负责需求收敛")
|
||||||
private String remark;
|
private String remark;
|
||||||
|
|
||||||
|
@ArraySchema(schema = @Schema(description = "非主角色的中文名列表,多角色场景使用(如同人 manager + creator);单角色时为空数组", example = "产品创建者"))
|
||||||
|
private List<String> additionalRoleNames = Collections.emptyList();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package com.njcn.rdms.module.project.controller.admin.product.vo.product;
|
package com.njcn.rdms.module.project.controller.admin.product.vo.product;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Schema(description = "管理后台 - 产品上下文中的当前角色 Response VO")
|
@Schema(description = "管理后台 - 产品上下文中的当前角色 Response VO")
|
||||||
@Data
|
@Data
|
||||||
public class ProductContextRoleRespVO {
|
public class ProductContextRoleRespVO {
|
||||||
@@ -10,10 +14,16 @@ public class ProductContextRoleRespVO {
|
|||||||
@Schema(description = "对象角色编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3201")
|
@Schema(description = "对象角色编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3201")
|
||||||
private Long roleId;
|
private Long roleId;
|
||||||
|
|
||||||
@Schema(description = "对象角色编码", example = "product_manager")
|
@Schema(description = "对象角色编码(主角色 code,权限判断兼容字段)", example = "product_manager")
|
||||||
private String roleCode;
|
private String roleCode;
|
||||||
|
|
||||||
@Schema(description = "对象角色名称", example = "产品经理")
|
@Schema(description = "对象角色名称(主角色 name)", example = "产品经理")
|
||||||
private String roleName;
|
private String roleName;
|
||||||
|
|
||||||
|
@Schema(description = "是否游客上下文(隐式 observer 兜底时为 true)", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
|
||||||
|
private Boolean guestFlag;
|
||||||
|
|
||||||
|
@ArraySchema(schema = @Schema(description = "非主角色的中文名列表,多角色场景使用;单角色时为空数组", example = "创建者"))
|
||||||
|
private List<String> additionalRoleNames = Collections.emptyList();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,4 +39,14 @@ public class ProductCreateWithTeamReqVO {
|
|||||||
@Valid
|
@Valid
|
||||||
private List<ProductMemberSaveReqVO> members;
|
private List<ProductMemberSaveReqVO> members;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关心人 user ID 列表(创建时手动添加,可选)。
|
||||||
|
*
|
||||||
|
* <p>跟 members 是平行字段:watcher 不参与团队管理,只是被授予"产品关心人"角色(product_watcher)拿
|
||||||
|
* 数据可见性。允许跟 members 的 user 重叠(多角色合法);后端按 (user, object, role) 三元组写入,
|
||||||
|
* 重复跳过 / INACTIVE 复活,业务侧不强校验。
|
||||||
|
*/
|
||||||
|
@Schema(description = "关心人用户ID列表(可选,可与团队成员重叠)", example = "101,102")
|
||||||
|
private List<Long> watcherUserIds;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.njcn.rdms.module.project.controller.admin.project.task;
|
|||||||
|
|
||||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||||
|
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageReqVO;
|
||||||
|
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageRespVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeleteReqVO;
|
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeleteReqVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO;
|
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO;
|
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO;
|
||||||
@@ -74,6 +76,14 @@ public class ProjectTaskController {
|
|||||||
return success(projectStatusBoardService.getTaskStatusBoard(projectId, executionId, reqVO));
|
return success(projectStatusBoardService.getTaskStatusBoard(projectId, executionId, reqVO));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/board-page")
|
||||||
|
@Operation(summary = "获取任务看板分页(按状态分列 + 每列分页 + 各列总数)")
|
||||||
|
public CommonResult<ProjectTaskBoardPageRespVO> getTaskBoardPage(@PathVariable("projectId") Long projectId,
|
||||||
|
@PathVariable("executionId") Long executionId,
|
||||||
|
@Valid ProjectTaskBoardPageReqVO reqVO) {
|
||||||
|
return success(projectStatusBoardService.getTaskBoardPage(projectId, executionId, reqVO));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{taskId}/change-status")
|
@PostMapping("/{taskId}/change-status")
|
||||||
@Operation(summary = "变更任务状态")
|
@Operation(summary = "变更任务状态")
|
||||||
public CommonResult<Boolean> changeStatus(@PathVariable("projectId") Long projectId,
|
public CommonResult<Boolean> changeStatus(@PathVariable("projectId") Long projectId,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.njcn.rdms.module.project.controller.admin.project.task.vo;
|
||||||
|
|
||||||
|
import com.njcn.rdms.framework.common.pojo.PageParam;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import static com.njcn.rdms.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 看板视图任务分页 Request VO。
|
||||||
|
* <p>过滤口径(keyword / parentTaskId / ownerId / updateTime)与 {@link ProjectTaskPageReqVO} 严格一致;
|
||||||
|
* statusCode 升级为数组:缺省=返回该执行下任务状态字典的全部列;传若干个=只返回这些状态的列;
|
||||||
|
* 字典外的 statusCode 静默忽略。pageNo / pageSize 应用到返回的所有列(每列各自分页但页码统一)。
|
||||||
|
*/
|
||||||
|
@Schema(description = "管理后台 - 任务看板分页 Request VO")
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class ProjectTaskBoardPageReqVO extends PageParam {
|
||||||
|
|
||||||
|
@Schema(description = "列选择;缺省返回全部状态列,传若干个只返回这些状态的列;字典外的值静默忽略",
|
||||||
|
example = "[\"pending\",\"active\"]")
|
||||||
|
private String[] statusCode;
|
||||||
|
|
||||||
|
@Schema(description = "关键词,匹配任务标题", example = "联调")
|
||||||
|
private String keyword;
|
||||||
|
|
||||||
|
@Schema(description = "父任务编号", example = "9001")
|
||||||
|
private Long parentTaskId;
|
||||||
|
|
||||||
|
@Schema(description = "任务负责人用户编号", example = "3002")
|
||||||
|
private Long ownerId;
|
||||||
|
|
||||||
|
@Schema(description = "更新时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]")
|
||||||
|
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
|
||||||
|
private LocalDateTime[] updateTime;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.njcn.rdms.module.project.controller.admin.project.task.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 看板视图任务分页 Response VO。
|
||||||
|
* <p>每个 item 表示一列:列定义(statusCode/statusName/sort/terminal)+ 当前页切片(list)+ 该列在当前过滤条件下的总数(total)。
|
||||||
|
* list 元素结构与 {@link ProjectTaskRespVO} 完全一致。
|
||||||
|
*/
|
||||||
|
@Schema(description = "管理后台 - 任务看板分页 Response VO")
|
||||||
|
@Data
|
||||||
|
public class ProjectTaskBoardPageRespVO {
|
||||||
|
|
||||||
|
@Schema(description = "列数组(按 sort 升序)", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private List<ColumnItemVO> items;
|
||||||
|
|
||||||
|
@Schema(description = "任务看板单列分页")
|
||||||
|
@Data
|
||||||
|
public static class ColumnItemVO {
|
||||||
|
|
||||||
|
@Schema(description = "状态编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "pending")
|
||||||
|
private String statusCode;
|
||||||
|
|
||||||
|
@Schema(description = "状态名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "待开始")
|
||||||
|
private String statusName;
|
||||||
|
|
||||||
|
@Schema(description = "排序权重(与 /status-board.items[].sort 同源)", requiredMode = Schema.RequiredMode.REQUIRED,
|
||||||
|
example = "10")
|
||||||
|
private Integer sort;
|
||||||
|
|
||||||
|
@Schema(description = "是否终态", example = "false")
|
||||||
|
private Boolean terminal;
|
||||||
|
|
||||||
|
@Schema(description = "该列当前页切片;元素结构与 /tasks/page 的 list 元素一致",
|
||||||
|
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private List<ProjectTaskRespVO> list;
|
||||||
|
|
||||||
|
@Schema(description = "该列在当前过滤条件下的总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "12")
|
||||||
|
private Long total;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,19 +1,25 @@
|
|||||||
package com.njcn.rdms.module.project.controller.admin.project.vo.project;
|
package com.njcn.rdms.module.project.controller.admin.project.vo.project;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Schema(description = "管理后台 - 项目上下文中的当前角色 Response VO")
|
@Schema(description = "管理后台 - 项目上下文中的当前角色 Response VO")
|
||||||
@Data
|
@Data
|
||||||
public class ProjectContextRoleRespVO {
|
public class ProjectContextRoleRespVO {
|
||||||
|
|
||||||
@Schema(description = "对象角色编号", example = "3201")
|
@Schema(description = "对象角色编号", example = "3201")
|
||||||
private Long roleId;
|
private Long roleId;
|
||||||
@Schema(description = "对象角色编码", example = "project_manager")
|
@Schema(description = "对象角色编码(主角色 code,权限判断兼容字段)", example = "project_manager")
|
||||||
private String roleCode;
|
private String roleCode;
|
||||||
@Schema(description = "对象角色名称", example = "项目经理")
|
@Schema(description = "对象角色名称(主角色 name)", example = "项目经理")
|
||||||
private String roleName;
|
private String roleName;
|
||||||
@Schema(description = "是否游客上下文", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
|
@Schema(description = "是否游客上下文", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
|
||||||
private Boolean guestFlag;
|
private Boolean guestFlag;
|
||||||
|
@ArraySchema(schema = @Schema(description = "非主角色的中文名列表,多角色场景使用;单角色时为空数组", example = "创建者"))
|
||||||
|
private List<String> additionalRoleNames = Collections.emptyList();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,4 +40,14 @@ public class ProjectCreateWithTeamReqVO {
|
|||||||
@Valid
|
@Valid
|
||||||
private List<ProjectMemberSaveReqVO> members;
|
private List<ProjectMemberSaveReqVO> members;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关心人 user ID 列表(创建时手动添加,可选)。
|
||||||
|
*
|
||||||
|
* <p>跟 members 是平行字段:watcher 不参与团队管理,只是被授予"项目关心人"角色(project_watcher)拿
|
||||||
|
* 数据可见性。允许跟 members 的 user 重叠(多角色合法);后端按 (user, object, role) 三元组写入,
|
||||||
|
* 重复跳过 / INACTIVE 复活,业务侧不强校验。
|
||||||
|
*/
|
||||||
|
@Schema(description = "关心人用户ID列表(可选,可与团队成员重叠)", example = "101,102")
|
||||||
|
private List<Long> watcherUserIds;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ public class ObjectStatusModelDO extends BaseDO {
|
|||||||
* 是否允许编辑对象主数据
|
* 是否允许编辑对象主数据
|
||||||
*/
|
*/
|
||||||
private Boolean allowEdit;
|
private Boolean allowEdit;
|
||||||
|
/**
|
||||||
|
* 是否不参与上层进度统计。
|
||||||
|
*/
|
||||||
|
private Boolean progressExcludedFlag;
|
||||||
/**
|
/**
|
||||||
* 备注
|
* 备注
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
|||||||
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
|
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -42,6 +43,19 @@ public interface UserObjectRoleMapper extends BaseMapperX<UserObjectRoleDO> {
|
|||||||
.eq(UserObjectRoleDO::getStatus, 0));
|
.eq(UserObjectRoleDO::getStatus, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* multi-role:拿 user 在某对象内全部 ACTIVE 角色行(含 manager + creator + watcher 等所有显式角色)。
|
||||||
|
* 用于对象域鉴权(ProductObjectPermissionService / ProjectObjectPermissionService 的 anyMatch 权限聚合)
|
||||||
|
* 与 Context 主角色挑选(按 sort 升序排,主角色 + additionalRoleNames)。
|
||||||
|
*/
|
||||||
|
default List<UserObjectRoleDO> selectActiveListByObjectAndUserId(String objectType, Long objectId, Long userId) {
|
||||||
|
return selectList(new LambdaQueryWrapperX<UserObjectRoleDO>()
|
||||||
|
.eq(UserObjectRoleDO::getObjectType, objectType)
|
||||||
|
.eq(UserObjectRoleDO::getObjectId, objectId)
|
||||||
|
.eq(UserObjectRoleDO::getUserId, userId)
|
||||||
|
.eq(UserObjectRoleDO::getStatus, 0));
|
||||||
|
}
|
||||||
|
|
||||||
default List<UserObjectRoleDO> selectListByIdsAndObject(List<Long> ids, String objectType, Long objectId) {
|
default List<UserObjectRoleDO> selectListByIdsAndObject(List<Long> ids, String objectType, Long objectId) {
|
||||||
if (ids == null || ids.isEmpty()) {
|
if (ids == null || ids.isEmpty()) {
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
@@ -59,4 +73,42 @@ public interface UserObjectRoleMapper extends BaseMapperX<UserObjectRoleDO> {
|
|||||||
.eq(UserObjectRoleDO::getStatus, 0));
|
.eq(UserObjectRoleDO::getStatus, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* multi-role 唯一索引精确命中:按 (object_type, object_id, user_id, role_id) 单查任一记录。
|
||||||
|
* 不带 status —— ACTIVE / INACTIVE 都要返回,用于 insertOrReactivate / pre-check 撞索引场景。
|
||||||
|
*/
|
||||||
|
default UserObjectRoleDO selectByObjectUserAndRole(String objectType, Long objectId, Long userId, Long roleId) {
|
||||||
|
return selectOne(new LambdaQueryWrapperX<UserObjectRoleDO>()
|
||||||
|
.eq(UserObjectRoleDO::getObjectType, objectType)
|
||||||
|
.eq(UserObjectRoleDO::getObjectId, objectId)
|
||||||
|
.eq(UserObjectRoleDO::getUserId, userId)
|
||||||
|
.eq(UserObjectRoleDO::getRoleId, roleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同 {@link #selectByObjectUserAndRole},但仅返回 ACTIVE 行(status=0)。
|
||||||
|
* 用于 manager 转岗:按 (user, object, manager_role_id) 三元组定位 manager ACTIVE 行后改 role_id 降级。
|
||||||
|
*/
|
||||||
|
default UserObjectRoleDO selectActiveByObjectUserAndRole(String objectType, Long objectId, Long userId, Long roleId) {
|
||||||
|
return selectOne(new LambdaQueryWrapperX<UserObjectRoleDO>()
|
||||||
|
.eq(UserObjectRoleDO::getObjectType, objectType)
|
||||||
|
.eq(UserObjectRoleDO::getObjectId, objectId)
|
||||||
|
.eq(UserObjectRoleDO::getUserId, userId)
|
||||||
|
.eq(UserObjectRoleDO::getRoleId, roleId)
|
||||||
|
.eq(UserObjectRoleDO::getStatus, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通道 2 用:批量按 userIds 反查指定 objectType 下的活跃记录(status=0),用于"组织负责人 → 下属参与的对象"反推。
|
||||||
|
*/
|
||||||
|
default List<UserObjectRoleDO> selectListByUserIdsAndObjectType(Collection<Long> userIds, String objectType) {
|
||||||
|
if (userIds == null || userIds.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
return selectList(new LambdaQueryWrapperX<UserObjectRoleDO>()
|
||||||
|
.in(UserObjectRoleDO::getUserId, userIds)
|
||||||
|
.eq(UserObjectRoleDO::getObjectType, objectType)
|
||||||
|
.eq(UserObjectRoleDO::getStatus, 0));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
package com.njcn.rdms.module.project.dal.mysql.product;
|
package com.njcn.rdms.module.project.dal.mysql.product;
|
||||||
|
|
||||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
|
||||||
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
|
|
||||||
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
|
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
|
||||||
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
|
|
||||||
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
|
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
import org.springframework.util.StringUtils;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -16,21 +12,6 @@ import java.util.Map;
|
|||||||
@Mapper
|
@Mapper
|
||||||
public interface ProductMapper extends BaseMapperX<ProductDO> {
|
public interface ProductMapper extends BaseMapperX<ProductDO> {
|
||||||
|
|
||||||
default PageResult<ProductDO> selectPage(ProductPageReqVO reqVO) {
|
|
||||||
LambdaQueryWrapperX<ProductDO> queryWrapper = new LambdaQueryWrapperX<>();
|
|
||||||
if (StringUtils.hasText(reqVO.getKeyword())) {
|
|
||||||
queryWrapper.and(wrapper -> wrapper.like(ProductDO::getCode, reqVO.getKeyword())
|
|
||||||
.or()
|
|
||||||
.like(ProductDO::getName, reqVO.getKeyword()));
|
|
||||||
}
|
|
||||||
queryWrapper.eqIfPresent(ProductDO::getDirectionCode, reqVO.getDirectionCode())
|
|
||||||
.eqIfPresent(ProductDO::getManagerUserId, reqVO.getManagerUserId())
|
|
||||||
.eqIfPresent(ProductDO::getStatusCode, reqVO.getStatusCode())
|
|
||||||
.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime())
|
|
||||||
.orderByDesc(BaseDO::getCreateTime);
|
|
||||||
return selectPage(reqVO, queryWrapper);
|
|
||||||
}
|
|
||||||
|
|
||||||
default ProductDO selectByCode(String code) {
|
default ProductDO selectByCode(String code) {
|
||||||
return selectOne(ProductDO::getCode, code);
|
return selectOne(ProductDO::getCode, code);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
package com.njcn.rdms.module.project.dal.mysql.project;
|
package com.njcn.rdms.module.project.dal.mysql.project;
|
||||||
|
|
||||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
|
||||||
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
|
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
|
||||||
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
|
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
|
||||||
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
|
|
||||||
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
|
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
import org.springframework.util.StringUtils;
|
|
||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -17,23 +14,6 @@ import java.util.Map;
|
|||||||
@Mapper
|
@Mapper
|
||||||
public interface ProjectMapper extends BaseMapperX<ProjectDO> {
|
public interface ProjectMapper extends BaseMapperX<ProjectDO> {
|
||||||
|
|
||||||
default PageResult<ProjectDO> selectPage(ProjectPageReqVO reqVO) {
|
|
||||||
LambdaQueryWrapperX<ProjectDO> queryWrapper = new LambdaQueryWrapperX<>();
|
|
||||||
if (StringUtils.hasText(reqVO.getKeyword())) {
|
|
||||||
queryWrapper.and(wrapper -> wrapper.like(ProjectDO::getProjectCode, reqVO.getKeyword())
|
|
||||||
.or()
|
|
||||||
.like(ProjectDO::getProjectName, reqVO.getKeyword()));
|
|
||||||
}
|
|
||||||
queryWrapper.eqIfPresent(ProjectDO::getProjectType, reqVO.getProjectType())
|
|
||||||
.eqIfPresent(ProjectDO::getDirectionCode, reqVO.getDirectionCode())
|
|
||||||
.eqIfPresent(ProjectDO::getProductId, reqVO.getProductId())
|
|
||||||
.eqIfPresent(ProjectDO::getManagerUserId, reqVO.getManagerUserId())
|
|
||||||
.eqIfPresent(ProjectDO::getStatusCode, reqVO.getStatusCode())
|
|
||||||
.betweenIfPresent(BaseDO::getUpdateTime, reqVO.getUpdateTime())
|
|
||||||
.orderByDesc(BaseDO::getCreateTime);
|
|
||||||
return selectPage(reqVO, queryWrapper);
|
|
||||||
}
|
|
||||||
|
|
||||||
default ProjectDO selectByCode(String projectCode) {
|
default ProjectDO selectByCode(String projectCode) {
|
||||||
return selectOne(ProjectDO::getProjectCode, projectCode);
|
return selectOne(ProjectDO::getProjectCode, projectCode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,28 +125,40 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
|
|||||||
* 取直接子任务的 progressRate 列表(用于父任务进度简单平均汇总)。
|
* 取直接子任务的 progressRate 列表(用于父任务进度简单平均汇总)。
|
||||||
* 只读取必要字段,避免拉回整行;逻辑删除的子任务由 BaseMapper 自动过滤。
|
* 只读取必要字段,避免拉回整行;逻辑删除的子任务由 BaseMapper 自动过滤。
|
||||||
*/
|
*/
|
||||||
default List<ProjectTaskDO> selectChildrenProgressByParentTaskId(Long parentTaskId) {
|
default List<ProjectTaskDO> selectChildrenProgressByParentTaskId(Long parentTaskId,
|
||||||
|
Collection<String> excludedStatusCodes) {
|
||||||
if (parentTaskId == null) {
|
if (parentTaskId == null) {
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
return selectList(new LambdaQueryWrapperX<ProjectTaskDO>()
|
LambdaQueryWrapperX<ProjectTaskDO> queryWrapper = new LambdaQueryWrapperX<>();
|
||||||
.select(ProjectTaskDO::getId, ProjectTaskDO::getProgressRate)
|
queryWrapper.select(ProjectTaskDO::getId, ProjectTaskDO::getProgressRate);
|
||||||
.eq(ProjectTaskDO::getParentTaskId, parentTaskId));
|
queryWrapper.eq(ProjectTaskDO::getParentTaskId, parentTaskId);
|
||||||
|
if (excludedStatusCodes != null && !excludedStatusCodes.isEmpty()) {
|
||||||
|
queryWrapper.notIn(ProjectTaskDO::getStatusCode, excludedStatusCodes);
|
||||||
|
}
|
||||||
|
return selectList(queryWrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行详情进度:按当前执行下一级任务 progressRate 简单平均;无一级任务时 SQL 返回 null。
|
* 执行详情进度:按当前执行下一级任务 progressRate 简单平均;无一级任务时 SQL 返回 null。
|
||||||
*/
|
*/
|
||||||
@Select("""
|
@Select("""
|
||||||
|
<script>
|
||||||
SELECT AVG(COALESCE(progress_rate, 0))
|
SELECT AVG(COALESCE(progress_rate, 0))
|
||||||
FROM rdms_task
|
FROM rdms_task
|
||||||
WHERE deleted = b'0'
|
WHERE deleted = b'0'
|
||||||
AND project_id = #{projectId}
|
AND project_id = #{projectId}
|
||||||
AND execution_id = #{executionId}
|
AND execution_id = #{executionId}
|
||||||
AND parent_task_id IS NULL
|
AND parent_task_id IS NULL
|
||||||
|
<if test="excludedStatusCodes != null and excludedStatusCodes.size() > 0">
|
||||||
|
AND status_code NOT IN
|
||||||
|
<foreach collection="excludedStatusCodes" item="statusCode" open="(" separator="," close=")">#{statusCode}</foreach>
|
||||||
|
</if>
|
||||||
|
</script>
|
||||||
""")
|
""")
|
||||||
BigDecimal selectRootTaskAvgProgressByExecutionId(@Param("projectId") Long projectId,
|
BigDecimal selectRootTaskAvgProgressByExecutionId(@Param("projectId") Long projectId,
|
||||||
@Param("executionId") Long executionId);
|
@Param("executionId") Long executionId,
|
||||||
|
@Param("excludedStatusCodes") Collection<String> excludedStatusCodes);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行分页进度:按当前页 executionId 批量聚合一级任务 progressRate,避免列表 N+1。
|
* 执行分页进度:按当前页 executionId 批量聚合一级任务 progressRate,避免列表 N+1。
|
||||||
@@ -160,12 +172,70 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
|
|||||||
AND execution_id IN
|
AND execution_id IN
|
||||||
<foreach collection="executionIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
<foreach collection="executionIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
AND parent_task_id IS NULL
|
AND parent_task_id IS NULL
|
||||||
|
<if test="excludedStatusCodes != null and excludedStatusCodes.size() > 0">
|
||||||
|
AND status_code NOT IN
|
||||||
|
<foreach collection="excludedStatusCodes" item="statusCode" open="(" separator="," close=")">#{statusCode}</foreach>
|
||||||
|
</if>
|
||||||
GROUP BY execution_id
|
GROUP BY execution_id
|
||||||
</script>
|
</script>
|
||||||
""")
|
""")
|
||||||
List<Map<String, Object>> selectRootTaskAvgProgressGroupByExecutionIds(
|
List<Map<String, Object>> selectRootTaskAvgProgressGroupByExecutionIds(
|
||||||
@Param("projectId") Long projectId,
|
@Param("projectId") Long projectId,
|
||||||
@Param("executionIds") Collection<Long> executionIds);
|
@Param("executionIds") Collection<Long> executionIds,
|
||||||
|
@Param("excludedStatusCodes") Collection<String> excludedStatusCodes);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行详情完成态聚合:返回根任务总数与"已完成"数,用于 execution complete 按钮判定。
|
||||||
|
* 与 selectRootTaskAvgProgressByExecutionId 共用同一根任务筛选口径(execution_id + parent_task_id IS NULL + excludedStatusCodes)。
|
||||||
|
* 业务侧判定 totals > 0 && totals == completedCount 即视为"根任务全部已完成",空集(totals = 0)按"不全部完成"处理。
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
<script>
|
||||||
|
SELECT COUNT(*) AS totals,
|
||||||
|
SUM(CASE WHEN status_code = #{completedStatusCode} THEN 1 ELSE 0 END) AS completedCount
|
||||||
|
FROM rdms_task
|
||||||
|
WHERE deleted = b'0'
|
||||||
|
AND project_id = #{projectId}
|
||||||
|
AND execution_id = #{executionId}
|
||||||
|
AND parent_task_id IS NULL
|
||||||
|
<if test="excludedStatusCodes != null and excludedStatusCodes.size() > 0">
|
||||||
|
AND status_code NOT IN
|
||||||
|
<foreach collection="excludedStatusCodes" item="statusCode" open="(" separator="," close=")">#{statusCode}</foreach>
|
||||||
|
</if>
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
Map<String, Object> selectRootTaskCompletionStateByExecutionId(@Param("projectId") Long projectId,
|
||||||
|
@Param("executionId") Long executionId,
|
||||||
|
@Param("completedStatusCode") String completedStatusCode,
|
||||||
|
@Param("excludedStatusCodes") Collection<String> excludedStatusCodes);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行分页完成态批量聚合:按 executionId 一次性返回 (totals, completedCount),避免列表 N+1。
|
||||||
|
* 筛选口径与 selectRootTaskCompletionStateByExecutionId 同源。
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
<script>
|
||||||
|
SELECT execution_id AS executionId,
|
||||||
|
COUNT(*) AS totals,
|
||||||
|
SUM(CASE WHEN status_code = #{completedStatusCode} THEN 1 ELSE 0 END) AS completedCount
|
||||||
|
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
|
||||||
|
<if test="excludedStatusCodes != null and excludedStatusCodes.size() > 0">
|
||||||
|
AND status_code NOT IN
|
||||||
|
<foreach collection="excludedStatusCodes" item="statusCode" open="(" separator="," close=")">#{statusCode}</foreach>
|
||||||
|
</if>
|
||||||
|
GROUP BY execution_id
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
List<Map<String, Object>> selectRootTaskCompletionStateGroupByExecutionIds(
|
||||||
|
@Param("projectId") Long projectId,
|
||||||
|
@Param("executionIds") Collection<Long> executionIds,
|
||||||
|
@Param("completedStatusCode") String completedStatusCode,
|
||||||
|
@Param("excludedStatusCodes") Collection<String> excludedStatusCodes);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仅更新单个任务的 progressRate,不动其他字段(避免污染 lastStatusReason 等)。
|
* 仅更新单个任务的 progressRate,不动其他字段(避免污染 lastStatusReason 等)。
|
||||||
|
|||||||
@@ -57,4 +57,17 @@ public interface ObjectStatusModelMapper extends BaseMapperX<ObjectStatusModelDO
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询某对象类型下所有已启用、且不参与上层进度统计的状态码。
|
||||||
|
*/
|
||||||
|
default List<String> selectProgressExcludedStatusCodesByObjectTypeEnabled(String objectType) {
|
||||||
|
return selectList(new LambdaQueryWrapperX<ObjectStatusModelDO>()
|
||||||
|
.eq(ObjectStatusModelDO::getObjectType, objectType)
|
||||||
|
.eq(ObjectStatusModelDO::getStatus, 0)
|
||||||
|
.eq(ObjectStatusModelDO::getProgressExcludedFlag, true))
|
||||||
|
.stream()
|
||||||
|
.map(ObjectStatusModelDO::getStatusCode)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.njcn.rdms.module.project.framework.rpc.config;
|
package com.njcn.rdms.module.project.framework.rpc.config;
|
||||||
|
|
||||||
|
import com.njcn.rdms.module.system.api.dept.OrgLeaderApi;
|
||||||
import com.njcn.rdms.module.system.api.dict.DictDataApi;
|
import com.njcn.rdms.module.system.api.dict.DictDataApi;
|
||||||
import com.njcn.rdms.module.system.api.file.FileApi;
|
import com.njcn.rdms.module.system.api.file.FileApi;
|
||||||
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
|
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
|
||||||
|
import com.njcn.rdms.module.system.api.permission.PermissionApi;
|
||||||
|
import com.njcn.rdms.module.system.api.permission.UserVisibilityConfigApi;
|
||||||
import com.njcn.rdms.module.system.api.user.AdminUserApi;
|
import com.njcn.rdms.module.system.api.user.AdminUserApi;
|
||||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -11,6 +14,6 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
* Project 模块的 RPC 配置
|
* Project 模块的 RPC 配置
|
||||||
*/
|
*/
|
||||||
@Configuration(value = "projectRpcConfiguration", proxyBeanMethods = false)
|
@Configuration(value = "projectRpcConfiguration", proxyBeanMethods = false)
|
||||||
@EnableFeignClients(clients = {AdminUserApi.class, ObjectPermissionApi.class, DictDataApi.class, FileApi.class})
|
@EnableFeignClients(clients = {AdminUserApi.class, ObjectPermissionApi.class, DictDataApi.class, FileApi.class, PermissionApi.class, OrgLeaderApi.class, UserVisibilityConfigApi.class})
|
||||||
public class RpcConfiguration {
|
public class RpcConfiguration {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import org.springframework.util.StringUtils;
|
|||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -41,9 +42,10 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
|
|||||||
throw invalidParamException("对象编号不能为空");
|
throw invalidParamException("对象编号不能为空");
|
||||||
}
|
}
|
||||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||||
UserObjectRoleDO currentMember = userObjectRoleMapper
|
// 多角色支持:拿 user 在对象内全部 ACTIVE 角色行
|
||||||
.selectActiveByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, objectId, loginUserId);
|
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
|
||||||
if (currentMember == null) {
|
.selectActiveListByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, objectId, loginUserId);
|
||||||
|
if (userRoles.isEmpty()) {
|
||||||
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED,
|
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED,
|
||||||
buildDeniedPermission(permission, memberOnly));
|
buildDeniedPermission(permission, memberOnly));
|
||||||
}
|
}
|
||||||
@@ -51,7 +53,12 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String normalizedPermission = normalizePermission(permission);
|
String normalizedPermission = normalizePermission(permission);
|
||||||
if (!getRolePermissions(currentMember.getRoleId()).contains(normalizedPermission)) {
|
// 任一角色含该权限码即放行(等价于多角色 union;短路求值)
|
||||||
|
boolean allowed = userRoles.stream()
|
||||||
|
.map(UserObjectRoleDO::getRoleId)
|
||||||
|
.distinct()
|
||||||
|
.anyMatch(roleId -> getRolePermissions(roleId).contains(normalizedPermission));
|
||||||
|
if (!allowed) {
|
||||||
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED, normalizedPermission);
|
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED, normalizedPermission);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import org.springframework.util.StringUtils;
|
|||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -41,9 +42,10 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
|
|||||||
throw invalidParamException("对象编号不能为空");
|
throw invalidParamException("对象编号不能为空");
|
||||||
}
|
}
|
||||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||||
UserObjectRoleDO currentMember = userObjectRoleMapper
|
// 多角色支持:拿 user 在对象内全部 ACTIVE 角色行
|
||||||
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, objectId, loginUserId);
|
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
|
||||||
if (currentMember == null) {
|
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, objectId, loginUserId);
|
||||||
|
if (userRoles.isEmpty()) {
|
||||||
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED,
|
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED,
|
||||||
buildDeniedPermission(permission, memberOnly));
|
buildDeniedPermission(permission, memberOnly));
|
||||||
}
|
}
|
||||||
@@ -51,7 +53,12 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String normalizedPermission = normalizePermission(permission);
|
String normalizedPermission = normalizePermission(permission);
|
||||||
if (!getRolePermissions(currentMember.getRoleId()).contains(normalizedPermission)) {
|
// 任一角色含该权限码即放行(等价于多角色 union;短路求值,权限码命中早 return)
|
||||||
|
boolean allowed = userRoles.stream()
|
||||||
|
.map(UserObjectRoleDO::getRoleId)
|
||||||
|
.distinct()
|
||||||
|
.anyMatch(roleId -> getRolePermissions(roleId).contains(normalizedPermission));
|
||||||
|
if (!allowed) {
|
||||||
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED, normalizedPermission);
|
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED, normalizedPermission);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.njcn.rdms.module.project.service.datascope;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据权限范围:用户在某 objectType(project/product)下能看到哪些对象。
|
||||||
|
* 不可变。三态:ALL(看全部,不加 SQL 条件)/ ID_LIST(看具体集合)/ EMPTY(看不到任何)。
|
||||||
|
*
|
||||||
|
* 设计来源:docs/superpowers/specs/2026-05-14-object-data-scope-design.md 第 3.1 节
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public final class ObjectDataScope {
|
||||||
|
|
||||||
|
public enum State { ALL, ID_LIST, EMPTY }
|
||||||
|
|
||||||
|
private final State state;
|
||||||
|
private final Set<Long> ids; // 仅 ID_LIST 时有值
|
||||||
|
private final Set<String> directionCodes; // 仅 ID_LIST 时有值
|
||||||
|
|
||||||
|
private ObjectDataScope(State state, Set<Long> ids, Set<String> directionCodes) {
|
||||||
|
this.state = state;
|
||||||
|
this.ids = ids == null ? Collections.emptySet() : Collections.unmodifiableSet(ids);
|
||||||
|
this.directionCodes = directionCodes == null ? Collections.emptySet() : Collections.unmodifiableSet(directionCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ObjectDataScope all() {
|
||||||
|
return new ObjectDataScope(State.ALL, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ObjectDataScope empty() {
|
||||||
|
return new ObjectDataScope(State.EMPTY, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ObjectDataScope idList(Set<Long> ids, Set<String> directionCodes) {
|
||||||
|
boolean idsEmpty = ids == null || ids.isEmpty();
|
||||||
|
boolean dcEmpty = directionCodes == null || directionCodes.isEmpty();
|
||||||
|
if (idsEmpty && dcEmpty) {
|
||||||
|
return empty();
|
||||||
|
}
|
||||||
|
return new ObjectDataScope(State.ID_LIST, ids, directionCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详情入口判定:当前 user 是否能"看到" (objectId, directionCode)。
|
||||||
|
* - ALL → true
|
||||||
|
* - ID_LIST → ids.contains(objectId) || directionCodes.contains(directionCode)
|
||||||
|
* - EMPTY → false
|
||||||
|
*/
|
||||||
|
public boolean contains(Long objectId, String objectDirectionCode) {
|
||||||
|
switch (state) {
|
||||||
|
case ALL: return true;
|
||||||
|
case EMPTY: return false;
|
||||||
|
case ID_LIST:
|
||||||
|
if (objectId != null && ids.contains(objectId)) return true;
|
||||||
|
if (objectDirectionCode != null && directionCodes.contains(objectDirectionCode)) return true;
|
||||||
|
return false;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.njcn.rdms.module.project.service.datascope;
|
||||||
|
|
||||||
|
public interface ObjectDataScopeService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 user 在某 objectType 下能看到的对象范围。
|
||||||
|
*
|
||||||
|
* @param userId 登录用户 id
|
||||||
|
* @param objectType "product" 或 "project"
|
||||||
|
*/
|
||||||
|
ObjectDataScope compute(Long userId, String objectType);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.njcn.rdms.module.project.service.datascope;
|
||||||
|
|
||||||
|
import cn.hutool.core.collection.CollUtil;
|
||||||
|
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
|
||||||
|
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
|
||||||
|
import com.njcn.rdms.module.system.api.dept.OrgLeaderApi;
|
||||||
|
import com.njcn.rdms.module.system.api.permission.PermissionApi;
|
||||||
|
import com.njcn.rdms.module.system.api.permission.UserVisibilityConfigApi;
|
||||||
|
import com.njcn.rdms.module.system.api.permission.dto.UserVisibilityConfigRespDTO;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据权限 scope 计算实现。3 通道并集 + 超管短路。
|
||||||
|
*
|
||||||
|
* 设计:docs/superpowers/specs/2026-05-14-object-data-scope-design.md 第 3.3 节
|
||||||
|
*
|
||||||
|
* 3 通道全接通 + 超管短路:通道 1(自己参与)+ 通道 2(组织负责人反推)+ 通道 3(用户可见性配置)。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class ObjectDataScopeServiceImpl implements ObjectDataScopeService {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private PermissionApi permissionApi;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private UserObjectRoleMapper userObjectRoleMapper;
|
||||||
|
// channel1 用 Mapper 直接查(同模块);channel3 在阶段 2 注入跨模块 API
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private OrgLeaderApi orgLeaderApi;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private UserVisibilityConfigApi userVisibilityConfigApi;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ObjectDataScope compute(Long userId, String objectType) {
|
||||||
|
if (Boolean.TRUE.equals(permissionApi.isSuperAdmin(userId).getCheckedData())) {
|
||||||
|
return ObjectDataScope.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<Long> ids = new HashSet<>();
|
||||||
|
Set<String> directionCodes = new HashSet<>();
|
||||||
|
|
||||||
|
ids.addAll(computeChannel1(userId, objectType));
|
||||||
|
ids.addAll(computeChannel2(userId, objectType));
|
||||||
|
|
||||||
|
UserVisibilityConfigRespDTO cfg = userVisibilityConfigApi.getConfig(userId).getCheckedData();
|
||||||
|
if (cfg != null) {
|
||||||
|
if ("all".equals(cfg.getType())) {
|
||||||
|
return ObjectDataScope.all(); // 短路
|
||||||
|
}
|
||||||
|
if ("directions".equals(cfg.getType()) && CollUtil.isNotEmpty(cfg.getDirectionCodes())) {
|
||||||
|
directionCodes.addAll(cfg.getDirectionCodes());
|
||||||
|
}
|
||||||
|
// projects 当前不消费(设计文档明示)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("[ObjectDataScope] user={} type={} ids.size={} directions.size={}",
|
||||||
|
userId, objectType, ids.size(), directionCodes.size());
|
||||||
|
|
||||||
|
if (ids.isEmpty() && directionCodes.isEmpty()) {
|
||||||
|
return ObjectDataScope.empty();
|
||||||
|
}
|
||||||
|
return ObjectDataScope.idList(ids, directionCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<Long> computeChannel1(Long userId, String objectType) {
|
||||||
|
return userObjectRoleMapper.selectActiveListByObjectTypeAndUserId(objectType, userId).stream()
|
||||||
|
.map(UserObjectRoleDO::getObjectId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通道 2:组织负责人反推。
|
||||||
|
* 通过 OrgLeaderApi 拿到当前用户作为负责人可覆盖的下属 userId 集合,
|
||||||
|
* 再查这批下属参与了哪些同类型对象,合并进 ids。
|
||||||
|
*/
|
||||||
|
Set<Long> computeChannel2(Long userId, String objectType) {
|
||||||
|
Set<Long> reachableUserIds = orgLeaderApi.getReachableUserIds(userId).getCheckedData();
|
||||||
|
if (CollUtil.isEmpty(reachableUserIds)) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
return userObjectRoleMapper.selectListByUserIdsAndObjectType(reachableUserIds, objectType).stream()
|
||||||
|
.map(UserObjectRoleDO::getObjectId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.njcn.rdms.module.project.service.member;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对象角色自动分配服务:新建产品 / 项目时按规则自动写 rdms_user_object_role。
|
||||||
|
*
|
||||||
|
* 写入规则(参 spec 7.1 节):
|
||||||
|
* - 创建者 = 责任人时,仍写 2 条 (user 同, role 不同),让 creator 信息不丢
|
||||||
|
* - 创建者 ≠ 责任人时,写 2 条 (user 不同, role 不同)
|
||||||
|
*
|
||||||
|
* watcher 批量写入参 spec 7.2 节,允许 watcher 跟 manager 是同一 user。
|
||||||
|
*
|
||||||
|
* 使用复活语义:(user, object, role) 三元组若存在 INACTIVE 行 → update 复活;
|
||||||
|
* 不存在 → INSERT;已 ACTIVE → 跳过。
|
||||||
|
*/
|
||||||
|
public interface ObjectRoleAutoAssignService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动落地 creator + manager 双角色记录(一次性写两条,给 fresh 创建流程使用)。
|
||||||
|
*
|
||||||
|
* @param objectType "product" 或 "project"
|
||||||
|
* @param objectId 新建对象 ID
|
||||||
|
* @param creatorUserId 创建者 user ID(一般取 LoginUser)
|
||||||
|
* @param managerUserId 责任人 user ID
|
||||||
|
* @param creatorRoleCode creator 角色 code(product_creator / project_creator)
|
||||||
|
* @param managerRoleCode manager 角色 code(product_manager / project_manager)
|
||||||
|
*/
|
||||||
|
void assignCreatorAndManager(String objectType, Long objectId,
|
||||||
|
Long creatorUserId, Long managerUserId,
|
||||||
|
String creatorRoleCode, String managerRoleCode);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动落地 creator 单角色记录(manager 由现有业务流程已经写好的场景使用)。
|
||||||
|
*
|
||||||
|
* @param objectType "product" 或 "project"
|
||||||
|
* @param objectId 新建对象 ID
|
||||||
|
* @param creatorUserId 创建者 user ID
|
||||||
|
* @param creatorRoleCode creator 角色 code
|
||||||
|
*/
|
||||||
|
void assignCreator(String objectType, Long objectId, Long creatorUserId, String creatorRoleCode);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动落地 watcher 角色记录(批量、自动去重;空列表直接返回)。
|
||||||
|
*
|
||||||
|
* @param objectType "product" 或 "project"
|
||||||
|
* @param objectId 对象 ID
|
||||||
|
* @param watcherUserIds 关心人 user ID 列表(可空 / 重复,会去重)
|
||||||
|
* @param watcherRoleCode watcher 角色 code(product_watcher / project_watcher)
|
||||||
|
*/
|
||||||
|
void assignWatchers(String objectType, Long objectId,
|
||||||
|
List<Long> watcherUserIds, String watcherRoleCode);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.njcn.rdms.module.project.service.member;
|
||||||
|
|
||||||
|
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
|
||||||
|
import com.njcn.rdms.module.project.constant.ProductObjectConstants;
|
||||||
|
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
|
||||||
|
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
|
||||||
|
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
|
||||||
|
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||||
|
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
|
||||||
|
import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ObjectRoleAutoAssignService 实现。
|
||||||
|
*
|
||||||
|
* 写入分支(参 spec 7.1 / 7.2 节):
|
||||||
|
* - (user, object, role) 三元组不存在 → INSERT
|
||||||
|
* - 存在 ACTIVE → 跳过(防御性,正常流程不会走到)
|
||||||
|
* - 存在 INACTIVE → 复活(status=ACTIVE, leftTime=null, joinedTime=now)
|
||||||
|
*
|
||||||
|
* 用 selectByObjectUserAndRole(不带 status 过滤)查老行,避免 INACTIVE 占索引位导致 INSERT 冲突。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class ObjectRoleAutoAssignServiceImpl implements ObjectRoleAutoAssignService {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private UserObjectRoleMapper userObjectRoleMapper;
|
||||||
|
@Resource
|
||||||
|
private ObjectPermissionApi objectPermissionApi;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void assignCreatorAndManager(String objectType, Long objectId,
|
||||||
|
Long creatorUserId, Long managerUserId,
|
||||||
|
String creatorRoleCode, String managerRoleCode) {
|
||||||
|
Long creatorRoleId = resolveRoleId(creatorRoleCode, objectType);
|
||||||
|
Long managerRoleId = resolveRoleId(managerRoleCode, objectType);
|
||||||
|
|
||||||
|
insertOrReactivate(objectType, objectId, managerUserId, managerRoleId, "auto: manager");
|
||||||
|
insertOrReactivate(objectType, objectId, creatorUserId, creatorRoleId, "auto: creator");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void assignCreator(String objectType, Long objectId, Long creatorUserId, String creatorRoleCode) {
|
||||||
|
Long creatorRoleId = resolveRoleId(creatorRoleCode, objectType);
|
||||||
|
insertOrReactivate(objectType, objectId, creatorUserId, creatorRoleId, "auto: creator");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void assignWatchers(String objectType, Long objectId,
|
||||||
|
List<Long> watcherUserIds, String watcherRoleCode) {
|
||||||
|
if (watcherUserIds == null || watcherUserIds.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Long watcherRoleId = resolveRoleId(watcherRoleCode, objectType);
|
||||||
|
watcherUserIds.stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.distinct()
|
||||||
|
.forEach(uid -> insertOrReactivate(objectType, objectId, uid, watcherRoleId, "auto: watcher"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long resolveRoleId(String roleCode, String objectType) {
|
||||||
|
ObjectRoleRespDTO role = objectPermissionApi
|
||||||
|
.getObjectRoleByCode(roleCode, ObjectRoleConstants.ROLE_SCOPE_OBJECT, objectType)
|
||||||
|
.getCheckedData();
|
||||||
|
if (role == null || role.getId() == null) {
|
||||||
|
// 按 objectType 派发到对应业务错误码,避免 IllegalStateException 透出 500
|
||||||
|
if (ProductObjectConstants.OBJECT_TYPE.equals(objectType)) {
|
||||||
|
throw exception(ErrorCodeConstants.PRODUCT_INTERNAL_ROLE_NOT_CONFIGURED, roleCode);
|
||||||
|
}
|
||||||
|
if (ProjectObjectConstants.OBJECT_TYPE.equals(objectType)) {
|
||||||
|
throw exception(ErrorCodeConstants.PROJECT_INTERNAL_ROLE_NOT_CONFIGURED, roleCode);
|
||||||
|
}
|
||||||
|
// 未知 objectType 兜底(理论不会走到——调用方都用 ProductObjectConstants / ProjectObjectConstants)
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"内置对象角色未在 system_role 找到: code=" + roleCode + ", object_type=" + objectType);
|
||||||
|
}
|
||||||
|
return role.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insertOrReactivate(String objectType, Long objectId, Long userId, Long roleId, String remark) {
|
||||||
|
UserObjectRoleDO existing = userObjectRoleMapper
|
||||||
|
.selectByObjectUserAndRole(objectType, objectId, userId, roleId);
|
||||||
|
if (existing != null && Objects.equals(existing.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
if (existing == null) {
|
||||||
|
UserObjectRoleDO row = new UserObjectRoleDO();
|
||||||
|
row.setUserId(userId);
|
||||||
|
row.setObjectType(objectType);
|
||||||
|
row.setObjectId(objectId);
|
||||||
|
row.setRoleId(roleId);
|
||||||
|
row.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
|
||||||
|
row.setJoinedTime(now);
|
||||||
|
row.setLeftTime(null);
|
||||||
|
row.setRemark(remark);
|
||||||
|
userObjectRoleMapper.insert(row);
|
||||||
|
} else {
|
||||||
|
existing.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
|
||||||
|
existing.setLeftTime(null);
|
||||||
|
existing.setJoinedTime(now);
|
||||||
|
existing.setRemark(remark);
|
||||||
|
userObjectRoleMapper.updateById(existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -29,7 +29,9 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -65,24 +67,78 @@ public class ProductMemberServiceImpl implements ProductMemberService {
|
|||||||
List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(ProductObjectConstants.OBJECT_TYPE, productId);
|
List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(ProductObjectConstants.OBJECT_TYPE, productId);
|
||||||
Map<Long, ObjectRoleRespDTO> roleMap = getRoleMap(members.stream().map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet()));
|
Map<Long, ObjectRoleRespDTO> roleMap = getRoleMap(members.stream().map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet()));
|
||||||
Map<Long, AdminUserRespDTO> userMap = getUserMap(members.stream().map(UserObjectRoleDO::getUserId).collect(Collectors.toSet()));
|
Map<Long, AdminUserRespDTO> userMap = getUserMap(members.stream().map(UserObjectRoleDO::getUserId).collect(Collectors.toSet()));
|
||||||
return members.stream().map(member -> {
|
|
||||||
ProductMemberRespVO respVO = new ProductMemberRespVO();
|
// 拆分 ACTIVE / INACTIVE:
|
||||||
respVO.setId(member.getId());
|
// - ACTIVE 行按 userId 聚合,同人多角色合并成一行(manager 优先做主),非主角色名放 additionalRoleNames
|
||||||
respVO.setUserId(member.getUserId());
|
// - INACTIVE 行保持独立(历史角色行各自留痕,便于审计)
|
||||||
AdminUserRespDTO user = userMap.get(member.getUserId());
|
List<UserObjectRoleDO> activeRows = new ArrayList<>();
|
||||||
respVO.setUserNickname(user == null ? null : user.getNickname());
|
List<UserObjectRoleDO> inactiveRows = new ArrayList<>();
|
||||||
respVO.setRoleId(member.getRoleId());
|
for (UserObjectRoleDO m : members) {
|
||||||
ObjectRoleRespDTO role = roleMap.get(member.getRoleId());
|
if (Objects.equals(m.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
|
||||||
respVO.setRoleName(role == null ? null : role.getName());
|
activeRows.add(m);
|
||||||
respVO.setRoleCode(role == null ? null : role.getCode());
|
} else {
|
||||||
respVO.setManagerFlag(Objects.equals(member.getUserId(), product.getManagerUserId())
|
inactiveRows.add(m);
|
||||||
&& Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE));
|
}
|
||||||
respVO.setStatus(member.getStatus());
|
}
|
||||||
respVO.setJoinedTime(member.getJoinedTime());
|
Map<Long, List<UserObjectRoleDO>> activeByUser = activeRows.stream()
|
||||||
respVO.setLeftTime(member.getLeftTime());
|
.collect(Collectors.groupingBy(UserObjectRoleDO::getUserId, LinkedHashMap::new, Collectors.toList()));
|
||||||
respVO.setRemark(member.getRemark());
|
|
||||||
return respVO;
|
List<ProductMemberRespVO> result = new ArrayList<>();
|
||||||
}).collect(Collectors.toList());
|
activeByUser.forEach((userId, rows) -> {
|
||||||
|
UserObjectRoleDO primary = pickPrimaryRow(rows, roleMap);
|
||||||
|
List<String> additionalRoleNames = rows.stream()
|
||||||
|
.filter(r -> !Objects.equals(r.getId(), primary.getId()))
|
||||||
|
.map(r -> {
|
||||||
|
ObjectRoleRespDTO role = roleMap.get(r.getRoleId());
|
||||||
|
return role == null ? null : role.getName();
|
||||||
|
})
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.sorted()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
result.add(toRespVO(primary, roleMap, userMap, product, additionalRoleNames));
|
||||||
|
});
|
||||||
|
// INACTIVE 行各自独立成行
|
||||||
|
inactiveRows.forEach(m -> result.add(toRespVO(m, roleMap, userMap, product, Collections.emptyList())));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同 userId 多角色时选主角色行:MANAGER_ROLE_CODE 优先,否则按 roleId 升序兜底。
|
||||||
|
*/
|
||||||
|
private UserObjectRoleDO pickPrimaryRow(List<UserObjectRoleDO> rows, Map<Long, ObjectRoleRespDTO> roleMap) {
|
||||||
|
return rows.stream()
|
||||||
|
.filter(r -> {
|
||||||
|
ObjectRoleRespDTO role = roleMap.get(r.getRoleId());
|
||||||
|
return role != null && Objects.equals(ProductObjectConstants.MANAGER_ROLE_CODE, role.getCode());
|
||||||
|
})
|
||||||
|
.findFirst()
|
||||||
|
.orElseGet(() -> rows.stream()
|
||||||
|
.min(Comparator.comparing(UserObjectRoleDO::getRoleId))
|
||||||
|
.orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductMemberRespVO toRespVO(UserObjectRoleDO member,
|
||||||
|
Map<Long, ObjectRoleRespDTO> roleMap,
|
||||||
|
Map<Long, AdminUserRespDTO> userMap,
|
||||||
|
ProductDO product,
|
||||||
|
List<String> additionalRoleNames) {
|
||||||
|
ProductMemberRespVO respVO = new ProductMemberRespVO();
|
||||||
|
respVO.setId(member.getId());
|
||||||
|
respVO.setUserId(member.getUserId());
|
||||||
|
AdminUserRespDTO user = userMap.get(member.getUserId());
|
||||||
|
respVO.setUserNickname(user == null ? null : user.getNickname());
|
||||||
|
respVO.setRoleId(member.getRoleId());
|
||||||
|
ObjectRoleRespDTO role = roleMap.get(member.getRoleId());
|
||||||
|
respVO.setRoleName(role == null ? null : role.getName());
|
||||||
|
respVO.setRoleCode(role == null ? null : role.getCode());
|
||||||
|
respVO.setManagerFlag(Objects.equals(member.getUserId(), product.getManagerUserId())
|
||||||
|
&& Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE));
|
||||||
|
respVO.setStatus(member.getStatus());
|
||||||
|
respVO.setJoinedTime(member.getJoinedTime());
|
||||||
|
respVO.setLeftTime(member.getLeftTime());
|
||||||
|
respVO.setRemark(member.getRemark());
|
||||||
|
respVO.setAdditionalRoleNames(additionalRoleNames);
|
||||||
|
return respVO;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -92,8 +148,10 @@ public class ProductMemberServiceImpl implements ProductMemberService {
|
|||||||
public Long createProductMember(Long productId, ProductMemberSaveReqVO reqVO) {
|
public Long createProductMember(Long productId, ProductMemberSaveReqVO reqVO) {
|
||||||
ProductDO product = validateProductEditable(productId);
|
ProductDO product = validateProductEditable(productId);
|
||||||
ObjectRoleRespDTO targetRole = validateProductRole(reqVO.getRoleId());
|
ObjectRoleRespDTO targetRole = validateProductRole(reqVO.getRoleId());
|
||||||
|
// 多角色支持:按 (user, object, role) 三元组判存在;不带 status 过滤以便 INACTIVE 行复活(避免唯一索引 INSERT 冲突)
|
||||||
UserObjectRoleDO existingMember = userObjectRoleMapper
|
UserObjectRoleDO existingMember = userObjectRoleMapper
|
||||||
.selectByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, productId, reqVO.getUserId());
|
.selectByObjectUserAndRole(ProductObjectConstants.OBJECT_TYPE, productId,
|
||||||
|
reqVO.getUserId(), targetRole.getId());
|
||||||
if (existingMember != null && Objects.equals(existingMember.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
|
if (existingMember != null && Objects.equals(existingMember.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
|
||||||
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_ALREADY_EXISTS);
|
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_ALREADY_EXISTS);
|
||||||
}
|
}
|
||||||
@@ -101,6 +159,8 @@ public class ProductMemberServiceImpl implements ProductMemberService {
|
|||||||
UserObjectRoleDO member;
|
UserObjectRoleDO member;
|
||||||
UserObjectRoleDO before = null;
|
UserObjectRoleDO before = null;
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
// 物理 INSERT vs INACTIVE 复活的 audit 语义区分:null=新增;INACTIVE 复活=REACTIVATE
|
||||||
|
String actionType;
|
||||||
if (existingMember == null) {
|
if (existingMember == null) {
|
||||||
member = new UserObjectRoleDO();
|
member = new UserObjectRoleDO();
|
||||||
member.setUserId(reqVO.getUserId());
|
member.setUserId(reqVO.getUserId());
|
||||||
@@ -112,6 +172,7 @@ public class ProductMemberServiceImpl implements ProductMemberService {
|
|||||||
member.setLeftTime(null);
|
member.setLeftTime(null);
|
||||||
member.setRemark(normalizeNullableText(reqVO.getRemark()));
|
member.setRemark(normalizeNullableText(reqVO.getRemark()));
|
||||||
userObjectRoleMapper.insert(member);
|
userObjectRoleMapper.insert(member);
|
||||||
|
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
|
||||||
} else {
|
} else {
|
||||||
before = cloneMember(existingMember);
|
before = cloneMember(existingMember);
|
||||||
member = existingMember;
|
member = existingMember;
|
||||||
@@ -121,9 +182,10 @@ public class ProductMemberServiceImpl implements ProductMemberService {
|
|||||||
member.setLeftTime(null);
|
member.setLeftTime(null);
|
||||||
member.setRemark(normalizeNullableText(reqVO.getRemark()));
|
member.setRemark(normalizeNullableText(reqVO.getRemark()));
|
||||||
userObjectRoleMapper.updateById(member);
|
userObjectRoleMapper.updateById(member);
|
||||||
|
actionType = ObjectActivityConstants.MEMBER_ACTION_REACTIVATE;
|
||||||
}
|
}
|
||||||
|
|
||||||
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, before, member, null);
|
writeMemberAuditLog(member, actionType, before, member, null);
|
||||||
if (isManagerRole(targetRole)) {
|
if (isManagerRole(targetRole)) {
|
||||||
transferManager(product, member, reqVO.getPreviousManagerUserId(), reqVO.getPreviousManagerRoleId(), null);
|
transferManager(product, member, reqVO.getPreviousManagerUserId(), reqVO.getPreviousManagerRoleId(), null);
|
||||||
}
|
}
|
||||||
@@ -145,6 +207,16 @@ public class ProductMemberServiceImpl implements ProductMemberService {
|
|||||||
UserObjectRoleDO before = cloneMember(member);
|
UserObjectRoleDO before = cloneMember(member);
|
||||||
member.setRemark(normalizeNullableText(reqVO.getRemark()));
|
member.setRemark(normalizeNullableText(reqVO.getRemark()));
|
||||||
|
|
||||||
|
// 多角色边界:避免 setRoleId 撞唯一索引——若 user 在该 product 已有 (targetRole) 的另一行(含 INACTIVE 历史行),
|
||||||
|
// 直接 update 会触发唯一索引 (user_id, object_type, object_id, role_id, deleted) 冲突。业务侧明确报错。
|
||||||
|
if (!Objects.equals(member.getRoleId(), targetRole.getId())) {
|
||||||
|
UserObjectRoleDO conflict = userObjectRoleMapper.selectByObjectUserAndRole(
|
||||||
|
ProductObjectConstants.OBJECT_TYPE, productId, member.getUserId(), targetRole.getId());
|
||||||
|
if (conflict != null && !Objects.equals(conflict.getId(), member.getId())) {
|
||||||
|
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_ALREADY_EXISTS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isManagerRole(targetRole)) {
|
if (isManagerRole(targetRole)) {
|
||||||
member.setRoleId(targetRole.getId());
|
member.setRoleId(targetRole.getId());
|
||||||
userObjectRoleMapper.updateById(member);
|
userObjectRoleMapper.updateById(member);
|
||||||
@@ -268,13 +340,26 @@ public class ProductMemberServiceImpl implements ProductMemberService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void transferPreviousManager(Long productId, Long previousManagerUserId, Long previousManagerRoleId, String reason) {
|
private void transferPreviousManager(Long productId, Long previousManagerUserId, Long previousManagerRoleId, String reason) {
|
||||||
|
// 多角色边界校验:若 user 在 (product, previousManagerRoleId) 已有任意行(ACTIVE 或 INACTIVE 历史行),
|
||||||
|
// 后续 update / INSERT 都会触发唯一索引 (user_id, object_type, object_id, role_id, deleted) 冲突 ——
|
||||||
|
// 一刀切抛业务异常,让用户先去人工清理历史行。selectByObjectUserAndRole 不带 status 过滤。
|
||||||
|
UserObjectRoleDO targetRoleExisting = userObjectRoleMapper.selectByObjectUserAndRole(
|
||||||
|
ProductObjectConstants.OBJECT_TYPE, productId, previousManagerUserId, previousManagerRoleId);
|
||||||
|
if (targetRoleExisting != null) {
|
||||||
|
throw exception(ErrorCodeConstants.PRODUCT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE, previousManagerRoleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多角色支持:按 (user, object, manager_role_id) 三元组定位 manager 行;user 的 creator/specialist 等其他角色行不动
|
||||||
|
Long productManagerRoleId = resolveProductManagerRoleId();
|
||||||
UserObjectRoleDO existingMember = userObjectRoleMapper
|
UserObjectRoleDO existingMember = userObjectRoleMapper
|
||||||
.selectByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, productId, previousManagerUserId);
|
.selectActiveByObjectUserAndRole(ProductObjectConstants.OBJECT_TYPE, productId,
|
||||||
|
previousManagerUserId, productManagerRoleId);
|
||||||
UserObjectRoleDO before = existingMember == null ? null : cloneMember(existingMember);
|
UserObjectRoleDO before = existingMember == null ? null : cloneMember(existingMember);
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
UserObjectRoleDO member;
|
UserObjectRoleDO member;
|
||||||
String actionType;
|
String actionType;
|
||||||
if (existingMember == null) {
|
if (existingMember == null) {
|
||||||
|
// user 当前没有 manager 角色 ACTIVE 行 —— 兼容老逻辑:插入 previousManagerRoleId 行
|
||||||
member = new UserObjectRoleDO();
|
member = new UserObjectRoleDO();
|
||||||
member.setUserId(previousManagerUserId);
|
member.setUserId(previousManagerUserId);
|
||||||
member.setObjectType(ProductObjectConstants.OBJECT_TYPE);
|
member.setObjectType(ProductObjectConstants.OBJECT_TYPE);
|
||||||
@@ -287,21 +372,30 @@ public class ProductMemberServiceImpl implements ProductMemberService {
|
|||||||
userObjectRoleMapper.insert(member);
|
userObjectRoleMapper.insert(member);
|
||||||
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
|
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
|
||||||
} else {
|
} else {
|
||||||
|
// existingMember 是 manager 行 ACTIVE,update 改 role_id 成 previousManagerRoleId("降级"该行)
|
||||||
member = existingMember;
|
member = existingMember;
|
||||||
member.setRoleId(previousManagerRoleId);
|
member.setRoleId(previousManagerRoleId);
|
||||||
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
|
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
|
||||||
member.setLeftTime(null);
|
member.setLeftTime(null);
|
||||||
if (!Objects.equals(before.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
|
actionType = ObjectActivityConstants.MEMBER_ACTION_UPDATE;
|
||||||
member.setJoinedTime(now);
|
|
||||||
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
|
|
||||||
} else {
|
|
||||||
actionType = ObjectActivityConstants.MEMBER_ACTION_UPDATE;
|
|
||||||
}
|
|
||||||
userObjectRoleMapper.updateById(member);
|
userObjectRoleMapper.updateById(member);
|
||||||
}
|
}
|
||||||
writeMemberAuditLog(member, actionType, before, member, reason);
|
writeMemberAuditLog(member, actionType, before, member, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Long resolveProductManagerRoleId() {
|
||||||
|
ObjectRoleRespDTO role = objectPermissionApi
|
||||||
|
.getObjectRoleByCode(ProductObjectConstants.MANAGER_ROLE_CODE,
|
||||||
|
ObjectRoleConstants.ROLE_SCOPE_OBJECT,
|
||||||
|
ProductObjectConstants.OBJECT_TYPE)
|
||||||
|
.getCheckedData();
|
||||||
|
if (role == null || role.getId() == null) {
|
||||||
|
throw exception(ErrorCodeConstants.PRODUCT_INTERNAL_ROLE_NOT_CONFIGURED,
|
||||||
|
ProductObjectConstants.MANAGER_ROLE_CODE);
|
||||||
|
}
|
||||||
|
return role.getId();
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isManagerRole(ObjectRoleRespDTO role) {
|
private boolean isManagerRole(ObjectRoleRespDTO role) {
|
||||||
return Objects.equals(ProductObjectConstants.MANAGER_ROLE_CODE, role.getCode());
|
return Objects.equals(ProductObjectConstants.MANAGER_ROLE_CODE, role.getCode());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import com.google.common.annotations.VisibleForTesting;
|
|||||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||||
import com.njcn.rdms.framework.common.util.json.JsonUtils;
|
import com.njcn.rdms.framework.common.util.json.JsonUtils;
|
||||||
import com.njcn.rdms.framework.common.util.object.BeanUtils;
|
import com.njcn.rdms.framework.common.util.object.BeanUtils;
|
||||||
|
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
|
||||||
|
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||||
|
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
|
||||||
|
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
|
||||||
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
|
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
|
||||||
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
|
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
|
||||||
import com.njcn.rdms.module.project.constant.ProductObjectConstants;
|
import com.njcn.rdms.module.project.constant.ProductObjectConstants;
|
||||||
@@ -81,6 +85,10 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
private AdminUserApi adminUserApi;
|
private AdminUserApi adminUserApi;
|
||||||
@Resource
|
@Resource
|
||||||
private ProductRequirementModuleMapper requirementModuleMapper;
|
private ProductRequirementModuleMapper requirementModuleMapper;
|
||||||
|
@Resource
|
||||||
|
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
|
||||||
|
@Resource
|
||||||
|
private ObjectDataScopeService objectDataScopeService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
@@ -99,6 +107,10 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
productMapper.insert(product);
|
productMapper.insert(product);
|
||||||
|
|
||||||
initManagerMemberRelation(product);
|
initManagerMemberRelation(product);
|
||||||
|
// 多角色支持:自动落 creator 行(即使 creator == manager 也独立写一条,确保创建者身份不丢)
|
||||||
|
objectRoleAutoAssignService.assignCreator(ProductObjectConstants.OBJECT_TYPE, product.getId(),
|
||||||
|
SecurityFrameworkUtils.getLoginUserId(),
|
||||||
|
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PRODUCT_CREATOR.getCode());
|
||||||
initDefaultRequirementModule(product);
|
initDefaultRequirementModule(product);
|
||||||
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_CREATE, null, initialStatus,
|
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_CREATE, null, initialStatus,
|
||||||
buildProductFieldChanges(null, product), null);
|
buildProductFieldChanges(null, product), null);
|
||||||
@@ -150,7 +162,17 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
// 5) 产品维度的"指定经理"审计:与旧 initManagerMemberRelation 末尾一致,保留活动时间线一致性
|
// 5) 产品维度的"指定经理"审计:与旧 initManagerMemberRelation 末尾一致,保留活动时间线一致性
|
||||||
writeManagerInitAuditLog(product.getId(), product.getManagerUserId());
|
writeManagerInitAuditLog(product.getId(), product.getManagerUserId());
|
||||||
|
|
||||||
// 6) 产品创建审计
|
// 6) 多角色支持:自动落 creator 行(不论 creator 是否在 members 列表里,都独立写一条 creator 角色)
|
||||||
|
objectRoleAutoAssignService.assignCreator(ProductObjectConstants.OBJECT_TYPE, product.getId(),
|
||||||
|
SecurityFrameworkUtils.getLoginUserId(),
|
||||||
|
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PRODUCT_CREATOR.getCode());
|
||||||
|
|
||||||
|
// 7) 关心人批量落地(watcherUserIds 可空;可与 members 的 user 重叠 —— 多角色合法)
|
||||||
|
objectRoleAutoAssignService.assignWatchers(ProductObjectConstants.OBJECT_TYPE, product.getId(),
|
||||||
|
reqVO.getWatcherUserIds(),
|
||||||
|
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PRODUCT_WATCHER.getCode());
|
||||||
|
|
||||||
|
// 8) 产品创建审计
|
||||||
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_CREATE, null, initialStatus,
|
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_CREATE, null, initialStatus,
|
||||||
buildProductFieldChanges(null, product), null);
|
buildProductFieldChanges(null, product), null);
|
||||||
return product.getId();
|
return product.getId();
|
||||||
@@ -241,43 +263,173 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
ProductDO product = validateProductExists(id);
|
ProductDO product = validateProductExists(id);
|
||||||
|
|
||||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||||
UserObjectRoleDO currentMember = userObjectRoleMapper
|
// 多角色支持:拿全部 ACTIVE 角色 ID 传聚合 API;主角色由 API 端按 sort 升序挑选 + 附 additionalRoleNames
|
||||||
.selectActiveByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, id, loginUserId);
|
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
|
||||||
ProductContextRespVO respVO = new ProductContextRespVO();
|
.selectActiveListByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, id, loginUserId);
|
||||||
respVO.setCurrentProduct(buildCurrentProduct(product));
|
if (!userRoles.isEmpty()) {
|
||||||
if (currentMember == null) {
|
List<Long> roleIds = userRoles.stream().map(UserObjectRoleDO::getRoleId).distinct().toList();
|
||||||
respVO.setNavs(Collections.emptyList());
|
return buildProductContext(product, roleIds, false, null);
|
||||||
respVO.setButtons(Collections.emptyList());
|
|
||||||
return respVO;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ObjectRolePermissionRespDTO permissionDetail = objectPermissionApi
|
// 显式角色为空:走 scope.contains 判定隐式 observer 兜底(设计文档 2.1 节末段)
|
||||||
.getObjectRolePermissionDetail(currentMember.getRoleId(), ObjectRoleConstants.ROLE_SCOPE_OBJECT,
|
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE);
|
||||||
|
if (!scope.contains(id, product.getDirectionCode())) {
|
||||||
|
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED, "查看");
|
||||||
|
}
|
||||||
|
return buildImplicitObserverContext(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 隐式 observer 兜底上下文:用户无显式产品角色但在 scope 范围内,按 implicit_observer_product 角色渲染菜单/权限。
|
||||||
|
*/
|
||||||
|
private ProductContextRespVO buildImplicitObserverContext(ProductDO product) {
|
||||||
|
ObjectRoleRespDTO observerRole = objectPermissionApi
|
||||||
|
.getObjectRoleByCode(
|
||||||
|
com.njcn.rdms.module.system.enums.permission.ObjectRoleConstants.IMPLICIT_OBSERVER_PRODUCT.getCode(),
|
||||||
|
ObjectRoleConstants.ROLE_SCOPE_OBJECT,
|
||||||
ProductObjectConstants.OBJECT_TYPE)
|
ProductObjectConstants.OBJECT_TYPE)
|
||||||
.getCheckedData();
|
.getCheckedData();
|
||||||
ObjectRoleRespDTO currentRole = permissionDetail == null ? null : permissionDetail.getCurrentRole();
|
if (observerRole == null || observerRole.getId() == null) {
|
||||||
|
// 角色未配置,降级为无菜单的 guest 上下文(不应发生,属配置缺失)
|
||||||
|
return buildProductContextWithoutMenus(product, true);
|
||||||
|
}
|
||||||
|
return buildProductContext(product, List.of(observerRole.getId()), true, observerRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductContextRespVO buildProductContext(ProductDO product, List<Long> roleIds, boolean guestFlag,
|
||||||
|
ObjectRoleRespDTO fallbackRole) {
|
||||||
|
ProductContextRespVO respVO = new ProductContextRespVO();
|
||||||
|
respVO.setCurrentProduct(buildCurrentProduct(product));
|
||||||
|
|
||||||
|
ObjectRolePermissionRespDTO permissionDetail = objectPermissionApi
|
||||||
|
.getObjectRolePermissionDetailMerged(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT,
|
||||||
|
ProductObjectConstants.OBJECT_TYPE)
|
||||||
|
.getCheckedData();
|
||||||
|
ObjectRoleRespDTO currentRole = permissionDetail == null || permissionDetail.getCurrentRole() == null
|
||||||
|
? fallbackRole
|
||||||
|
: permissionDetail.getCurrentRole();
|
||||||
List<ObjectMenuRespDTO> menus = permissionDetail == null || permissionDetail.getMenus() == null
|
List<ObjectMenuRespDTO> menus = permissionDetail == null || permissionDetail.getMenus() == null
|
||||||
? Collections.emptyList()
|
? Collections.emptyList()
|
||||||
: permissionDetail.getMenus();
|
: permissionDetail.getMenus();
|
||||||
respVO.setCurrentRole(buildCurrentRole(currentMember, currentRole));
|
List<String> additionalRoleNames = permissionDetail == null || permissionDetail.getAdditionalRoleNames() == null
|
||||||
|
? Collections.emptyList()
|
||||||
|
: permissionDetail.getAdditionalRoleNames();
|
||||||
|
Long primaryRoleId = currentRole != null ? currentRole.getId() : (roleIds.isEmpty() ? null : roleIds.get(0));
|
||||||
|
respVO.setCurrentRole(buildCurrentRole(primaryRoleId, currentRole, guestFlag, additionalRoleNames));
|
||||||
respVO.setNavs(buildContextNavs(menus));
|
respVO.setNavs(buildContextNavs(menus));
|
||||||
respVO.setButtons(buildContextButtons(menus));
|
respVO.setButtons(buildContextButtons(menus));
|
||||||
return respVO;
|
return respVO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ProductContextRespVO buildProductContextWithoutMenus(ProductDO product, boolean guestFlag) {
|
||||||
|
ProductContextRespVO respVO = new ProductContextRespVO();
|
||||||
|
respVO.setCurrentProduct(buildCurrentProduct(product));
|
||||||
|
respVO.setCurrentRole(buildCurrentRole(null, null, guestFlag, Collections.emptyList()));
|
||||||
|
respVO.setNavs(Collections.emptyList());
|
||||||
|
respVO.setButtons(Collections.emptyList());
|
||||||
|
return respVO;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PageResult<ProductDO> getProductPage(ProductPageReqVO pageReqVO) {
|
public PageResult<ProductDO> getProductPage(ProductPageReqVO pageReqVO) {
|
||||||
return productMapper.selectPage(pageReqVO);
|
// 计算当前用户在 product 域的数据权限范围
|
||||||
|
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||||
|
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE);
|
||||||
|
if (scope.getState() == ObjectDataScope.State.EMPTY) {
|
||||||
|
return PageResult.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留原有业务过滤条件(同 ProductMapper.selectPage 默认方法)
|
||||||
|
LambdaQueryWrapperX<ProductDO> wrapper = new LambdaQueryWrapperX<>();
|
||||||
|
if (StringUtils.hasText(pageReqVO.getKeyword())) {
|
||||||
|
wrapper.and(w -> w.like(ProductDO::getCode, pageReqVO.getKeyword())
|
||||||
|
.or()
|
||||||
|
.like(ProductDO::getName, pageReqVO.getKeyword()));
|
||||||
|
}
|
||||||
|
wrapper.eqIfPresent(ProductDO::getDirectionCode, pageReqVO.getDirectionCode())
|
||||||
|
.eqIfPresent(ProductDO::getManagerUserId, pageReqVO.getManagerUserId())
|
||||||
|
.eqIfPresent(ProductDO::getStatusCode, pageReqVO.getStatusCode())
|
||||||
|
.betweenIfPresent(BaseDO::getUpdateTime, pageReqVO.getUpdateTime())
|
||||||
|
.orderByDesc(BaseDO::getCreateTime);
|
||||||
|
|
||||||
|
// 注入 scope 数据权限过滤条件(在所有业务条件之后)
|
||||||
|
if (scope.getState() == ObjectDataScope.State.ID_LIST) {
|
||||||
|
Set<Long> ids = scope.getIds();
|
||||||
|
Set<String> dcs = scope.getDirectionCodes();
|
||||||
|
wrapper.and(w -> {
|
||||||
|
boolean addedAny = false;
|
||||||
|
if (!ids.isEmpty()) {
|
||||||
|
w.in(ProductDO::getId, ids);
|
||||||
|
addedAny = true;
|
||||||
|
}
|
||||||
|
if (!dcs.isEmpty()) {
|
||||||
|
if (addedAny) {
|
||||||
|
w.or();
|
||||||
|
}
|
||||||
|
w.in(ProductDO::getDirectionCode, dcs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// ALL 状态不加任何 scope 条件,直接查全部
|
||||||
|
|
||||||
|
return productMapper.selectPage(pageReqVO, wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ProductOverviewSummaryRespVO getProductOverviewSummary() {
|
public ProductOverviewSummaryRespVO getProductOverviewSummary() {
|
||||||
|
// 与列表对称:按当前用户的 ObjectDataScope 过滤统计数字(超管 ALL 走全表 SQL,普通用户走 scope 过滤)
|
||||||
|
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||||
|
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE);
|
||||||
|
List<Map<String, Object>> rows = buildStatusCountRows(scope);
|
||||||
|
|
||||||
ProductOverviewSummaryRespVO respVO = new ProductOverviewSummaryRespVO();
|
ProductOverviewSummaryRespVO respVO = new ProductOverviewSummaryRespVO();
|
||||||
respVO.setStatusCounts(buildProductStatusCounts(objectStatusModelMapper
|
respVO.setStatusCounts(buildProductStatusCounts(objectStatusModelMapper
|
||||||
.selectListByObjectType(ProductObjectConstants.OBJECT_TYPE), productMapper.selectStatusCountList()));
|
.selectListByObjectType(ProductObjectConstants.OBJECT_TYPE), rows));
|
||||||
return respVO;
|
return respVO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 scope 算出 (statusCode, countValue) 行集,喂给 {@link #buildProductStatusCounts}。
|
||||||
|
* EMPTY 直接空集;ALL 走原全表 GROUP BY SQL;ID_LIST 用 wrapper 取 status_code,Java 端 group + count。
|
||||||
|
*/
|
||||||
|
private List<Map<String, Object>> buildStatusCountRows(ObjectDataScope scope) {
|
||||||
|
if (scope.getState() == ObjectDataScope.State.EMPTY) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
if (scope.getState() == ObjectDataScope.State.ALL) {
|
||||||
|
return productMapper.selectStatusCountList();
|
||||||
|
}
|
||||||
|
// ID_LIST
|
||||||
|
LambdaQueryWrapperX<ProductDO> wrapper = new LambdaQueryWrapperX<>();
|
||||||
|
wrapper.select(ProductDO::getStatusCode);
|
||||||
|
Set<Long> ids = scope.getIds();
|
||||||
|
Set<String> dcs = scope.getDirectionCodes();
|
||||||
|
wrapper.and(w -> {
|
||||||
|
boolean addedAny = false;
|
||||||
|
if (!ids.isEmpty()) {
|
||||||
|
w.in(ProductDO::getId, ids);
|
||||||
|
addedAny = true;
|
||||||
|
}
|
||||||
|
if (!dcs.isEmpty()) {
|
||||||
|
if (addedAny) {
|
||||||
|
w.or();
|
||||||
|
}
|
||||||
|
w.in(ProductDO::getDirectionCode, dcs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return productMapper.selectList(wrapper).stream()
|
||||||
|
.filter(p -> p.getStatusCode() != null)
|
||||||
|
.collect(Collectors.groupingBy(ProductDO::getStatusCode, Collectors.counting()))
|
||||||
|
.entrySet().stream()
|
||||||
|
.map(e -> {
|
||||||
|
Map<String, Object> row = new HashMap<>();
|
||||||
|
row.put("statusCode", e.getKey());
|
||||||
|
row.put("countValue", e.getValue());
|
||||||
|
return row;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#reqVO.id",
|
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#reqVO.id",
|
||||||
@@ -520,13 +672,16 @@ public class ProductServiceImpl implements ProductService {
|
|||||||
return BeanUtils.toBean(product, ProductContextProductRespVO.class);
|
return BeanUtils.toBean(product, ProductContextProductRespVO.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ProductContextRoleRespVO buildCurrentRole(UserObjectRoleDO currentMember, ObjectRoleRespDTO currentRole) {
|
private ProductContextRoleRespVO buildCurrentRole(Long roleId, ObjectRoleRespDTO currentRole, boolean guestFlag,
|
||||||
|
List<String> additionalRoleNames) {
|
||||||
ProductContextRoleRespVO roleRespVO = new ProductContextRoleRespVO();
|
ProductContextRoleRespVO roleRespVO = new ProductContextRoleRespVO();
|
||||||
roleRespVO.setRoleId(currentMember.getRoleId());
|
roleRespVO.setRoleId(roleId);
|
||||||
|
roleRespVO.setGuestFlag(guestFlag);
|
||||||
if (currentRole != null) {
|
if (currentRole != null) {
|
||||||
roleRespVO.setRoleCode(currentRole.getCode());
|
roleRespVO.setRoleCode(currentRole.getCode());
|
||||||
roleRespVO.setRoleName(currentRole.getName());
|
roleRespVO.setRoleName(currentRole.getName());
|
||||||
}
|
}
|
||||||
|
roleRespVO.setAdditionalRoleNames(additionalRoleNames == null ? Collections.emptyList() : additionalRoleNames);
|
||||||
return roleRespVO;
|
return roleRespVO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -69,24 +71,78 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
|
|||||||
List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(ProjectObjectConstants.OBJECT_TYPE, projectId);
|
List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(ProjectObjectConstants.OBJECT_TYPE, projectId);
|
||||||
Map<Long, ObjectRoleRespDTO> roleMap = getRoleMap(members.stream().map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet()));
|
Map<Long, ObjectRoleRespDTO> roleMap = getRoleMap(members.stream().map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet()));
|
||||||
Map<Long, AdminUserRespDTO> userMap = getUserMap(members.stream().map(UserObjectRoleDO::getUserId).collect(Collectors.toSet()));
|
Map<Long, AdminUserRespDTO> userMap = getUserMap(members.stream().map(UserObjectRoleDO::getUserId).collect(Collectors.toSet()));
|
||||||
return members.stream().map(member -> {
|
|
||||||
ProjectMemberRespVO respVO = new ProjectMemberRespVO();
|
// 拆分 ACTIVE / INACTIVE:
|
||||||
respVO.setId(member.getId());
|
// - ACTIVE 行按 userId 聚合,同人多角色合并成一行(manager 优先做主),非主角色名放 additionalRoleNames
|
||||||
respVO.setUserId(member.getUserId());
|
// - INACTIVE 行保持独立(历史角色行各自留痕,便于审计)
|
||||||
AdminUserRespDTO user = userMap.get(member.getUserId());
|
List<UserObjectRoleDO> activeRows = new ArrayList<>();
|
||||||
respVO.setUserNickname(user == null ? null : user.getNickname());
|
List<UserObjectRoleDO> inactiveRows = new ArrayList<>();
|
||||||
respVO.setRoleId(member.getRoleId());
|
for (UserObjectRoleDO m : members) {
|
||||||
ObjectRoleRespDTO role = roleMap.get(member.getRoleId());
|
if (Objects.equals(m.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
|
||||||
respVO.setRoleName(role == null ? null : role.getName());
|
activeRows.add(m);
|
||||||
respVO.setRoleCode(role == null ? null : role.getCode());
|
} else {
|
||||||
respVO.setManagerFlag(Objects.equals(member.getUserId(), project.getManagerUserId())
|
inactiveRows.add(m);
|
||||||
&& Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE));
|
}
|
||||||
respVO.setStatus(member.getStatus());
|
}
|
||||||
respVO.setJoinedTime(member.getJoinedTime());
|
Map<Long, List<UserObjectRoleDO>> activeByUser = activeRows.stream()
|
||||||
respVO.setLeftTime(member.getLeftTime());
|
.collect(Collectors.groupingBy(UserObjectRoleDO::getUserId, LinkedHashMap::new, Collectors.toList()));
|
||||||
respVO.setRemark(member.getRemark());
|
|
||||||
return respVO;
|
List<ProjectMemberRespVO> result = new ArrayList<>();
|
||||||
}).collect(Collectors.toList());
|
activeByUser.forEach((userId, rows) -> {
|
||||||
|
UserObjectRoleDO primary = pickPrimaryRow(rows, roleMap);
|
||||||
|
List<String> additionalRoleNames = rows.stream()
|
||||||
|
.filter(r -> !Objects.equals(r.getId(), primary.getId()))
|
||||||
|
.map(r -> {
|
||||||
|
ObjectRoleRespDTO role = roleMap.get(r.getRoleId());
|
||||||
|
return role == null ? null : role.getName();
|
||||||
|
})
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.sorted()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
result.add(toRespVO(primary, roleMap, userMap, project, additionalRoleNames));
|
||||||
|
});
|
||||||
|
// INACTIVE 行各自独立成行
|
||||||
|
inactiveRows.forEach(m -> result.add(toRespVO(m, roleMap, userMap, project, Collections.emptyList())));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同 userId 多角色时选主角色行:MANAGER_ROLE_CODE 优先,否则按 roleId 升序兜底。
|
||||||
|
*/
|
||||||
|
private UserObjectRoleDO pickPrimaryRow(List<UserObjectRoleDO> rows, Map<Long, ObjectRoleRespDTO> roleMap) {
|
||||||
|
return rows.stream()
|
||||||
|
.filter(r -> {
|
||||||
|
ObjectRoleRespDTO role = roleMap.get(r.getRoleId());
|
||||||
|
return role != null && Objects.equals(ProjectObjectConstants.MANAGER_ROLE_CODE, role.getCode());
|
||||||
|
})
|
||||||
|
.findFirst()
|
||||||
|
.orElseGet(() -> rows.stream()
|
||||||
|
.min(Comparator.comparing(UserObjectRoleDO::getRoleId))
|
||||||
|
.orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProjectMemberRespVO toRespVO(UserObjectRoleDO member,
|
||||||
|
Map<Long, ObjectRoleRespDTO> roleMap,
|
||||||
|
Map<Long, AdminUserRespDTO> userMap,
|
||||||
|
ProjectDO project,
|
||||||
|
List<String> additionalRoleNames) {
|
||||||
|
ProjectMemberRespVO respVO = new ProjectMemberRespVO();
|
||||||
|
respVO.setId(member.getId());
|
||||||
|
respVO.setUserId(member.getUserId());
|
||||||
|
AdminUserRespDTO user = userMap.get(member.getUserId());
|
||||||
|
respVO.setUserNickname(user == null ? null : user.getNickname());
|
||||||
|
respVO.setRoleId(member.getRoleId());
|
||||||
|
ObjectRoleRespDTO role = roleMap.get(member.getRoleId());
|
||||||
|
respVO.setRoleName(role == null ? null : role.getName());
|
||||||
|
respVO.setRoleCode(role == null ? null : role.getCode());
|
||||||
|
respVO.setManagerFlag(Objects.equals(member.getUserId(), project.getManagerUserId())
|
||||||
|
&& Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE));
|
||||||
|
respVO.setStatus(member.getStatus());
|
||||||
|
respVO.setJoinedTime(member.getJoinedTime());
|
||||||
|
respVO.setLeftTime(member.getLeftTime());
|
||||||
|
respVO.setRemark(member.getRemark());
|
||||||
|
respVO.setAdditionalRoleNames(additionalRoleNames);
|
||||||
|
return respVO;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -97,8 +153,10 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
|
|||||||
ProjectDO project = validateProjectEditable(projectId);
|
ProjectDO project = validateProjectEditable(projectId);
|
||||||
validateMemberUser(reqVO.getUserId());
|
validateMemberUser(reqVO.getUserId());
|
||||||
ObjectRoleRespDTO targetRole = validateProjectRole(reqVO.getRoleId());
|
ObjectRoleRespDTO targetRole = validateProjectRole(reqVO.getRoleId());
|
||||||
|
// 多角色支持:按 (user, object, role) 三元组判存在;不带 status 过滤以便 INACTIVE 行复活(避免唯一索引 INSERT 冲突)
|
||||||
UserObjectRoleDO existingMember = userObjectRoleMapper
|
UserObjectRoleDO existingMember = userObjectRoleMapper
|
||||||
.selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, reqVO.getUserId());
|
.selectByObjectUserAndRole(ProjectObjectConstants.OBJECT_TYPE, projectId,
|
||||||
|
reqVO.getUserId(), targetRole.getId());
|
||||||
if (existingMember != null && Objects.equals(existingMember.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
|
if (existingMember != null && Objects.equals(existingMember.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
|
||||||
throw exception(ErrorCodeConstants.PROJECT_MEMBER_ALREADY_EXISTS);
|
throw exception(ErrorCodeConstants.PROJECT_MEMBER_ALREADY_EXISTS);
|
||||||
}
|
}
|
||||||
@@ -113,12 +171,16 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
|
|||||||
member.setJoinedTime(LocalDateTime.now());
|
member.setJoinedTime(LocalDateTime.now());
|
||||||
member.setLeftTime(null);
|
member.setLeftTime(null);
|
||||||
member.setRemark(normalizeNullableText(reqVO.getRemark()));
|
member.setRemark(normalizeNullableText(reqVO.getRemark()));
|
||||||
|
// 物理 INSERT vs INACTIVE 复活的 audit 语义区分:null=新增;INACTIVE 复活=REACTIVATE
|
||||||
|
String actionType;
|
||||||
if (existingMember == null) {
|
if (existingMember == null) {
|
||||||
userObjectRoleMapper.insert(member);
|
userObjectRoleMapper.insert(member);
|
||||||
|
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
|
||||||
} else {
|
} else {
|
||||||
userObjectRoleMapper.updateById(member);
|
userObjectRoleMapper.updateById(member);
|
||||||
|
actionType = ObjectActivityConstants.MEMBER_ACTION_REACTIVATE;
|
||||||
}
|
}
|
||||||
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, before, member, null);
|
writeMemberAuditLog(member, actionType, before, member, null);
|
||||||
|
|
||||||
if (isManagerRole(targetRole)) {
|
if (isManagerRole(targetRole)) {
|
||||||
transferManager(project, member, reqVO.getPreviousManagerUserId(), reqVO.getPreviousManagerRoleId(), null);
|
transferManager(project, member, reqVO.getPreviousManagerUserId(), reqVO.getPreviousManagerRoleId(), null);
|
||||||
@@ -140,6 +202,16 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
|
|||||||
UserObjectRoleDO before = cloneMember(member);
|
UserObjectRoleDO before = cloneMember(member);
|
||||||
member.setRemark(normalizeNullableText(reqVO.getRemark()));
|
member.setRemark(normalizeNullableText(reqVO.getRemark()));
|
||||||
|
|
||||||
|
// 多角色边界:避免 setRoleId 撞唯一索引——若 user 在该 project 已有 (targetRole) 的另一行(含 INACTIVE 历史行),
|
||||||
|
// 直接 update 会触发唯一索引 (user_id, object_type, object_id, role_id, deleted) 冲突。业务侧明确报错,避免 SQL 异常透出。
|
||||||
|
if (!Objects.equals(member.getRoleId(), targetRole.getId())) {
|
||||||
|
UserObjectRoleDO conflict = userObjectRoleMapper.selectByObjectUserAndRole(
|
||||||
|
ProjectObjectConstants.OBJECT_TYPE, projectId, member.getUserId(), targetRole.getId());
|
||||||
|
if (conflict != null && !Objects.equals(conflict.getId(), member.getId())) {
|
||||||
|
throw exception(ErrorCodeConstants.PROJECT_MEMBER_ALREADY_EXISTS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isManagerRole(targetRole)) {
|
if (isManagerRole(targetRole)) {
|
||||||
// 项目经理交接只切换负责人并调整原经理角色,不再把原经理自动移出项目团队。
|
// 项目经理交接只切换负责人并调整原经理角色,不再把原经理自动移出项目团队。
|
||||||
member.setRoleId(targetRole.getId());
|
member.setRoleId(targetRole.getId());
|
||||||
@@ -302,13 +374,26 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void transferPreviousManager(Long projectId, Long previousManagerUserId, Long previousManagerRoleId, String reason) {
|
private void transferPreviousManager(Long projectId, Long previousManagerUserId, Long previousManagerRoleId, String reason) {
|
||||||
|
// 多角色边界校验:若 user 在 (project, previousManagerRoleId) 已有任意行(ACTIVE 或 INACTIVE 历史行),
|
||||||
|
// 后续 update / INSERT 都会触发唯一索引 (user_id, object_type, object_id, role_id, deleted) 冲突 ——
|
||||||
|
// 一刀切抛业务异常,让用户先去人工清理历史行。selectByObjectUserAndRole 不带 status 过滤。
|
||||||
|
UserObjectRoleDO targetRoleExisting = userObjectRoleMapper.selectByObjectUserAndRole(
|
||||||
|
ProjectObjectConstants.OBJECT_TYPE, projectId, previousManagerUserId, previousManagerRoleId);
|
||||||
|
if (targetRoleExisting != null) {
|
||||||
|
throw exception(ErrorCodeConstants.PROJECT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE, previousManagerRoleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多角色支持:按 (user, object, manager_role_id) 三元组定位 manager 行;user 的 creator/dev 等其他角色行不动
|
||||||
|
Long projectManagerRoleId = resolveProjectManagerRoleId();
|
||||||
UserObjectRoleDO existingMember = userObjectRoleMapper
|
UserObjectRoleDO existingMember = userObjectRoleMapper
|
||||||
.selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, previousManagerUserId);
|
.selectActiveByObjectUserAndRole(ProjectObjectConstants.OBJECT_TYPE, projectId,
|
||||||
|
previousManagerUserId, projectManagerRoleId);
|
||||||
UserObjectRoleDO before = existingMember == null ? null : cloneMember(existingMember);
|
UserObjectRoleDO before = existingMember == null ? null : cloneMember(existingMember);
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
UserObjectRoleDO member;
|
UserObjectRoleDO member;
|
||||||
String actionType;
|
String actionType;
|
||||||
if (existingMember == null) {
|
if (existingMember == null) {
|
||||||
|
// user 当前没有 manager 角色 ACTIVE 行(罕见,可能业务上不该走到这)—— 仍兼容老逻辑:插入 previousManagerRoleId 行
|
||||||
member = new UserObjectRoleDO();
|
member = new UserObjectRoleDO();
|
||||||
member.setUserId(previousManagerUserId);
|
member.setUserId(previousManagerUserId);
|
||||||
member.setObjectType(ProjectObjectConstants.OBJECT_TYPE);
|
member.setObjectType(ProjectObjectConstants.OBJECT_TYPE);
|
||||||
@@ -321,21 +406,30 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
|
|||||||
userObjectRoleMapper.insert(member);
|
userObjectRoleMapper.insert(member);
|
||||||
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
|
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
|
||||||
} else {
|
} else {
|
||||||
|
// existingMember 是 manager 行 ACTIVE,update 改 role_id 成 previousManagerRoleId("降级"该行)
|
||||||
member = existingMember;
|
member = existingMember;
|
||||||
member.setRoleId(previousManagerRoleId);
|
member.setRoleId(previousManagerRoleId);
|
||||||
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
|
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
|
||||||
member.setLeftTime(null);
|
member.setLeftTime(null);
|
||||||
if (!Objects.equals(before.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
|
actionType = ObjectActivityConstants.MEMBER_ACTION_UPDATE;
|
||||||
member.setJoinedTime(now);
|
|
||||||
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
|
|
||||||
} else {
|
|
||||||
actionType = ObjectActivityConstants.MEMBER_ACTION_UPDATE;
|
|
||||||
}
|
|
||||||
userObjectRoleMapper.updateById(member);
|
userObjectRoleMapper.updateById(member);
|
||||||
}
|
}
|
||||||
writeMemberAuditLog(member, actionType, before, member, reason);
|
writeMemberAuditLog(member, actionType, before, member, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Long resolveProjectManagerRoleId() {
|
||||||
|
ObjectRoleRespDTO role = objectPermissionApi
|
||||||
|
.getObjectRoleByCode(ProjectObjectConstants.MANAGER_ROLE_CODE,
|
||||||
|
ObjectRoleConstants.ROLE_SCOPE_OBJECT,
|
||||||
|
ProjectObjectConstants.OBJECT_TYPE)
|
||||||
|
.getCheckedData();
|
||||||
|
if (role == null || role.getId() == null) {
|
||||||
|
throw exception(ErrorCodeConstants.PROJECT_INTERNAL_ROLE_NOT_CONFIGURED,
|
||||||
|
ProjectObjectConstants.MANAGER_ROLE_CODE);
|
||||||
|
}
|
||||||
|
return role.getId();
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isManagerRole(ObjectRoleRespDTO role) {
|
private boolean isManagerRole(ObjectRoleRespDTO role) {
|
||||||
return Objects.equals(ProjectObjectConstants.MANAGER_ROLE_CODE, role.getCode());
|
return Objects.equals(ProjectObjectConstants.MANAGER_ROLE_CODE, role.getCode());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
|||||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
|
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
|
||||||
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||||
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
|
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
|
||||||
|
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
|
||||||
|
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||||
|
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
|
||||||
|
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
|
||||||
import com.njcn.rdms.module.system.api.dict.DictDataApi;
|
import com.njcn.rdms.module.system.api.dict.DictDataApi;
|
||||||
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
|
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
|
||||||
import com.njcn.rdms.module.system.api.permission.dto.ObjectMenuRespDTO;
|
import com.njcn.rdms.module.system.api.permission.dto.ObjectMenuRespDTO;
|
||||||
@@ -59,6 +63,7 @@ import java.time.LocalDateTime;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -102,6 +107,10 @@ class ProjectServiceImpl implements ProjectService {
|
|||||||
private DictDataApi dictDataApi;
|
private DictDataApi dictDataApi;
|
||||||
@Resource
|
@Resource
|
||||||
private ProjectRequirementModuleMapper projectRequirementModuleMapper;
|
private ProjectRequirementModuleMapper projectRequirementModuleMapper;
|
||||||
|
@Resource
|
||||||
|
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
|
||||||
|
@Resource
|
||||||
|
private ObjectDataScopeService objectDataScopeService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
@@ -132,6 +141,10 @@ class ProjectServiceImpl implements ProjectService {
|
|||||||
projectMapper.insert(project);
|
projectMapper.insert(project);
|
||||||
|
|
||||||
initManagerMemberRelation(project);
|
initManagerMemberRelation(project);
|
||||||
|
// 多角色支持:自动落 creator 行(即使 creator == manager 也独立写一条,确保创建者身份不丢)
|
||||||
|
objectRoleAutoAssignService.assignCreator(ProjectObjectConstants.OBJECT_TYPE, project.getId(),
|
||||||
|
SecurityFrameworkUtils.getLoginUserId(),
|
||||||
|
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PROJECT_CREATOR.getCode());
|
||||||
initDefaultRequirementModule(project);
|
initDefaultRequirementModule(project);
|
||||||
writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_CREATE, null, initialStatus,
|
writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_CREATE, null, initialStatus,
|
||||||
buildProjectFieldChanges(null, project), null);
|
buildProjectFieldChanges(null, project), null);
|
||||||
@@ -195,7 +208,17 @@ class ProjectServiceImpl implements ProjectService {
|
|||||||
// 5) 项目维度的"指定经理"审计:与旧 initManagerMemberRelation 末尾一致,保留活动时间线一致性
|
// 5) 项目维度的"指定经理"审计:与旧 initManagerMemberRelation 末尾一致,保留活动时间线一致性
|
||||||
writeManagerChangeAuditLog(project.getId(), null, project.getManagerUserId(), null);
|
writeManagerChangeAuditLog(project.getId(), null, project.getManagerUserId(), null);
|
||||||
|
|
||||||
// 6) 项目创建审计
|
// 6) 多角色支持:自动落 creator 行(不论 creator 是否在 members 列表里,都独立写一条 creator 角色)
|
||||||
|
objectRoleAutoAssignService.assignCreator(ProjectObjectConstants.OBJECT_TYPE, project.getId(),
|
||||||
|
SecurityFrameworkUtils.getLoginUserId(),
|
||||||
|
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PROJECT_CREATOR.getCode());
|
||||||
|
|
||||||
|
// 7) 关心人批量落地(watcherUserIds 可空;可与 members 的 user 重叠 —— 多角色合法)
|
||||||
|
objectRoleAutoAssignService.assignWatchers(ProjectObjectConstants.OBJECT_TYPE, project.getId(),
|
||||||
|
reqVO.getWatcherUserIds(),
|
||||||
|
com.njcn.rdms.module.system.enums.permission.RoleCodeEnum.PROJECT_WATCHER.getCode());
|
||||||
|
|
||||||
|
// 8) 项目创建审计
|
||||||
writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_CREATE, null, initialStatus,
|
writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_CREATE, null, initialStatus,
|
||||||
buildProjectFieldChanges(null, project), null);
|
buildProjectFieldChanges(null, project), null);
|
||||||
return project.getId();
|
return project.getId();
|
||||||
@@ -351,29 +374,46 @@ class ProjectServiceImpl implements ProjectService {
|
|||||||
public ProjectContextRespVO getProjectContext(Long id) {
|
public ProjectContextRespVO getProjectContext(Long id) {
|
||||||
ProjectDO project = validateProjectExists(id);
|
ProjectDO project = validateProjectExists(id);
|
||||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||||
UserObjectRoleDO currentMember = userObjectRoleMapper
|
// 多角色支持:拿全部 ACTIVE 角色 ID 传聚合 API;主角色按 sort 升序由 API 挑选 + 附 additionalRoleNames
|
||||||
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, id, loginUserId);
|
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
|
||||||
if (currentMember != null) {
|
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, id, loginUserId);
|
||||||
return buildProjectContext(project, currentMember.getRoleId(), false, null);
|
if (!userRoles.isEmpty()) {
|
||||||
|
List<Long> roleIds = userRoles.stream().map(UserObjectRoleDO::getRoleId).distinct().toList();
|
||||||
|
return buildProjectContext(project, roleIds, false, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
ObjectRoleRespDTO visitorRole = objectPermissionApi
|
// 显式角色为空:走 scope.contains 判定隐式 observer 兜底(设计文档 2.1 节末段)
|
||||||
.getObjectRoleByCode(ProjectObjectConstants.VISITOR_ROLE_CODE, ObjectRoleConstants.ROLE_SCOPE_OBJECT,
|
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
|
||||||
ProjectObjectConstants.OBJECT_TYPE)
|
if (!scope.contains(id, project.getDirectionCode())) {
|
||||||
.getCheckedData();
|
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED, "查看");
|
||||||
if (visitorRole == null || visitorRole.getId() == null) {
|
|
||||||
return buildProjectContextWithoutMenus(project, true);
|
|
||||||
}
|
}
|
||||||
return buildProjectContext(project, visitorRole.getId(), true, visitorRole);
|
return buildImplicitObserverContext(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ProjectContextRespVO buildProjectContext(ProjectDO project, Long roleId, boolean guestFlag,
|
/**
|
||||||
|
* 隐式 observer 兜底上下文:用户无显式项目角色但在 scope 范围内,按 implicit_observer_project 角色渲染菜单/权限。
|
||||||
|
*/
|
||||||
|
private ProjectContextRespVO buildImplicitObserverContext(ProjectDO project) {
|
||||||
|
ObjectRoleRespDTO observerRole = objectPermissionApi
|
||||||
|
.getObjectRoleByCode(
|
||||||
|
com.njcn.rdms.module.system.enums.permission.ObjectRoleConstants.IMPLICIT_OBSERVER_PROJECT.getCode(),
|
||||||
|
ObjectRoleConstants.ROLE_SCOPE_OBJECT,
|
||||||
|
ProjectObjectConstants.OBJECT_TYPE)
|
||||||
|
.getCheckedData();
|
||||||
|
if (observerRole == null || observerRole.getId() == null) {
|
||||||
|
// 角色未配置,降级为无菜单的 guest 上下文(不应发生,属配置缺失)
|
||||||
|
return buildProjectContextWithoutMenus(project, true);
|
||||||
|
}
|
||||||
|
return buildProjectContext(project, List.of(observerRole.getId()), true, observerRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProjectContextRespVO buildProjectContext(ProjectDO project, List<Long> roleIds, boolean guestFlag,
|
||||||
ObjectRoleRespDTO fallbackRole) {
|
ObjectRoleRespDTO fallbackRole) {
|
||||||
ProjectContextRespVO respVO = new ProjectContextRespVO();
|
ProjectContextRespVO respVO = new ProjectContextRespVO();
|
||||||
respVO.setCurrentProject(buildCurrentProject(project));
|
respVO.setCurrentProject(buildCurrentProject(project));
|
||||||
|
|
||||||
ObjectRolePermissionRespDTO permissionDetail = objectPermissionApi
|
ObjectRolePermissionRespDTO permissionDetail = objectPermissionApi
|
||||||
.getObjectRolePermissionDetail(roleId, ObjectRoleConstants.ROLE_SCOPE_OBJECT,
|
.getObjectRolePermissionDetailMerged(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT,
|
||||||
ProjectObjectConstants.OBJECT_TYPE)
|
ProjectObjectConstants.OBJECT_TYPE)
|
||||||
.getCheckedData();
|
.getCheckedData();
|
||||||
ObjectRoleRespDTO currentRole = permissionDetail == null || permissionDetail.getCurrentRole() == null
|
ObjectRoleRespDTO currentRole = permissionDetail == null || permissionDetail.getCurrentRole() == null
|
||||||
@@ -382,7 +422,11 @@ class ProjectServiceImpl implements ProjectService {
|
|||||||
List<ObjectMenuRespDTO> menus = permissionDetail == null || permissionDetail.getMenus() == null
|
List<ObjectMenuRespDTO> menus = permissionDetail == null || permissionDetail.getMenus() == null
|
||||||
? Collections.emptyList()
|
? Collections.emptyList()
|
||||||
: permissionDetail.getMenus();
|
: permissionDetail.getMenus();
|
||||||
respVO.setCurrentRole(buildCurrentRole(roleId, currentRole, guestFlag));
|
List<String> additionalRoleNames = permissionDetail == null || permissionDetail.getAdditionalRoleNames() == null
|
||||||
|
? Collections.emptyList()
|
||||||
|
: permissionDetail.getAdditionalRoleNames();
|
||||||
|
Long primaryRoleId = currentRole != null ? currentRole.getId() : (roleIds.isEmpty() ? null : roleIds.get(0));
|
||||||
|
respVO.setCurrentRole(buildCurrentRole(primaryRoleId, currentRole, guestFlag, additionalRoleNames));
|
||||||
respVO.setNavs(buildContextNavs(menus));
|
respVO.setNavs(buildContextNavs(menus));
|
||||||
respVO.setButtons(buildContextButtons(menus));
|
respVO.setButtons(buildContextButtons(menus));
|
||||||
return respVO;
|
return respVO;
|
||||||
@@ -390,17 +434,106 @@ class ProjectServiceImpl implements ProjectService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PageResult<ProjectDO> getProjectPage(ProjectPageReqVO pageReqVO) {
|
public PageResult<ProjectDO> getProjectPage(ProjectPageReqVO pageReqVO) {
|
||||||
return projectMapper.selectPage(pageReqVO);
|
// 计算当前用户在 project 域的数据权限范围
|
||||||
|
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||||
|
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
|
||||||
|
if (scope.getState() == ObjectDataScope.State.EMPTY) {
|
||||||
|
return PageResult.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留原有业务过滤条件(同 ProjectMapper.selectPage 默认方法)
|
||||||
|
LambdaQueryWrapperX<ProjectDO> wrapper = new LambdaQueryWrapperX<>();
|
||||||
|
if (StringUtils.hasText(pageReqVO.getKeyword())) {
|
||||||
|
wrapper.and(w -> w.like(ProjectDO::getProjectCode, pageReqVO.getKeyword())
|
||||||
|
.or()
|
||||||
|
.like(ProjectDO::getProjectName, pageReqVO.getKeyword()));
|
||||||
|
}
|
||||||
|
wrapper.eqIfPresent(ProjectDO::getProjectType, pageReqVO.getProjectType())
|
||||||
|
.eqIfPresent(ProjectDO::getDirectionCode, pageReqVO.getDirectionCode())
|
||||||
|
.eqIfPresent(ProjectDO::getProductId, pageReqVO.getProductId())
|
||||||
|
.eqIfPresent(ProjectDO::getManagerUserId, pageReqVO.getManagerUserId())
|
||||||
|
.eqIfPresent(ProjectDO::getStatusCode, pageReqVO.getStatusCode())
|
||||||
|
.betweenIfPresent(BaseDO::getUpdateTime, pageReqVO.getUpdateTime())
|
||||||
|
.orderByDesc(BaseDO::getCreateTime);
|
||||||
|
|
||||||
|
// 注入 scope 数据权限过滤条件(在所有业务条件之后)
|
||||||
|
if (scope.getState() == ObjectDataScope.State.ID_LIST) {
|
||||||
|
Set<Long> ids = scope.getIds();
|
||||||
|
Set<String> dcs = scope.getDirectionCodes();
|
||||||
|
wrapper.and(w -> {
|
||||||
|
boolean addedAny = false;
|
||||||
|
if (!ids.isEmpty()) {
|
||||||
|
w.in(ProjectDO::getId, ids);
|
||||||
|
addedAny = true;
|
||||||
|
}
|
||||||
|
if (!dcs.isEmpty()) {
|
||||||
|
if (addedAny) {
|
||||||
|
w.or();
|
||||||
|
}
|
||||||
|
w.in(ProjectDO::getDirectionCode, dcs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// ALL 状态不加任何 scope 条件,直接查全部
|
||||||
|
|
||||||
|
return projectMapper.selectPage(pageReqVO, wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ProjectOverviewSummaryRespVO getProjectOverviewSummary() {
|
public ProjectOverviewSummaryRespVO getProjectOverviewSummary() {
|
||||||
|
// 与列表对称:按当前用户的 ObjectDataScope 过滤统计数字(超管 ALL 走全表 SQL,普通用户走 scope 过滤)
|
||||||
|
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||||
|
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
|
||||||
|
List<Map<String, Object>> rows = buildStatusCountRows(scope);
|
||||||
|
|
||||||
ProjectOverviewSummaryRespVO respVO = new ProjectOverviewSummaryRespVO();
|
ProjectOverviewSummaryRespVO respVO = new ProjectOverviewSummaryRespVO();
|
||||||
respVO.setStatusCounts(buildProjectStatusCounts(objectStatusModelMapper
|
respVO.setStatusCounts(buildProjectStatusCounts(objectStatusModelMapper
|
||||||
.selectListByObjectType(ProjectObjectConstants.OBJECT_TYPE), projectMapper.selectStatusCountList()));
|
.selectListByObjectType(ProjectObjectConstants.OBJECT_TYPE), rows));
|
||||||
return respVO;
|
return respVO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 scope 算出 (statusCode, countValue) 行集,喂给 {@link #buildProjectStatusCounts}。
|
||||||
|
* EMPTY 直接空集;ALL 走原全表 GROUP BY SQL;ID_LIST 用 wrapper 取 status_code,Java 端 group + count。
|
||||||
|
*/
|
||||||
|
private List<Map<String, Object>> buildStatusCountRows(ObjectDataScope scope) {
|
||||||
|
if (scope.getState() == ObjectDataScope.State.EMPTY) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
if (scope.getState() == ObjectDataScope.State.ALL) {
|
||||||
|
return projectMapper.selectStatusCountList();
|
||||||
|
}
|
||||||
|
// ID_LIST
|
||||||
|
LambdaQueryWrapperX<ProjectDO> wrapper = new LambdaQueryWrapperX<>();
|
||||||
|
wrapper.select(ProjectDO::getStatusCode);
|
||||||
|
Set<Long> ids = scope.getIds();
|
||||||
|
Set<String> dcs = scope.getDirectionCodes();
|
||||||
|
wrapper.and(w -> {
|
||||||
|
boolean addedAny = false;
|
||||||
|
if (!ids.isEmpty()) {
|
||||||
|
w.in(ProjectDO::getId, ids);
|
||||||
|
addedAny = true;
|
||||||
|
}
|
||||||
|
if (!dcs.isEmpty()) {
|
||||||
|
if (addedAny) {
|
||||||
|
w.or();
|
||||||
|
}
|
||||||
|
w.in(ProjectDO::getDirectionCode, dcs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return projectMapper.selectList(wrapper).stream()
|
||||||
|
.filter(p -> p.getStatusCode() != null)
|
||||||
|
.collect(Collectors.groupingBy(ProjectDO::getStatusCode, Collectors.counting()))
|
||||||
|
.entrySet().stream()
|
||||||
|
.map(e -> {
|
||||||
|
Map<String, Object> row = new HashMap<>();
|
||||||
|
row.put("statusCode", e.getKey());
|
||||||
|
row.put("countValue", e.getValue());
|
||||||
|
return row;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
private String getProductName(Long productId) {
|
private String getProductName(Long productId) {
|
||||||
if (productId == null) {
|
if (productId == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -771,9 +904,11 @@ class ProjectServiceImpl implements ProjectService {
|
|||||||
if (oldManagerUserId == null) {
|
if (oldManagerUserId == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
UserObjectRoleDO oldMember = userObjectRoleMapper.selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE,
|
// 多角色支持:只 INACTIVATE manager 角色那一行;user 在项目内的 creator/dev 等其他角色行不动
|
||||||
projectId, oldManagerUserId);
|
Long managerRoleId = resolveProjectManagerRoleId();
|
||||||
if (oldMember == null || !Objects.equals(oldMember.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
|
UserObjectRoleDO oldMember = userObjectRoleMapper.selectActiveByObjectUserAndRole(
|
||||||
|
ProjectObjectConstants.OBJECT_TYPE, projectId, oldManagerUserId, managerRoleId);
|
||||||
|
if (oldMember == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
UserObjectRoleDO before = cloneMember(oldMember);
|
UserObjectRoleDO before = cloneMember(oldMember);
|
||||||
@@ -784,10 +919,13 @@ class ProjectServiceImpl implements ProjectService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void ensureManagerRelation(Long projectId, Long managerUserId, Long managerRoleId, String reason) {
|
private void ensureManagerRelation(Long projectId, Long managerUserId, Long managerRoleId, String reason) {
|
||||||
UserObjectRoleDO existingMember = userObjectRoleMapper.selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE,
|
// 多角色支持:按 (user, object, manager_role_id) 三元组定位 manager 行;
|
||||||
projectId, managerUserId);
|
// 用 selectByObjectUserAndRole(不带 status 过滤)拿 INACTIVE 老行复活,避免 INSERT 冲突唯一索引
|
||||||
|
UserObjectRoleDO existingMember = userObjectRoleMapper.selectByObjectUserAndRole(
|
||||||
|
ProjectObjectConstants.OBJECT_TYPE, projectId, managerUserId, managerRoleId);
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
if (existingMember == null) {
|
if (existingMember == null) {
|
||||||
|
// user 在项目内还没有 manager 角色行(可能已有 creator/dev 等其他角色,不影响)→ insert
|
||||||
UserObjectRoleDO member = new UserObjectRoleDO();
|
UserObjectRoleDO member = new UserObjectRoleDO();
|
||||||
member.setUserId(managerUserId);
|
member.setUserId(managerUserId);
|
||||||
member.setObjectType(ProjectObjectConstants.OBJECT_TYPE);
|
member.setObjectType(ProjectObjectConstants.OBJECT_TYPE);
|
||||||
@@ -800,8 +938,8 @@ class ProjectServiceImpl implements ProjectService {
|
|||||||
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, null, member, reason);
|
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, null, member, reason);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// existingMember 已是 (user, object, manager_role_id) 行(可能 ACTIVE 或 INACTIVE)→ 激活
|
||||||
UserObjectRoleDO before = cloneMember(existingMember);
|
UserObjectRoleDO before = cloneMember(existingMember);
|
||||||
existingMember.setRoleId(managerRoleId);
|
|
||||||
existingMember.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
|
existingMember.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
|
||||||
existingMember.setLeftTime(null);
|
existingMember.setLeftTime(null);
|
||||||
if (!Objects.equals(before.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
|
if (!Objects.equals(before.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
|
||||||
@@ -811,6 +949,18 @@ class ProjectServiceImpl implements ProjectService {
|
|||||||
writeMemberAuditLog(existingMember, ObjectActivityConstants.MEMBER_ACTION_UPDATE, before, existingMember, reason);
|
writeMemberAuditLog(existingMember, ObjectActivityConstants.MEMBER_ACTION_UPDATE, before, existingMember, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Long resolveProjectManagerRoleId() {
|
||||||
|
ObjectRoleRespDTO role = objectPermissionApi
|
||||||
|
.getObjectRoleByCode(ProjectObjectConstants.MANAGER_ROLE_CODE,
|
||||||
|
ObjectRoleConstants.ROLE_SCOPE_OBJECT,
|
||||||
|
ProjectObjectConstants.OBJECT_TYPE)
|
||||||
|
.getCheckedData();
|
||||||
|
if (role == null || role.getId() == null) {
|
||||||
|
throw new IllegalStateException("内置角色 " + ProjectObjectConstants.MANAGER_ROLE_CODE + " 未在 system_role 找到");
|
||||||
|
}
|
||||||
|
return role.getId();
|
||||||
|
}
|
||||||
|
|
||||||
private void changeStatus(ProjectDO project, String actionCode, String reason) {
|
private void changeStatus(ProjectDO project, String actionCode, String reason) {
|
||||||
String fromStatus = project.getStatusCode();
|
String fromStatus = project.getStatusCode();
|
||||||
ObjectStatusTransitionDO transition = validateProjectTransition(fromStatus, actionCode, reason);
|
ObjectStatusTransitionDO transition = validateProjectTransition(fromStatus, actionCode, reason);
|
||||||
@@ -856,13 +1006,14 @@ class ProjectServiceImpl implements ProjectService {
|
|||||||
private ProjectContextRespVO buildProjectContextWithoutMenus(ProjectDO project, boolean guestFlag) {
|
private ProjectContextRespVO buildProjectContextWithoutMenus(ProjectDO project, boolean guestFlag) {
|
||||||
ProjectContextRespVO respVO = new ProjectContextRespVO();
|
ProjectContextRespVO respVO = new ProjectContextRespVO();
|
||||||
respVO.setCurrentProject(buildCurrentProject(project));
|
respVO.setCurrentProject(buildCurrentProject(project));
|
||||||
respVO.setCurrentRole(buildCurrentRole(null, null, guestFlag));
|
respVO.setCurrentRole(buildCurrentRole(null, null, guestFlag, Collections.emptyList()));
|
||||||
respVO.setNavs(Collections.emptyList());
|
respVO.setNavs(Collections.emptyList());
|
||||||
respVO.setButtons(Collections.emptyList());
|
respVO.setButtons(Collections.emptyList());
|
||||||
return respVO;
|
return respVO;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ProjectContextRoleRespVO buildCurrentRole(Long roleId, ObjectRoleRespDTO currentRole, boolean guestFlag) {
|
private ProjectContextRoleRespVO buildCurrentRole(Long roleId, ObjectRoleRespDTO currentRole, boolean guestFlag,
|
||||||
|
List<String> additionalRoleNames) {
|
||||||
ProjectContextRoleRespVO roleRespVO = new ProjectContextRoleRespVO();
|
ProjectContextRoleRespVO roleRespVO = new ProjectContextRoleRespVO();
|
||||||
roleRespVO.setRoleId(roleId);
|
roleRespVO.setRoleId(roleId);
|
||||||
roleRespVO.setGuestFlag(guestFlag);
|
roleRespVO.setGuestFlag(guestFlag);
|
||||||
@@ -870,6 +1021,7 @@ class ProjectServiceImpl implements ProjectService {
|
|||||||
roleRespVO.setRoleCode(currentRole.getCode());
|
roleRespVO.setRoleCode(currentRole.getCode());
|
||||||
roleRespVO.setRoleName(currentRole.getName());
|
roleRespVO.setRoleName(currentRole.getName());
|
||||||
}
|
}
|
||||||
|
roleRespVO.setAdditionalRoleNames(additionalRoleNames == null ? Collections.emptyList() : additionalRoleNames);
|
||||||
return roleRespVO;
|
return roleRespVO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.njcn.rdms.module.project.service.project;
|
|||||||
|
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardReqVO;
|
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardReqVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardRespVO;
|
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardRespVO;
|
||||||
|
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageReqVO;
|
||||||
|
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageRespVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO;
|
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardRespVO;
|
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardRespVO;
|
||||||
|
|
||||||
@@ -11,4 +13,12 @@ public interface ProjectStatusBoardService {
|
|||||||
|
|
||||||
ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO);
|
ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 看板视图任务分页:一次请求返回若干状态列,每列附带当前页切片 + 该列总数。
|
||||||
|
* <p>statusCode 缺省=按状态字典返回全部列(空列也回,list=[]、total=0);传若干个=只返回这些状态的列,
|
||||||
|
* 字典外的值静默忽略。pageNo / pageSize 应用到返回的所有列(每列各自分页但页码统一)。
|
||||||
|
* <p>list 元素结构与 /tasks/page 完全一致(共享 {@code assembleTaskRespVOPage} 装配方法)。
|
||||||
|
*/
|
||||||
|
ProjectTaskBoardPageRespVO getTaskBoardPage(Long projectId, Long executionId, ProjectTaskBoardPageReqVO reqVO);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
package com.njcn.rdms.module.project.service.project;
|
package com.njcn.rdms.module.project.service.project;
|
||||||
|
|
||||||
|
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||||
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
|
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
|
||||||
import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
|
import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardReqVO;
|
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardReqVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardRespVO;
|
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardRespVO;
|
||||||
|
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageReqVO;
|
||||||
|
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskBoardPageRespVO;
|
||||||
|
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.ProjectTaskStatusBoardReqVO;
|
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardReqVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardRespVO;
|
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusBoardRespVO;
|
||||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
|
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
|
||||||
|
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
|
||||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
|
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
|
||||||
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
|
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
|
||||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||||
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
|
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
|
||||||
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
|
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
|
||||||
|
import com.njcn.rdms.module.project.service.project.task.ProjectTaskService;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService {
|
public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService {
|
||||||
@@ -31,6 +43,8 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
|
|||||||
private ProjectTaskMapper projectTaskMapper;
|
private ProjectTaskMapper projectTaskMapper;
|
||||||
@Resource
|
@Resource
|
||||||
private VisibilityScopeResolver visibilityScopeResolver;
|
private VisibilityScopeResolver visibilityScopeResolver;
|
||||||
|
@Resource
|
||||||
|
private ProjectTaskService projectTaskService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ProjectExecutionStatusBoardRespVO getExecutionStatusBoard(Long projectId, ProjectExecutionStatusBoardReqVO reqVO) {
|
public ProjectExecutionStatusBoardRespVO getExecutionStatusBoard(Long projectId, ProjectExecutionStatusBoardReqVO reqVO) {
|
||||||
@@ -42,19 +56,97 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO) {
|
public ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO) {
|
||||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
VisibilityScope scope = resolveTaskScope(projectId, executionId);
|
||||||
VisibilityScope scope = visibilityScopeResolver.resolveForExecution(projectId, executionId, userId);
|
|
||||||
// 执行 owner = 当前用户 → 看本执行下全部任务,等价于 seesAll。
|
|
||||||
if (!scope.seesAll()) {
|
|
||||||
ProjectExecutionDO exec = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
|
|
||||||
if (exec != null && Objects.equals(exec.getOwnerId(), userId)) {
|
|
||||||
scope = VisibilityScope.all();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
|
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
|
||||||
return buildTaskStatusBoard(projectId, executionId, scope, reqVO, statusModels);
|
return buildTaskStatusBoard(projectId, executionId, scope, reqVO, statusModels);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ProjectTaskBoardPageRespVO getTaskBoardPage(Long projectId, Long executionId, ProjectTaskBoardPageReqVO reqVO) {
|
||||||
|
VisibilityScope scope = resolveTaskScope(projectId, executionId);
|
||||||
|
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper
|
||||||
|
.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
|
||||||
|
|
||||||
|
// 列选择:入参为空 → 全集;非空 → 与字典做交集(字典外 statusCode 静默忽略)
|
||||||
|
Set<String> selected = collectSelectedStatusCodes(reqVO.getStatusCode());
|
||||||
|
List<ObjectStatusModelDO> targetStatusModels = selected.isEmpty()
|
||||||
|
? statusModels
|
||||||
|
: statusModels.stream()
|
||||||
|
.filter(model -> selected.contains(model.getStatusCode()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
ProjectTaskBoardPageRespVO respVO = new ProjectTaskBoardPageRespVO();
|
||||||
|
List<ProjectTaskBoardPageRespVO.ColumnItemVO> items = targetStatusModels.stream()
|
||||||
|
.map(statusModel -> buildBoardColumn(projectId, executionId, scope, reqVO, statusModel))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
respVO.setItems(items);
|
||||||
|
return respVO;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把入参 statusCode 数组归一化成一个去重 Set;null / 空 / 全 blank 都视为"不选列 = 全集"。
|
||||||
|
*/
|
||||||
|
private Set<String> collectSelectedStatusCodes(String[] statusCodes) {
|
||||||
|
if (statusCodes == null || statusCodes.length == 0) {
|
||||||
|
return Collections.emptySet();
|
||||||
|
}
|
||||||
|
return Arrays.stream(statusCodes)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(s -> !s.isEmpty())
|
||||||
|
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProjectTaskBoardPageRespVO.ColumnItemVO buildBoardColumn(Long projectId, Long executionId,
|
||||||
|
VisibilityScope scope,
|
||||||
|
ProjectTaskBoardPageReqVO reqVO,
|
||||||
|
ObjectStatusModelDO statusModel) {
|
||||||
|
ProjectTaskPageReqVO innerReq = toInnerPageReq(reqVO, statusModel.getStatusCode());
|
||||||
|
PageResult<ProjectTaskDO> doPage = projectTaskMapper.selectPageByExecutionId(projectId, executionId, scope, innerReq);
|
||||||
|
PageResult<ProjectTaskRespVO> voPage = projectTaskService.assembleTaskRespVOPage(projectId, executionId, doPage);
|
||||||
|
|
||||||
|
ProjectTaskBoardPageRespVO.ColumnItemVO item = new ProjectTaskBoardPageRespVO.ColumnItemVO();
|
||||||
|
item.setStatusCode(statusModel.getStatusCode());
|
||||||
|
item.setStatusName(statusModel.getStatusName());
|
||||||
|
item.setSort(statusModel.getSort());
|
||||||
|
item.setTerminal(statusModel.getTerminalFlag());
|
||||||
|
item.setList(voPage.getList() == null ? Collections.emptyList() : voPage.getList());
|
||||||
|
item.setTotal(voPage.getTotal() == null ? 0L : voPage.getTotal());
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把看板分页入参翻译成单状态列的 /tasks/page 入参,复用现有 mapper 与装配逻辑。
|
||||||
|
*/
|
||||||
|
private ProjectTaskPageReqVO toInnerPageReq(ProjectTaskBoardPageReqVO reqVO, String statusCode) {
|
||||||
|
ProjectTaskPageReqVO innerReq = new ProjectTaskPageReqVO();
|
||||||
|
innerReq.setPageNo(reqVO.getPageNo());
|
||||||
|
innerReq.setPageSize(reqVO.getPageSize());
|
||||||
|
innerReq.setKeyword(reqVO.getKeyword());
|
||||||
|
innerReq.setParentTaskId(reqVO.getParentTaskId());
|
||||||
|
innerReq.setOwnerId(reqVO.getOwnerId());
|
||||||
|
innerReq.setUpdateTime(reqVO.getUpdateTime());
|
||||||
|
innerReq.setStatusCode(statusCode);
|
||||||
|
return innerReq;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算任务可见性 scope,与 ProjectTaskServiceImpl#computeTaskScope 同款:
|
||||||
|
* 项目经理 → seesAll;执行负责人 = 当前用户 → seesAll;否则按 resolveForExecution 求并集。
|
||||||
|
*/
|
||||||
|
private VisibilityScope resolveTaskScope(Long projectId, Long executionId) {
|
||||||
|
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||||
|
VisibilityScope scope = visibilityScopeResolver.resolveForExecution(projectId, executionId, userId);
|
||||||
|
if (scope.seesAll()) {
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
ProjectExecutionDO exec = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
|
||||||
|
if (exec != null && Objects.equals(exec.getOwnerId(), userId)) {
|
||||||
|
return VisibilityScope.all();
|
||||||
|
}
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
private ProjectExecutionStatusBoardRespVO buildExecutionStatusBoard(Long projectId,
|
private ProjectExecutionStatusBoardRespVO buildExecutionStatusBoard(Long projectId,
|
||||||
VisibilityScope scope,
|
VisibilityScope scope,
|
||||||
ProjectExecutionStatusBoardReqVO reqVO,
|
ProjectExecutionStatusBoardReqVO reqVO,
|
||||||
|
|||||||
@@ -207,9 +207,10 @@ public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssi
|
|||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
void validateProjectMember(Long projectId, Long userId) {
|
void validateProjectMember(Long projectId, Long userId) {
|
||||||
UserObjectRoleDO projectMember = userObjectRoleMapper
|
// 多角色支持:user 在项目内有任意 ACTIVE 角色即可作为 assignee
|
||||||
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId);
|
if (userObjectRoleMapper
|
||||||
if (projectMember == null) {
|
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId)
|
||||||
|
.isEmpty()) {
|
||||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_INVALID);
|
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_INVALID);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,7 +211,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
|||||||
}
|
}
|
||||||
ProjectExecutionRespVO respVO = BeanUtils.toBean(execution, ProjectExecutionRespVO.class);
|
ProjectExecutionRespVO respVO = BeanUtils.toBean(execution, ProjectExecutionRespVO.class);
|
||||||
respVO.setProgressRate(loadExecutionProgress(projectId, executionId));
|
respVO.setProgressRate(loadExecutionProgress(projectId, executionId));
|
||||||
applyLifecycle(respVO);
|
boolean rootTasksAllCompleted = loadExecutionRootTasksAllCompleted(projectId, executionId);
|
||||||
|
applyLifecycle(respVO, rootTasksAllCompleted);
|
||||||
respVO.setOwnerNickname(loadOwnerNickname(execution.getOwnerId()));
|
respVO.setOwnerNickname(loadOwnerNickname(execution.getOwnerId()));
|
||||||
return respVO;
|
return respVO;
|
||||||
}
|
}
|
||||||
@@ -225,6 +226,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
|||||||
return voPageResult;
|
return voPageResult;
|
||||||
}
|
}
|
||||||
fillExecutionProgress(projectId, list);
|
fillExecutionProgress(projectId, list);
|
||||||
|
// 批量预聚合根任务完成态,避免列表 N+1。未命中执行 → 缺省 false,complete 按钮不下发。
|
||||||
|
Map<Long, Boolean> rootTasksAllCompletedMap = loadExecutionRootTasksAllCompletedMap(projectId, list);
|
||||||
// 批量补负责人昵称,避免 N+1
|
// 批量补负责人昵称,避免 N+1
|
||||||
Set<Long> ownerIds = list.stream()
|
Set<Long> ownerIds = list.stream()
|
||||||
.map(ProjectExecutionRespVO::getOwnerId)
|
.map(ProjectExecutionRespVO::getOwnerId)
|
||||||
@@ -236,7 +239,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
|||||||
// 列表行 cancel/pause/resume/complete 按钮依赖 availableActions,与详情同款装配 lifecycle。
|
// 列表行 cancel/pause/resume/complete 按钮依赖 availableActions,与详情同款装配 lifecycle。
|
||||||
// 单行装配失败做兜底降级(status_model 缺失等脏数据),避免影响整页返回。
|
// 单行装配失败做兜底降级(status_model 缺失等脏数据),避免影响整页返回。
|
||||||
try {
|
try {
|
||||||
applyLifecycle(vo);
|
applyLifecycle(vo, rootTasksAllCompletedMap.getOrDefault(vo.getId(), false));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("execution lifecycle apply failed in page assembly. executionId={}, statusCode={}, error={}",
|
log.warn("execution lifecycle apply failed in page assembly. executionId={}, statusCode={}, error={}",
|
||||||
vo.getId(), vo.getStatusCode(), e.getMessage());
|
vo.getId(), vo.getStatusCode(), e.getMessage());
|
||||||
@@ -392,9 +395,10 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
|||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
void validateOwnerIsActiveProjectMember(Long projectId, Long ownerId) {
|
void validateOwnerIsActiveProjectMember(Long projectId, Long ownerId) {
|
||||||
UserObjectRoleDO member = userObjectRoleMapper
|
// 多角色支持:user 在项目内有任意 ACTIVE 角色即视为项目成员
|
||||||
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, ownerId);
|
if (userObjectRoleMapper
|
||||||
if (member == null) {
|
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, ownerId)
|
||||||
|
.isEmpty()) {
|
||||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_OWNER_INVALID);
|
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_OWNER_INVALID);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -489,9 +493,10 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void validateExecutionAssigneeProjectScope(Long projectId, Long userId) {
|
private void validateExecutionAssigneeProjectScope(Long projectId, Long userId) {
|
||||||
UserObjectRoleDO member = userObjectRoleMapper
|
// 多角色支持:user 在项目内有任意 ACTIVE 角色即可作为 assignee
|
||||||
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId);
|
if (userObjectRoleMapper
|
||||||
if (member == null) {
|
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId)
|
||||||
|
.isEmpty()) {
|
||||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_INVALID);
|
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_INVALID);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -670,10 +675,12 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
|||||||
return StringUtils.hasText(value) ? value : "";
|
return StringUtils.hasText(value) ? value : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
private void applyLifecycle(ProjectExecutionRespVO respVO) {
|
private void applyLifecycle(ProjectExecutionRespVO respVO, boolean rootTasksAllCompleted) {
|
||||||
// 传入 ownerId 用于 availableActions 的 owner-only 字段硬卡过滤(spec §7.1)
|
// 传入 ownerId / progressRate / rootTasksAllCompleted 用于 availableActions 的 owner-only、完成进度、
|
||||||
|
// 根任务完成态过滤。rootTasksAllCompleted=false 时不下发 complete,避免任务仍进行中时执行被闭环。
|
||||||
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView lifecycle =
|
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView lifecycle =
|
||||||
projectExecutionStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId());
|
projectExecutionStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId(),
|
||||||
|
respVO.getProgressRate(), rootTasksAllCompleted);
|
||||||
respVO.setStatusName(lifecycle.statusName());
|
respVO.setStatusName(lifecycle.statusName());
|
||||||
respVO.setTerminal(lifecycle.terminal());
|
respVO.setTerminal(lifecycle.terminal());
|
||||||
respVO.setAllowEdit(lifecycle.allowEdit());
|
respVO.setAllowEdit(lifecycle.allowEdit());
|
||||||
@@ -690,7 +697,9 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private BigDecimal loadExecutionProgress(Long projectId, Long executionId) {
|
private BigDecimal loadExecutionProgress(Long projectId, Long executionId) {
|
||||||
return normalizeProgress(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId));
|
List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
|
||||||
|
return normalizeProgress(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId,
|
||||||
|
excludedStatusCodes));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void fillExecutionProgress(Long projectId, List<ProjectExecutionRespVO> list) {
|
private void fillExecutionProgress(Long projectId, List<ProjectExecutionRespVO> list) {
|
||||||
@@ -698,7 +707,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
|||||||
.map(ProjectExecutionRespVO::getId)
|
.map(ProjectExecutionRespVO::getId)
|
||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
Map<Long, BigDecimal> progressMap = loadExecutionProgressMap(projectId, executionIds);
|
List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
|
||||||
|
Map<Long, BigDecimal> progressMap = loadExecutionProgressMap(projectId, executionIds, excludedStatusCodes);
|
||||||
list.forEach(vo -> vo.setProgressRate(progressMap.getOrDefault(vo.getId(), normalizeProgress(null))));
|
list.forEach(vo -> vo.setProgressRate(progressMap.getOrDefault(vo.getId(), normalizeProgress(null))));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,12 +716,13 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
|||||||
* 列表口径批量聚合:一次 GROUP BY 取本页所有执行的一级任务平均进度。
|
* 列表口径批量聚合:一次 GROUP BY 取本页所有执行的一级任务平均进度。
|
||||||
* 未命中的 executionId(执行下无一级任务)不入 map,由调用方 normalizeProgress 兜底为 0.00。
|
* 未命中的 executionId(执行下无一级任务)不入 map,由调用方 normalizeProgress 兜底为 0.00。
|
||||||
*/
|
*/
|
||||||
private Map<Long, BigDecimal> loadExecutionProgressMap(Long projectId, Collection<Long> executionIds) {
|
private Map<Long, BigDecimal> loadExecutionProgressMap(Long projectId, Collection<Long> executionIds,
|
||||||
|
Collection<String> excludedStatusCodes) {
|
||||||
if (executionIds == null || executionIds.isEmpty()) {
|
if (executionIds == null || executionIds.isEmpty()) {
|
||||||
return Collections.emptyMap();
|
return Collections.emptyMap();
|
||||||
}
|
}
|
||||||
List<Map<String, Object>> rows = projectTaskMapper
|
List<Map<String, Object>> rows = projectTaskMapper
|
||||||
.selectRootTaskAvgProgressGroupByExecutionIds(projectId, executionIds);
|
.selectRootTaskAvgProgressGroupByExecutionIds(projectId, executionIds, excludedStatusCodes);
|
||||||
if (rows == null || rows.isEmpty()) {
|
if (rows == null || rows.isEmpty()) {
|
||||||
return Collections.emptyMap();
|
return Collections.emptyMap();
|
||||||
}
|
}
|
||||||
@@ -729,6 +740,65 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<String> loadProgressExcludedTaskStatusCodes() {
|
||||||
|
List<String> statusCodes = objectStatusModelMapper
|
||||||
|
.selectProgressExcludedStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
|
||||||
|
return statusCodes == null ? Collections.emptyList() : statusCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行详情完成态:判断"参与聚合的根任务"是否全部为 completed。
|
||||||
|
* 筛选口径与 loadExecutionProgress 同源;空集(无参与聚合的根任务)返回 false,禁止下发 complete。
|
||||||
|
*/
|
||||||
|
private boolean loadExecutionRootTasksAllCompleted(Long projectId, Long executionId) {
|
||||||
|
List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
|
||||||
|
Map<String, Object> row = projectTaskMapper.selectRootTaskCompletionStateByExecutionId(projectId, executionId,
|
||||||
|
ProjectTaskConstants.STATUS_COMPLETED, excludedStatusCodes);
|
||||||
|
return isRootTasksAllCompleted(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表口径批量聚合:一次 GROUP BY 取本页所有执行的根任务完成态。
|
||||||
|
* 未命中的 executionId(执行下无参与聚合的根任务)不入 map,由调用方按缺省 false 处理(不下发 complete)。
|
||||||
|
*/
|
||||||
|
private Map<Long, Boolean> loadExecutionRootTasksAllCompletedMap(Long projectId,
|
||||||
|
List<ProjectExecutionRespVO> list) {
|
||||||
|
Set<Long> executionIds = list.stream()
|
||||||
|
.map(ProjectExecutionRespVO::getId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
|
if (executionIds.isEmpty()) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
|
||||||
|
List<Map<String, Object>> rows = projectTaskMapper.selectRootTaskCompletionStateGroupByExecutionIds(
|
||||||
|
projectId, executionIds, ProjectTaskConstants.STATUS_COMPLETED, excludedStatusCodes);
|
||||||
|
if (rows == null || rows.isEmpty()) {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
Map<Long, Boolean> 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")));
|
||||||
|
if (executionId == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.put(executionId, isRootTasksAllCompleted(row));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isRootTasksAllCompleted(Map<String, Object> row) {
|
||||||
|
if (row == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Long totals = toLong(row.get("totals"));
|
||||||
|
Long completedCount = toLong(row.getOrDefault("completedCount", row.get("completed_count")));
|
||||||
|
return totals != null && totals > 0 && completedCount != null && totals.equals(completedCount);
|
||||||
|
}
|
||||||
|
|
||||||
private Long toLong(Object value) {
|
private Long toLong(Object value) {
|
||||||
if (value instanceof Number number) {
|
if (value instanceof Number number) {
|
||||||
return number.longValue();
|
return number.longValue();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
|||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -27,6 +28,10 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
|
|||||||
* <li>剔除系统级动作 {@code auto_start}(不出现在 UI 按钮)。</li>
|
* <li>剔除系统级动作 {@code auto_start}(不出现在 UI 按钮)。</li>
|
||||||
* <li>对 owner-only 状态推进动作(complete / cancel / pause / resume),按 {@code execution.ownerId == currentUserId} 字段硬卡过滤,
|
* <li>对 owner-only 状态推进动作(complete / cancel / pause / resume),按 {@code execution.ownerId == currentUserId} 字段硬卡过滤,
|
||||||
* 与 {@link ProjectExecutionServiceImpl} 中 {@code validateOwnerForAction} 同款判定。</li>
|
* 与 {@link ProjectExecutionServiceImpl} 中 {@code validateOwnerForAction} 同款判定。</li>
|
||||||
|
* <li>对 {@code complete} 动作叠加进度过滤,执行进度未达到 100 时不下发完成按钮。</li>
|
||||||
|
* <li>对 {@code complete} 动作再叠加根任务完成态过滤:要求该执行下"参与聚合的根任务"全部为 completed
|
||||||
|
* 才下发;筛选口径与进度聚合同源(execution_id + parent_task_id IS NULL + excludedStatusCodes),
|
||||||
|
* 空集(无参与聚合的根任务)视为"未全部完成",不下发完成按钮,避免任务仍在进行中时执行被闭环。</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
* 非状态动作(delete / change-owner / update / assignee)的权限码 / 字段过滤未纳入本字段,
|
* 非状态动作(delete / change-owner / update / assignee)的权限码 / 字段过滤未纳入本字段,
|
||||||
* 前端按各动作对应权限码与 owner 字段独立判断(spec §6.5 允许条件矩阵);
|
* 前端按各动作对应权限码与 owner 字段独立判断(spec §6.5 允许条件矩阵);
|
||||||
@@ -39,13 +44,16 @@ public class ProjectExecutionStatusViewService {
|
|||||||
* 状态推进动作中要求 owner-only 字段硬卡的集合,与 ProjectExecutionServiceImpl#validateOwnerForAction 一致。
|
* 状态推进动作中要求 owner-only 字段硬卡的集合,与 ProjectExecutionServiceImpl#validateOwnerForAction 一致。
|
||||||
*/
|
*/
|
||||||
private static final Set<String> OWNER_ONLY_ACTIONS = Set.of("complete", "cancel", "pause", "resume");
|
private static final Set<String> OWNER_ONLY_ACTIONS = Set.of("complete", "cancel", "pause", "resume");
|
||||||
|
private static final String ACTION_COMPLETE = "complete";
|
||||||
|
private static final BigDecimal COMPLETE_PROGRESS_THRESHOLD = BigDecimal.valueOf(100);
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private ObjectStatusModelMapper objectStatusModelMapper;
|
private ObjectStatusModelMapper objectStatusModelMapper;
|
||||||
@Resource
|
@Resource
|
||||||
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
|
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
|
||||||
|
|
||||||
public ProjectExecutionLifecycleView getLifecycle(String statusCode, Long ownerId) {
|
public ProjectExecutionLifecycleView getLifecycle(String statusCode, Long ownerId, BigDecimal progressRate,
|
||||||
|
boolean rootTasksAllCompleted) {
|
||||||
ObjectStatusModelDO statusModel = objectStatusModelMapper
|
ObjectStatusModelDO statusModel = objectStatusModelMapper
|
||||||
.selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, statusCode);
|
.selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, statusCode);
|
||||||
if (statusModel == null) {
|
if (statusModel == null) {
|
||||||
@@ -55,11 +63,13 @@ public class ProjectExecutionStatusViewService {
|
|||||||
statusModel.getStatusName(),
|
statusModel.getStatusName(),
|
||||||
statusModel.getTerminalFlag(),
|
statusModel.getTerminalFlag(),
|
||||||
statusModel.getAllowEdit(),
|
statusModel.getAllowEdit(),
|
||||||
buildAvailableActions(statusCode, ownerId)
|
buildAvailableActions(statusCode, ownerId, progressRate, rootTasksAllCompleted)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ProjectExecutionLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId) {
|
private List<ProjectExecutionLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId,
|
||||||
|
BigDecimal progressRate,
|
||||||
|
boolean rootTasksAllCompleted) {
|
||||||
List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper
|
List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper
|
||||||
.selectListByObjectTypeAndFromStatus(ProjectExecutionConstants.OBJECT_TYPE, statusCode);
|
.selectListByObjectTypeAndFromStatus(ProjectExecutionConstants.OBJECT_TYPE, statusCode);
|
||||||
if (transitions == null || transitions.isEmpty()) {
|
if (transitions == null || transitions.isEmpty()) {
|
||||||
@@ -72,6 +82,10 @@ public class ProjectExecutionStatusViewService {
|
|||||||
// owner-only 字段硬卡:非负责人本人时不返回 complete / cancel / pause / resume 动作
|
// owner-only 字段硬卡:非负责人本人时不返回 complete / cancel / pause / resume 动作
|
||||||
.filter(transition -> !OWNER_ONLY_ACTIONS.contains(transition.getActionCode())
|
.filter(transition -> !OWNER_ONLY_ACTIONS.contains(transition.getActionCode())
|
||||||
|| (currentUserId != null && Objects.equals(ownerId, currentUserId)))
|
|| (currentUserId != null && Objects.equals(ownerId, currentUserId)))
|
||||||
|
// 完成动作额外要求:执行进度已达 100,且参与聚合的根任务全部已完成(避免任务仍进行中时执行被闭环);
|
||||||
|
// 暂停、恢复、取消不受进度 / 根任务状态影响。
|
||||||
|
.filter(transition -> !ACTION_COMPLETE.equals(transition.getActionCode())
|
||||||
|
|| (isCompleteProgressSatisfied(progressRate) && rootTasksAllCompleted))
|
||||||
.map(transition -> {
|
.map(transition -> {
|
||||||
ProjectExecutionLifecycleActionRespVO action = new ProjectExecutionLifecycleActionRespVO();
|
ProjectExecutionLifecycleActionRespVO action = new ProjectExecutionLifecycleActionRespVO();
|
||||||
action.setActionCode(transition.getActionCode());
|
action.setActionCode(transition.getActionCode());
|
||||||
@@ -82,6 +96,10 @@ public class ProjectExecutionStatusViewService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isCompleteProgressSatisfied(BigDecimal progressRate) {
|
||||||
|
return progressRate != null && progressRate.compareTo(COMPLETE_PROGRESS_THRESHOLD) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
public record ProjectExecutionLifecycleView(String statusName,
|
public record ProjectExecutionLifecycleView(String statusName,
|
||||||
Boolean terminal,
|
Boolean terminal,
|
||||||
Boolean allowEdit,
|
Boolean allowEdit,
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ public interface ProjectTaskService {
|
|||||||
*/
|
*/
|
||||||
PageResult<ProjectTaskRespVO> getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO);
|
PageResult<ProjectTaskRespVO> getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把任务 DO 分页结果整体装配成 RespVO 分页结果。
|
||||||
|
* <p>提供给"看板分页接口"复用同款装配口径,保证两个接口列元素结构 / 序列化完全一致;
|
||||||
|
* 看板分页内部按状态列循环时,应调用本方法装配每列,不要自行重复装配。
|
||||||
|
*/
|
||||||
|
PageResult<ProjectTaskRespVO> assembleTaskRespVOPage(Long projectId, Long executionId,
|
||||||
|
PageResult<ProjectTaskDO> doPage);
|
||||||
|
|
||||||
void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO);
|
void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -402,6 +402,17 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
|||||||
@Override
|
@Override
|
||||||
public PageResult<ProjectTaskRespVO> getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) {
|
public PageResult<ProjectTaskRespVO> getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) {
|
||||||
PageResult<ProjectTaskDO> pageResult = getTaskPage(projectId, executionId, reqVO);
|
PageResult<ProjectTaskDO> pageResult = getTaskPage(projectId, executionId, reqVO);
|
||||||
|
return assembleTaskRespVOPage(projectId, executionId, pageResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 ProjectTaskDO 分页结果整体装配成 RespVO 分页结果(含 ownerNickname / assignees / 工时合计 / 父任务 owner / 执行 owner / 生命周期)。
|
||||||
|
* <p>提供给 /tasks/page 与 /tasks/board-page 共用,保证两个接口的列元素结构与序列化口径完全一致;
|
||||||
|
* /tasks/board-page 不应自行重复装配,避免字段演进时漂移。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public PageResult<ProjectTaskRespVO> assembleTaskRespVOPage(Long projectId, Long executionId,
|
||||||
|
PageResult<ProjectTaskDO> pageResult) {
|
||||||
PageResult<ProjectTaskRespVO> voPageResult = BeanUtils.toBean(pageResult, ProjectTaskRespVO.class);
|
PageResult<ProjectTaskRespVO> voPageResult = BeanUtils.toBean(pageResult, ProjectTaskRespVO.class);
|
||||||
List<ProjectTaskRespVO> list = voPageResult.getList();
|
List<ProjectTaskRespVO> list = voPageResult.getList();
|
||||||
if (list == null || list.isEmpty()) {
|
if (list == null || list.isEmpty()) {
|
||||||
@@ -708,6 +719,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
|||||||
// 完成动作:兜底把任务进度刷到 100%,并触发父任务 AVG 重算
|
// 完成动作:兜底把任务进度刷到 100%,并触发父任务 AVG 重算
|
||||||
if ("complete".equals(actionCode)) {
|
if ("complete".equals(actionCode)) {
|
||||||
forceCompleteProgress(task);
|
forceCompleteProgress(task);
|
||||||
|
} else if (task.getParentTaskId() != null) {
|
||||||
|
recalcParentProgressFrom(task.getParentTaskId());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取消 / 暂停 / 恢复:触发子任务级联(每个子任务的内部链路自身会再级联自己的子,链式实现整棵子树)
|
// 取消 / 暂停 / 恢复:触发子任务级联(每个子任务的内部链路自身会再级联自己的子,链式实现整棵子树)
|
||||||
@@ -1024,21 +1037,22 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
|||||||
*/
|
*/
|
||||||
private void recalcParentProgressFrom(Long parentTaskId) {
|
private void recalcParentProgressFrom(Long parentTaskId) {
|
||||||
Long current = parentTaskId;
|
Long current = parentTaskId;
|
||||||
|
List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
|
||||||
while (current != null) {
|
while (current != null) {
|
||||||
ProjectTaskDO parent = projectTaskMapper.selectById(current);
|
ProjectTaskDO parent = projectTaskMapper.selectById(current);
|
||||||
if (parent == null) {
|
if (parent == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
List<ProjectTaskDO> children = projectTaskMapper.selectChildrenProgressByParentTaskId(current);
|
List<ProjectTaskDO> children = projectTaskMapper.selectChildrenProgressByParentTaskId(current,
|
||||||
if (children.isEmpty()) {
|
excludedStatusCodes);
|
||||||
return;
|
|
||||||
}
|
|
||||||
BigDecimal sum = BigDecimal.ZERO;
|
BigDecimal sum = BigDecimal.ZERO;
|
||||||
for (ProjectTaskDO child : children) {
|
for (ProjectTaskDO child : children) {
|
||||||
BigDecimal cp = child.getProgressRate() == null ? BigDecimal.ZERO : child.getProgressRate();
|
BigDecimal cp = child.getProgressRate() == null ? BigDecimal.ZERO : child.getProgressRate();
|
||||||
sum = sum.add(cp);
|
sum = sum.add(cp);
|
||||||
}
|
}
|
||||||
BigDecimal avg = sum.divide(BigDecimal.valueOf(children.size()), 2, RoundingMode.HALF_UP);
|
BigDecimal avg = children.isEmpty()
|
||||||
|
? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)
|
||||||
|
: sum.divide(BigDecimal.valueOf(children.size()), 2, RoundingMode.HALF_UP);
|
||||||
if (progressNumericallyEquals(avg, parent.getProgressRate())) {
|
if (progressNumericallyEquals(avg, parent.getProgressRate())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1047,6 +1061,12 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<String> loadProgressExcludedTaskStatusCodes() {
|
||||||
|
List<String> statusCodes = objectStatusModelMapper
|
||||||
|
.selectProgressExcludedStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
|
||||||
|
return statusCodes == null ? Collections.emptyList() : statusCodes;
|
||||||
|
}
|
||||||
|
|
||||||
private boolean progressNumericallyEquals(BigDecimal a, BigDecimal b) {
|
private boolean progressNumericallyEquals(BigDecimal a, BigDecimal b) {
|
||||||
if (a == null && b == null) {
|
if (a == null && b == null) {
|
||||||
return true;
|
return true;
|
||||||
@@ -1083,9 +1103,10 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void applyLifecycle(ProjectTaskRespVO respVO) {
|
private void applyLifecycle(ProjectTaskRespVO respVO) {
|
||||||
// 传入 ownerId 用于 availableActions 的 owner-only 字段硬卡过滤(spec §7.1)
|
// 传入 ownerId / progressRate 用于 availableActions 的 owner-only 与完成进度过滤。
|
||||||
ProjectTaskStatusViewService.ProjectTaskLifecycleView lifecycle =
|
ProjectTaskStatusViewService.ProjectTaskLifecycleView lifecycle =
|
||||||
projectTaskStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId());
|
projectTaskStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId(),
|
||||||
|
respVO.getProgressRate());
|
||||||
respVO.setStatusName(lifecycle.statusName());
|
respVO.setStatusName(lifecycle.statusName());
|
||||||
respVO.setTerminal(lifecycle.terminal());
|
respVO.setTerminal(lifecycle.terminal());
|
||||||
respVO.setAllowEdit(lifecycle.allowEdit());
|
respVO.setAllowEdit(lifecycle.allowEdit());
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
|||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -27,6 +28,7 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil
|
|||||||
* <li>剔除系统级动作 {@code auto_start}(由工时填报内部触发,不出现在 UI 按钮)。</li>
|
* <li>剔除系统级动作 {@code auto_start}(由工时填报内部触发,不出现在 UI 按钮)。</li>
|
||||||
* <li>对 owner-only 状态推进动作(complete / cancel / pause / resume),按 {@code task.ownerId == currentUserId} 字段硬卡过滤,
|
* <li>对 owner-only 状态推进动作(complete / cancel / pause / resume),按 {@code task.ownerId == currentUserId} 字段硬卡过滤,
|
||||||
* 与 {@link ProjectTaskServiceImpl#validateOwnerForAction} 同款判定。</li>
|
* 与 {@link ProjectTaskServiceImpl#validateOwnerForAction} 同款判定。</li>
|
||||||
|
* <li>对 {@code complete} 动作叠加进度过滤,任务进度未达到 100 时不下发完成按钮。</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
* 非状态动作(delete / 加协办人 / 工时填报等)的权限码 / 字段过滤未纳入本字段,前端按各动作对应权限码与 owner 字段独立判断;
|
* 非状态动作(delete / 加协办人 / 工时填报等)的权限码 / 字段过滤未纳入本字段,前端按各动作对应权限码与 owner 字段独立判断;
|
||||||
* 全量化纳入 {@code availableActions} 留待后续阶段统一改造。
|
* 全量化纳入 {@code availableActions} 留待后续阶段统一改造。
|
||||||
@@ -38,13 +40,15 @@ public class ProjectTaskStatusViewService {
|
|||||||
* 状态推进动作中要求 owner-only 字段硬卡的集合,与 ProjectTaskServiceImpl#validateOwnerForAction 一致。
|
* 状态推进动作中要求 owner-only 字段硬卡的集合,与 ProjectTaskServiceImpl#validateOwnerForAction 一致。
|
||||||
*/
|
*/
|
||||||
private static final Set<String> OWNER_ONLY_ACTIONS = Set.of("complete", "cancel", "pause", "resume");
|
private static final Set<String> OWNER_ONLY_ACTIONS = Set.of("complete", "cancel", "pause", "resume");
|
||||||
|
private static final String ACTION_COMPLETE = "complete";
|
||||||
|
private static final BigDecimal COMPLETE_PROGRESS_THRESHOLD = BigDecimal.valueOf(100);
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private ObjectStatusModelMapper objectStatusModelMapper;
|
private ObjectStatusModelMapper objectStatusModelMapper;
|
||||||
@Resource
|
@Resource
|
||||||
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
|
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
|
||||||
|
|
||||||
public ProjectTaskLifecycleView getLifecycle(String statusCode, Long ownerId) {
|
public ProjectTaskLifecycleView getLifecycle(String statusCode, Long ownerId, BigDecimal progressRate) {
|
||||||
ObjectStatusModelDO statusModel = objectStatusModelMapper
|
ObjectStatusModelDO statusModel = objectStatusModelMapper
|
||||||
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, statusCode);
|
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, statusCode);
|
||||||
if (statusModel == null) {
|
if (statusModel == null) {
|
||||||
@@ -54,11 +58,12 @@ public class ProjectTaskStatusViewService {
|
|||||||
statusModel.getStatusName(),
|
statusModel.getStatusName(),
|
||||||
statusModel.getTerminalFlag(),
|
statusModel.getTerminalFlag(),
|
||||||
statusModel.getAllowEdit(),
|
statusModel.getAllowEdit(),
|
||||||
buildAvailableActions(statusCode, ownerId)
|
buildAvailableActions(statusCode, ownerId, progressRate)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ProjectTaskLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId) {
|
private List<ProjectTaskLifecycleActionRespVO> buildAvailableActions(String statusCode, Long ownerId,
|
||||||
|
BigDecimal progressRate) {
|
||||||
List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper
|
List<ObjectStatusTransitionDO> transitions = objectStatusTransitionMapper
|
||||||
.selectListByObjectTypeAndFromStatus(ProjectTaskConstants.OBJECT_TYPE, statusCode);
|
.selectListByObjectTypeAndFromStatus(ProjectTaskConstants.OBJECT_TYPE, statusCode);
|
||||||
if (transitions == null || transitions.isEmpty()) {
|
if (transitions == null || transitions.isEmpty()) {
|
||||||
@@ -71,6 +76,9 @@ public class ProjectTaskStatusViewService {
|
|||||||
// owner-only 字段硬卡:非负责人本人时不返回 complete / cancel / pause / resume 动作
|
// owner-only 字段硬卡:非负责人本人时不返回 complete / cancel / pause / resume 动作
|
||||||
.filter(transition -> !OWNER_ONLY_ACTIONS.contains(transition.getActionCode())
|
.filter(transition -> !OWNER_ONLY_ACTIONS.contains(transition.getActionCode())
|
||||||
|| (currentUserId != null && Objects.equals(ownerId, currentUserId)))
|
|| (currentUserId != null && Objects.equals(ownerId, currentUserId)))
|
||||||
|
// 完成动作额外要求任务进度已达到 100;暂停、恢复、取消不受进度影响
|
||||||
|
.filter(transition -> !ACTION_COMPLETE.equals(transition.getActionCode())
|
||||||
|
|| isCompleteProgressSatisfied(progressRate))
|
||||||
.map(transition -> {
|
.map(transition -> {
|
||||||
ProjectTaskLifecycleActionRespVO action = new ProjectTaskLifecycleActionRespVO();
|
ProjectTaskLifecycleActionRespVO action = new ProjectTaskLifecycleActionRespVO();
|
||||||
action.setActionCode(transition.getActionCode());
|
action.setActionCode(transition.getActionCode());
|
||||||
@@ -81,6 +89,10 @@ public class ProjectTaskStatusViewService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isCompleteProgressSatisfied(BigDecimal progressRate) {
|
||||||
|
return progressRate != null && progressRate.compareTo(COMPLETE_PROGRESS_THRESHOLD) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
public record ProjectTaskLifecycleView(String statusName,
|
public record ProjectTaskLifecycleView(String statusName,
|
||||||
Boolean terminal,
|
Boolean terminal,
|
||||||
Boolean allowEdit,
|
Boolean allowEdit,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.MockedStatic;
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||||
@@ -36,8 +38,8 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
|
|||||||
void checkPermission_whenMemberOnlyAndCurrentUserIsMember_shouldPass() {
|
void checkPermission_whenMemberOnlyAndCurrentUserIsMember_shouldPass() {
|
||||||
Long productId = 1001L;
|
Long productId = 1001L;
|
||||||
Long loginUserId = 2001L;
|
Long loginUserId = 2001L;
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
|
||||||
.thenReturn(createMember(productId, loginUserId, 3001L));
|
.thenReturn(List.of(createMember(productId, loginUserId, 3001L)));
|
||||||
|
|
||||||
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
|
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
|
||||||
assertDoesNotThrow(() -> permissionService.checkPermission(productId, null, true));
|
assertDoesNotThrow(() -> permissionService.checkPermission(productId, null, true));
|
||||||
@@ -50,8 +52,8 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
|
|||||||
void checkPermission_whenCurrentRolePermissionsContainTarget_shouldPass() {
|
void checkPermission_whenCurrentRolePermissionsContainTarget_shouldPass() {
|
||||||
Long productId = 1002L;
|
Long productId = 1002L;
|
||||||
Long loginUserId = 2002L;
|
Long loginUserId = 2002L;
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
|
||||||
.thenReturn(createMember(productId, loginUserId, 3002L));
|
.thenReturn(List.of(createMember(productId, loginUserId, 3002L)));
|
||||||
when(objectPermissionApi.getObjectRolePermissions(3002L,
|
when(objectPermissionApi.getObjectRolePermissions(3002L,
|
||||||
PermissionScopeTypeEnum.OBJECT.getScopeType(), "product"))
|
PermissionScopeTypeEnum.OBJECT.getScopeType(), "product"))
|
||||||
.thenReturn(success(Set.of("project:product:query")));
|
.thenReturn(success(Set.of("project:product:query")));
|
||||||
@@ -65,8 +67,8 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
|
|||||||
void checkPermission_whenCurrentRoleDoesNotContainPermission_shouldThrowException() {
|
void checkPermission_whenCurrentRoleDoesNotContainPermission_shouldThrowException() {
|
||||||
Long productId = 1003L;
|
Long productId = 1003L;
|
||||||
Long loginUserId = 2003L;
|
Long loginUserId = 2003L;
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
|
||||||
.thenReturn(createMember(productId, loginUserId, 3003L));
|
.thenReturn(List.of(createMember(productId, loginUserId, 3003L)));
|
||||||
when(objectPermissionApi.getObjectRolePermissions(3003L,
|
when(objectPermissionApi.getObjectRolePermissions(3003L,
|
||||||
PermissionScopeTypeEnum.OBJECT.getScopeType(), "product"))
|
PermissionScopeTypeEnum.OBJECT.getScopeType(), "product"))
|
||||||
.thenReturn(success(Set.of("project:product:update")));
|
.thenReturn(success(Set.of("project:product:update")));
|
||||||
@@ -82,8 +84,8 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
|
|||||||
void checkPermission_whenCurrentUserIsNotMember_shouldThrowException() {
|
void checkPermission_whenCurrentUserIsNotMember_shouldThrowException() {
|
||||||
Long productId = 1004L;
|
Long productId = 1004L;
|
||||||
Long loginUserId = 2004L;
|
Long loginUserId = 2004L;
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
|
||||||
.thenReturn(null);
|
.thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
|
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
|
||||||
ServiceException ex = assertThrows(ServiceException.class,
|
ServiceException ex = assertThrows(ServiceException.class,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.MockedStatic;
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||||
@@ -36,8 +38,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
|
|||||||
void checkPermission_whenMemberOnlyAndActiveMember_shouldPass() {
|
void checkPermission_whenMemberOnlyAndActiveMember_shouldPass() {
|
||||||
Long projectId = 1001L;
|
Long projectId = 1001L;
|
||||||
Long loginUserId = 2001L;
|
Long loginUserId = 2001L;
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
|
||||||
.thenReturn(createMember(projectId, loginUserId, 3001L));
|
.thenReturn(List.of(createMember(projectId, loginUserId, 3001L)));
|
||||||
|
|
||||||
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
|
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
|
||||||
assertDoesNotThrow(() -> permissionService.checkPermission(projectId, null, true));
|
assertDoesNotThrow(() -> permissionService.checkPermission(projectId, null, true));
|
||||||
@@ -50,8 +52,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
|
|||||||
void checkPermission_whenNoActiveMember_shouldThrowProjectPermissionDenied() {
|
void checkPermission_whenNoActiveMember_shouldThrowProjectPermissionDenied() {
|
||||||
Long projectId = 1002L;
|
Long projectId = 1002L;
|
||||||
Long loginUserId = 2002L;
|
Long loginUserId = 2002L;
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
|
||||||
.thenReturn(null);
|
.thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
|
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
|
||||||
ServiceException ex = assertThrows(ServiceException.class,
|
ServiceException ex = assertThrows(ServiceException.class,
|
||||||
@@ -64,8 +66,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
|
|||||||
void checkPermission_whenPermissionPresent_shouldPass() {
|
void checkPermission_whenPermissionPresent_shouldPass() {
|
||||||
Long projectId = 1003L;
|
Long projectId = 1003L;
|
||||||
Long loginUserId = 2003L;
|
Long loginUserId = 2003L;
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
|
||||||
.thenReturn(createMember(projectId, loginUserId, 3003L));
|
.thenReturn(List.of(createMember(projectId, loginUserId, 3003L)));
|
||||||
when(objectPermissionApi.getObjectRolePermissions(3003L,
|
when(objectPermissionApi.getObjectRolePermissions(3003L,
|
||||||
PermissionScopeTypeEnum.OBJECT.getScopeType(), "project"))
|
PermissionScopeTypeEnum.OBJECT.getScopeType(), "project"))
|
||||||
.thenReturn(success(Set.of("project:project:update")));
|
.thenReturn(success(Set.of("project:project:update")));
|
||||||
@@ -79,8 +81,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
|
|||||||
void checkPermission_whenPermissionMissing_shouldThrowProjectPermissionDenied() {
|
void checkPermission_whenPermissionMissing_shouldThrowProjectPermissionDenied() {
|
||||||
Long projectId = 1004L;
|
Long projectId = 1004L;
|
||||||
Long loginUserId = 2004L;
|
Long loginUserId = 2004L;
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
|
||||||
.thenReturn(createMember(projectId, loginUserId, 3004L));
|
.thenReturn(List.of(createMember(projectId, loginUserId, 3004L)));
|
||||||
when(objectPermissionApi.getObjectRolePermissions(3004L,
|
when(objectPermissionApi.getObjectRolePermissions(3004L,
|
||||||
PermissionScopeTypeEnum.OBJECT.getScopeType(), "project"))
|
PermissionScopeTypeEnum.OBJECT.getScopeType(), "project"))
|
||||||
.thenReturn(success(Set.of("project:project:query")));
|
.thenReturn(success(Set.of("project:project:query")));
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package com.njcn.rdms.module.project.service.datascope;
|
||||||
|
|
||||||
|
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||||
|
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
|
||||||
|
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
|
||||||
|
import com.njcn.rdms.module.system.api.dept.OrgLeaderApi;
|
||||||
|
import com.njcn.rdms.module.system.api.permission.PermissionApi;
|
||||||
|
import com.njcn.rdms.module.system.api.permission.UserVisibilityConfigApi;
|
||||||
|
import com.njcn.rdms.module.system.api.permission.dto.UserVisibilityConfigRespDTO;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class ObjectDataScopeServiceImplTest extends BaseMockitoUnitTest {
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private ObjectDataScopeServiceImpl service;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PermissionApi permissionApi;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserObjectRoleMapper userObjectRoleMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private OrgLeaderApi orgLeaderApi;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserVisibilityConfigApi userVisibilityConfigApi;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compute_returnsAll_whenUserIsSuperAdmin() {
|
||||||
|
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(true));
|
||||||
|
ObjectDataScope scope = service.compute(1L, "project");
|
||||||
|
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.ALL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helper ----
|
||||||
|
|
||||||
|
private static UserObjectRoleDO row(Long objectId) {
|
||||||
|
UserObjectRoleDO r = new UserObjectRoleDO();
|
||||||
|
r.setObjectId(objectId);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserVisibilityConfigRespDTO cfg(String type, Set<String> directionCodes) {
|
||||||
|
UserVisibilityConfigRespDTO dto = new UserVisibilityConfigRespDTO();
|
||||||
|
dto.setType(type);
|
||||||
|
dto.setDirectionCodes(directionCodes);
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- task 0.4 旧 case(补通道 2 + 通道 3 mock,strict mode 不报未预期调用)----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compute_returnsEmpty_whenNonSuperAdminAndNoChannelMatch() {
|
||||||
|
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
|
||||||
|
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L)).thenReturn(List.of());
|
||||||
|
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of()));
|
||||||
|
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(null));
|
||||||
|
ObjectDataScope scope = service.compute(1L, "project");
|
||||||
|
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compute_returnsIdList_withChannel1Hits() {
|
||||||
|
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
|
||||||
|
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L))
|
||||||
|
.thenReturn(List.of(row(101L), row(102L)));
|
||||||
|
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of()));
|
||||||
|
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(null));
|
||||||
|
|
||||||
|
ObjectDataScope scope = service.compute(1L, "project");
|
||||||
|
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.ID_LIST);
|
||||||
|
assertThat(scope.getIds()).containsExactlyInAnyOrder(101L, 102L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- task 1.4 新 case ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compute_unionsChannel1AndChannel2_whenUserIsLeader() {
|
||||||
|
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
|
||||||
|
// 通道 1 命中 101
|
||||||
|
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L))
|
||||||
|
.thenReturn(List.of(row(101L)));
|
||||||
|
// 通道 2 命中 102 / 103
|
||||||
|
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of(2L, 3L)));
|
||||||
|
when(userObjectRoleMapper.selectListByUserIdsAndObjectType(Set.of(2L, 3L), "project"))
|
||||||
|
.thenReturn(List.of(row(102L), row(103L)));
|
||||||
|
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(null));
|
||||||
|
|
||||||
|
ObjectDataScope scope = service.compute(1L, "project");
|
||||||
|
assertThat(scope.getIds()).containsExactlyInAnyOrder(101L, 102L, 103L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compute_skipsChannel2_whenUserIsNotLeader() {
|
||||||
|
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
|
||||||
|
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of()));
|
||||||
|
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(null));
|
||||||
|
ObjectDataScope scope = service.compute(1L, "project");
|
||||||
|
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- task 2.6 新 case:通道 3 ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compute_returnsAll_whenChannel3IsAll() {
|
||||||
|
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
|
||||||
|
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L)).thenReturn(List.of());
|
||||||
|
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of()));
|
||||||
|
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(cfg("all", null)));
|
||||||
|
ObjectDataScope scope = service.compute(1L, "project");
|
||||||
|
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.ALL);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compute_addsDirectionCodes_whenChannel3IsDirections() {
|
||||||
|
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
|
||||||
|
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L)).thenReturn(List.of());
|
||||||
|
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of()));
|
||||||
|
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(cfg("directions", Set.of("system", "embedded"))));
|
||||||
|
ObjectDataScope scope = service.compute(1L, "project");
|
||||||
|
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.ID_LIST);
|
||||||
|
assertThat(scope.getIds()).isEmpty();
|
||||||
|
assertThat(scope.getDirectionCodes()).containsExactlyInAnyOrder("system", "embedded");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compute_ignoresChannel3_whenTypeIsProjects() {
|
||||||
|
when(permissionApi.isSuperAdmin(1L)).thenReturn(success(false));
|
||||||
|
when(userObjectRoleMapper.selectActiveListByObjectTypeAndUserId("project", 1L)).thenReturn(List.of());
|
||||||
|
when(orgLeaderApi.getReachableUserIds(1L)).thenReturn(success(Set.of()));
|
||||||
|
UserVisibilityConfigRespDTO projectsCfg = new UserVisibilityConfigRespDTO();
|
||||||
|
projectsCfg.setType("projects");
|
||||||
|
projectsCfg.setProjectIds(Set.of(101L, 102L));
|
||||||
|
when(userVisibilityConfigApi.getConfig(1L)).thenReturn(success(projectsCfg));
|
||||||
|
ObjectDataScope scope = service.compute(1L, "project");
|
||||||
|
// 业务暂不消费 projects 类型 → ids/directionCodes 都空 → EMPTY
|
||||||
|
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.EMPTY);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.njcn.rdms.module.project.service.datascope;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class ObjectDataScopeTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void all_contains_anything() {
|
||||||
|
ObjectDataScope scope = ObjectDataScope.all();
|
||||||
|
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.ALL);
|
||||||
|
assertThat(scope.contains(1L, "any")).isTrue();
|
||||||
|
assertThat(scope.contains(null, null)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void empty_contains_nothing() {
|
||||||
|
ObjectDataScope scope = ObjectDataScope.empty();
|
||||||
|
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.EMPTY);
|
||||||
|
assertThat(scope.contains(1L, "any")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void idList_with_only_ids_matches_ids() {
|
||||||
|
ObjectDataScope scope = ObjectDataScope.idList(Set.of(1L, 2L), Set.of());
|
||||||
|
assertThat(scope.contains(1L, null)).isTrue();
|
||||||
|
assertThat(scope.contains(99L, null)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void idList_with_only_directionCodes_matches_directions() {
|
||||||
|
ObjectDataScope scope = ObjectDataScope.idList(Set.of(), Set.of("system"));
|
||||||
|
assertThat(scope.contains(99L, "system")).isTrue();
|
||||||
|
assertThat(scope.contains(99L, "embedded")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void idList_with_both_or_semantics() {
|
||||||
|
ObjectDataScope scope = ObjectDataScope.idList(Set.of(1L), Set.of("system"));
|
||||||
|
assertThat(scope.contains(1L, "embedded")).isTrue(); // id 命中
|
||||||
|
assertThat(scope.contains(99L, "system")).isTrue(); // direction 命中
|
||||||
|
assertThat(scope.contains(99L, "embedded")).isFalse(); // 都不命中
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void idList_with_empty_inputs_returns_empty_state() {
|
||||||
|
ObjectDataScope scope = ObjectDataScope.idList(Set.of(), Set.of());
|
||||||
|
assertThat(scope.getState()).isEqualTo(ObjectDataScope.State.EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ids_and_directions_are_unmodifiable() {
|
||||||
|
ObjectDataScope scope = ObjectDataScope.idList(Set.of(1L), Set.of("system"));
|
||||||
|
org.junit.jupiter.api.Assertions.assertThrows(UnsupportedOperationException.class,
|
||||||
|
() -> scope.getIds().add(99L));
|
||||||
|
org.junit.jupiter.api.Assertions.assertThrows(UnsupportedOperationException.class,
|
||||||
|
() -> scope.getDirectionCodes().add("embedded"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import com.njcn.rdms.module.system.api.permission.dto.ObjectRoleRespDTO;
|
|||||||
import com.njcn.rdms.module.system.api.user.AdminUserApi;
|
import com.njcn.rdms.module.system.api.user.AdminUserApi;
|
||||||
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
|
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ import java.util.Set;
|
|||||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
@@ -97,7 +99,7 @@ class ProductMemberServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
() -> productMemberService.createProductMember(productId, reqVO));
|
() -> productMemberService.createProductMember(productId, reqVO));
|
||||||
|
|
||||||
assertEquals(ErrorCodeConstants.PRODUCT_MEMBER_ROLE_INVALID.getCode(), ex.getCode());
|
assertEquals(ErrorCodeConstants.PRODUCT_MEMBER_ROLE_INVALID.getCode(), ex.getCode());
|
||||||
verify(userObjectRoleMapper, never()).selectByObjectAndUserId(any(), any(), any());
|
verify(userObjectRoleMapper, never()).selectByObjectUserAndRole(any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -153,6 +155,56 @@ class ProductMemberServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
verify(bizAuditLogMapper, never()).insert(any(BizAuditLogDO.class));
|
verify(bizAuditLogMapper, never()).insert(any(BizAuditLogDO.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createProductMember_sameUserDifferentRole_shouldAllow() {
|
||||||
|
Long productId = 1010L;
|
||||||
|
ProductMemberSaveReqVO reqVO = new ProductMemberSaveReqVO();
|
||||||
|
reqVO.setUserId(2002L);
|
||||||
|
reqVO.setRoleId(3102L);
|
||||||
|
when(productMapper.selectById(productId)).thenReturn(createProduct(productId, 2001L));
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
|
||||||
|
.thenReturn(createStatus("active", true));
|
||||||
|
when(objectPermissionApi.getObjectRoleById(3102L, "object", "product"))
|
||||||
|
.thenReturn(success(createRole(3102L, "product_specialist", "产品专员")));
|
||||||
|
// user=2002L 在产品里 (2002L, 3102L) 这个 role 不存在 —— 即便 user 已有别的 role,新 role 应允许
|
||||||
|
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, 2002L, 3102L))
|
||||||
|
.thenReturn(null);
|
||||||
|
|
||||||
|
productMemberService.createProductMember(productId, reqVO);
|
||||||
|
|
||||||
|
// 不抛 ALREADY_EXISTS,新加一行
|
||||||
|
verify(userObjectRoleMapper).insert(any(UserObjectRoleDO.class));
|
||||||
|
verify(userObjectRoleMapper, never()).updateById(any(UserObjectRoleDO.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createProductMember_inactiveSameRoleExists_shouldReactivate() {
|
||||||
|
Long productId = 1011L;
|
||||||
|
Long userId = 2002L;
|
||||||
|
Long roleId = 3102L;
|
||||||
|
ProductMemberSaveReqVO reqVO = new ProductMemberSaveReqVO();
|
||||||
|
reqVO.setUserId(userId);
|
||||||
|
reqVO.setRoleId(roleId);
|
||||||
|
UserObjectRoleDO inactiveOld = createMember(9099L, productId, userId, roleId, 1);
|
||||||
|
inactiveOld.setLeftTime(LocalDateTime.now().minusDays(7));
|
||||||
|
when(productMapper.selectById(productId)).thenReturn(createProduct(productId, 2001L));
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
|
||||||
|
.thenReturn(createStatus("active", true));
|
||||||
|
when(objectPermissionApi.getObjectRoleById(roleId, "object", "product"))
|
||||||
|
.thenReturn(success(createRole(roleId, "product_specialist", "产品专员")));
|
||||||
|
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, userId, roleId))
|
||||||
|
.thenReturn(inactiveOld);
|
||||||
|
|
||||||
|
productMemberService.createProductMember(productId, reqVO);
|
||||||
|
|
||||||
|
// 复活:updateById 被调,status=ACTIVE,leftTime=null;不调 insert
|
||||||
|
ArgumentCaptor<UserObjectRoleDO> captor = ArgumentCaptor.forClass(UserObjectRoleDO.class);
|
||||||
|
verify(userObjectRoleMapper).updateById(captor.capture());
|
||||||
|
assertEquals(0, captor.getValue().getStatus());
|
||||||
|
assertNull(captor.getValue().getLeftTime());
|
||||||
|
verify(userObjectRoleMapper, never()).insert(any(UserObjectRoleDO.class));
|
||||||
|
}
|
||||||
|
|
||||||
private ProductDO createProduct(Long productId, Long managerUserId) {
|
private ProductDO createProduct(Long productId, Long managerUserId) {
|
||||||
ProductDO product = new ProductDO();
|
ProductDO product = new ProductDO();
|
||||||
product.setId(productId);
|
product.setId(productId);
|
||||||
@@ -199,4 +251,118 @@ class ProductMemberServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
return statusModel;
|
return statusModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============== review 遗留 4 条 issue 修复后的回归用例 ==============
|
||||||
|
|
||||||
|
/** Issue 1:transferPreviousManager 时原经理在目标 role 上有 INACTIVE 历史行 → 抛 DUPLICATE 异常(不是撞 SQL)。 */
|
||||||
|
@Test
|
||||||
|
void transferPreviousManager_targetRoleHasInactiveHistory_shouldThrowDuplicate() {
|
||||||
|
Long productId = 1020L;
|
||||||
|
ProductMemberSaveReqVO reqVO = new ProductMemberSaveReqVO();
|
||||||
|
reqVO.setUserId(2002L);
|
||||||
|
reqVO.setRoleId(3201L);
|
||||||
|
reqVO.setPreviousManagerUserId(2001L);
|
||||||
|
reqVO.setPreviousManagerRoleId(3202L);
|
||||||
|
ProductDO product = createProduct(productId, 2001L);
|
||||||
|
UserObjectRoleDO inactiveOldRow = createMember(9077L, productId, 2001L, 3202L, 1);
|
||||||
|
inactiveOldRow.setLeftTime(LocalDateTime.now().minusDays(30));
|
||||||
|
when(productMapper.selectById(productId)).thenReturn(product);
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
|
||||||
|
.thenReturn(createStatus("active", true));
|
||||||
|
when(objectPermissionApi.getObjectRoleById(3201L, "object", "product"))
|
||||||
|
.thenReturn(success(createRole(3201L, "product_manager", "产品经理")));
|
||||||
|
when(objectPermissionApi.getObjectRoleById(3202L, "object", "product"))
|
||||||
|
.thenReturn(success(createRole(3202L, "product_specialist", "产品专员")));
|
||||||
|
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, 2002L, 3201L)).thenReturn(null);
|
||||||
|
// 关键:原经理 (2001L, 3202L) 上有 INACTIVE 历史行 —— pre-check 用不带 status 的 select 命中
|
||||||
|
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, 2001L, 3202L))
|
||||||
|
.thenReturn(inactiveOldRow);
|
||||||
|
|
||||||
|
ServiceException ex = assertThrows(ServiceException.class,
|
||||||
|
() -> productMemberService.createProductMember(productId, reqVO));
|
||||||
|
|
||||||
|
assertEquals(ErrorCodeConstants.PRODUCT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE.getCode(), ex.getCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Issue 2:updateProductMember 改 roleId 时目标 role 已被该 user 的另一行占据 → 抛 ALREADY_EXISTS(不是撞 SQL)。 */
|
||||||
|
@Test
|
||||||
|
void updateProductMember_targetRoleHasOtherRow_shouldThrowAlreadyExists() {
|
||||||
|
Long productId = 1021L;
|
||||||
|
Long memberId = 9011L;
|
||||||
|
ProductMemberUpdateReqVO reqVO = new ProductMemberUpdateReqVO();
|
||||||
|
reqVO.setRoleId(3203L);
|
||||||
|
UserObjectRoleDO member = createMember(memberId, productId, 2002L, 3202L, 0);
|
||||||
|
// 该 user 已有另一行持有目标 role 3203L —— 直接 update 会撞唯一索引
|
||||||
|
UserObjectRoleDO conflictRow = createMember(9012L, productId, 2002L, 3203L, 0);
|
||||||
|
when(productMapper.selectById(productId)).thenReturn(createProduct(productId, 2001L));
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
|
||||||
|
.thenReturn(createStatus("active", true));
|
||||||
|
when(userObjectRoleMapper.selectByIdAndObject(memberId, "product", productId)).thenReturn(member);
|
||||||
|
when(objectPermissionApi.getObjectRoleById(3203L, "object", "product"))
|
||||||
|
.thenReturn(success(createRole(3203L, "product_dev", "产品开发")));
|
||||||
|
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, 2002L, 3203L))
|
||||||
|
.thenReturn(conflictRow);
|
||||||
|
|
||||||
|
ServiceException ex = assertThrows(ServiceException.class,
|
||||||
|
() -> productMemberService.updateProductMember(productId, memberId, reqVO));
|
||||||
|
|
||||||
|
assertEquals(ErrorCodeConstants.PRODUCT_MEMBER_ALREADY_EXISTS.getCode(), ex.getCode());
|
||||||
|
verify(userObjectRoleMapper, never()).updateById(any(UserObjectRoleDO.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Issue 3:复活分支审计动作应为 REACTIVATE,不是 ADD(语义区分新增 vs 软失效后再激活)。 */
|
||||||
|
@Test
|
||||||
|
void createProductMember_inactiveSameRoleExists_auditActionShouldBeReactivate() {
|
||||||
|
Long productId = 1022L;
|
||||||
|
Long userId = 2002L;
|
||||||
|
Long roleId = 3202L;
|
||||||
|
ProductMemberSaveReqVO reqVO = new ProductMemberSaveReqVO();
|
||||||
|
reqVO.setUserId(userId);
|
||||||
|
reqVO.setRoleId(roleId);
|
||||||
|
UserObjectRoleDO inactiveOld = createMember(9088L, productId, userId, roleId, 1);
|
||||||
|
inactiveOld.setLeftTime(LocalDateTime.now().minusDays(7));
|
||||||
|
when(productMapper.selectById(productId)).thenReturn(createProduct(productId, 2001L));
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
|
||||||
|
.thenReturn(createStatus("active", true));
|
||||||
|
when(objectPermissionApi.getObjectRoleById(roleId, "object", "product"))
|
||||||
|
.thenReturn(success(createRole(roleId, "product_specialist", "产品专员")));
|
||||||
|
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, userId, roleId))
|
||||||
|
.thenReturn(inactiveOld);
|
||||||
|
|
||||||
|
productMemberService.createProductMember(productId, reqVO);
|
||||||
|
|
||||||
|
ArgumentCaptor<BizAuditLogDO> auditCaptor = ArgumentCaptor.forClass(BizAuditLogDO.class);
|
||||||
|
verify(bizAuditLogMapper).insert(auditCaptor.capture());
|
||||||
|
// reactivate_member 来自 ObjectActivityConstants.MEMBER_ACTION_REACTIVATE,硬编码字面量避免引入额外 import
|
||||||
|
assertEquals("reactivate_member", auditCaptor.getValue().getActionType());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Issue 4:内置 manager 角色未在 system_role 找到 → 抛业务 ServiceException(不是 IllegalStateException 透出 500)。 */
|
||||||
|
@Test
|
||||||
|
void resolveProductManagerRoleId_internalRoleMissing_shouldThrowBusinessException() {
|
||||||
|
Long productId = 1023L;
|
||||||
|
ProductMemberSaveReqVO reqVO = new ProductMemberSaveReqVO();
|
||||||
|
reqVO.setUserId(2002L);
|
||||||
|
reqVO.setRoleId(3201L);
|
||||||
|
reqVO.setPreviousManagerUserId(2001L);
|
||||||
|
reqVO.setPreviousManagerRoleId(3202L);
|
||||||
|
ProductDO product = createProduct(productId, 2001L);
|
||||||
|
when(productMapper.selectById(productId)).thenReturn(product);
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("product", "active"))
|
||||||
|
.thenReturn(createStatus("active", true));
|
||||||
|
when(objectPermissionApi.getObjectRoleById(3201L, "object", "product"))
|
||||||
|
.thenReturn(success(createRole(3201L, "product_manager", "产品经理")));
|
||||||
|
when(objectPermissionApi.getObjectRoleById(3202L, "object", "product"))
|
||||||
|
.thenReturn(success(createRole(3202L, "product_specialist", "产品专员")));
|
||||||
|
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, 2002L, 3201L)).thenReturn(null);
|
||||||
|
when(userObjectRoleMapper.selectByObjectUserAndRole("product", productId, 2001L, 3202L)).thenReturn(null);
|
||||||
|
// 关键:内置 product_manager 角色查询返回 null —— resolveProductManagerRoleId 应抛业务异常
|
||||||
|
when(objectPermissionApi.getObjectRoleByCode("product_manager", "object", "product"))
|
||||||
|
.thenReturn(success(null));
|
||||||
|
|
||||||
|
ServiceException ex = assertThrows(ServiceException.class,
|
||||||
|
() -> productMemberService.createProductMember(productId, reqVO));
|
||||||
|
|
||||||
|
assertEquals(ErrorCodeConstants.PRODUCT_INTERNAL_ROLE_NOT_CONFIGURED.getCode(), ex.getCode());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import com.njcn.rdms.framework.common.exception.ServiceException;
|
|||||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||||
import com.njcn.rdms.framework.common.util.json.JsonUtils;
|
import com.njcn.rdms.framework.common.util.json.JsonUtils;
|
||||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||||
|
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO;
|
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductContextRespVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO;
|
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductDeleteReqVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductOverviewSummaryRespVO;
|
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductOverviewSummaryRespVO;
|
||||||
|
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductSaveReqVO;
|
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductSaveReqVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductStatusActionReqVO;
|
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductStatusActionReqVO;
|
||||||
|
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
|
||||||
|
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
|
||||||
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingBaseInfoUpdateReqVO;
|
import com.njcn.rdms.module.project.controller.admin.product.vo.setting.ProductSettingBaseInfoUpdateReqVO;
|
||||||
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
|
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
|
||||||
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
|
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
|
||||||
@@ -35,11 +39,13 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.MockedStatic;
|
import org.mockito.MockedStatic;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
@@ -74,6 +80,10 @@ class ProductServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
private AdminUserApi adminUserApi;
|
private AdminUserApi adminUserApi;
|
||||||
@Mock
|
@Mock
|
||||||
private com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementModuleMapper requirementModuleMapper;
|
private com.njcn.rdms.module.project.dal.mysql.product.ProductRequirementModuleMapper requirementModuleMapper;
|
||||||
|
@Mock
|
||||||
|
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
|
||||||
|
@Mock
|
||||||
|
private ObjectDataScopeService objectDataScopeService;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createProduct_shouldCreateDefaultRequirementModule() {
|
void createProduct_shouldCreateDefaultRequirementModule() {
|
||||||
@@ -460,9 +470,9 @@ class ProductServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
detail.setPermissions(Set.of("project:product:update"));
|
detail.setPermissions(Set.of("project:product:update"));
|
||||||
|
|
||||||
when(productMapper.selectById(productId)).thenReturn(product);
|
when(productMapper.selectById(productId)).thenReturn(product);
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
|
||||||
.thenReturn(currentMember);
|
.thenReturn(List.of(currentMember));
|
||||||
when(objectPermissionApi.getObjectRolePermissionDetail(roleId, "object", "product"))
|
when(objectPermissionApi.getObjectRolePermissionDetailMerged(List.of(roleId), "object", "product"))
|
||||||
.thenReturn(success(detail));
|
.thenReturn(success(detail));
|
||||||
|
|
||||||
ProductContextRespVO respVO;
|
ProductContextRespVO respVO;
|
||||||
@@ -479,6 +489,107 @@ class ProductServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
assertEquals(List.of("project:product:update"), respVO.getButtons());
|
assertEquals(List.of("project:product:update"), respVO.getButtons());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductContext_returnsImplicitObserver_whenNoExplicitRoleButScopeContains() {
|
||||||
|
Long productId = 1015L;
|
||||||
|
Long loginUserId = 3015L;
|
||||||
|
Long observerRoleId = 9201L;
|
||||||
|
ProductDO product = createProduct(productId, "direction_value", "隐式观察产品", 2018L, "描述", "active");
|
||||||
|
|
||||||
|
ObjectRoleRespDTO observerRole = createRole(observerRoleId, "implicit_observer_product", "产品隐式观察者");
|
||||||
|
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
|
||||||
|
detail.setCurrentRole(observerRole);
|
||||||
|
detail.setMenus(List.of(
|
||||||
|
createMenu(9401L, "概览", null, 2, 10, "/product/overview", "mdi:view-dashboard-outline", true)
|
||||||
|
));
|
||||||
|
|
||||||
|
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class)) {
|
||||||
|
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
|
||||||
|
mockedStatic.when(SecurityFrameworkUtils::getLoginUserNickname).thenReturn("观察人");
|
||||||
|
|
||||||
|
when(productMapper.selectById(productId)).thenReturn(product);
|
||||||
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
|
||||||
|
.thenReturn(Collections.emptyList());
|
||||||
|
when(objectDataScopeService.compute(loginUserId, "product"))
|
||||||
|
.thenReturn(ObjectDataScope.idList(Set.of(productId), Set.of()));
|
||||||
|
when(objectPermissionApi.getObjectRoleByCode("implicit_observer_product", "object", "product"))
|
||||||
|
.thenReturn(success(observerRole));
|
||||||
|
when(objectPermissionApi.getObjectRolePermissionDetailMerged(List.of(observerRoleId), "object", "product"))
|
||||||
|
.thenReturn(success(detail));
|
||||||
|
|
||||||
|
ProductContextRespVO respVO = productService.getProductContext(productId);
|
||||||
|
|
||||||
|
assertNotNull(respVO.getCurrentProduct());
|
||||||
|
assertEquals(observerRoleId, respVO.getCurrentRole().getRoleId());
|
||||||
|
assertEquals("implicit_observer_product", respVO.getCurrentRole().getRoleCode());
|
||||||
|
assertEquals(Boolean.TRUE, respVO.getCurrentRole().getGuestFlag());
|
||||||
|
assertEquals(1, respVO.getNavs().size());
|
||||||
|
assertEquals(9401L, respVO.getNavs().get(0).getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductContext_throws_whenNoExplicitRoleAndScopeMisses() {
|
||||||
|
Long productId = 1016L;
|
||||||
|
Long loginUserId = 3016L;
|
||||||
|
ProductDO product = createProduct(productId, "direction_value", "无权限产品", 2019L, "描述", "active");
|
||||||
|
|
||||||
|
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class)) {
|
||||||
|
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
|
||||||
|
|
||||||
|
when(productMapper.selectById(productId)).thenReturn(product);
|
||||||
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
|
||||||
|
.thenReturn(Collections.emptyList());
|
||||||
|
// scope 不包含该产品(空 ID 列表,directionCode 也不匹配)
|
||||||
|
when(objectDataScopeService.compute(loginUserId, "product"))
|
||||||
|
.thenReturn(ObjectDataScope.idList(Set.of(9999L), Set.of("other_direction")));
|
||||||
|
|
||||||
|
ServiceException ex = assertThrows(ServiceException.class,
|
||||||
|
() -> productService.getProductContext(productId));
|
||||||
|
assertEquals(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductPage_returnsEmpty_whenScopeIsEmpty() {
|
||||||
|
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||||
|
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||||
|
when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.empty());
|
||||||
|
|
||||||
|
PageResult<ProductDO> result = productService.getProductPage(new ProductPageReqVO());
|
||||||
|
|
||||||
|
assertThat(result.getList()).isEmpty();
|
||||||
|
verify(productMapper, never()).selectPage(any(ProductPageReqVO.class), any());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductPage_passesIdListIntoSql_whenScopeIsIdList() {
|
||||||
|
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||||
|
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||||
|
when(objectDataScopeService.compute(1L, "product"))
|
||||||
|
.thenReturn(ObjectDataScope.idList(Set.of(101L), Set.of()));
|
||||||
|
when(productMapper.selectPage(any(ProductPageReqVO.class), any())).thenReturn(new PageResult<>());
|
||||||
|
|
||||||
|
productService.getProductPage(new ProductPageReqVO());
|
||||||
|
|
||||||
|
verify(productMapper, times(1)).selectPage(any(ProductPageReqVO.class), any());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductPage_skipsScopeFilter_whenScopeIsAll() {
|
||||||
|
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||||
|
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||||
|
when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.all());
|
||||||
|
when(productMapper.selectPage(any(ProductPageReqVO.class), any())).thenReturn(new PageResult<>());
|
||||||
|
|
||||||
|
productService.getProductPage(new ProductPageReqVO());
|
||||||
|
|
||||||
|
verify(productMapper, times(1)).selectPage(any(ProductPageReqVO.class), any());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private ProductDO createProduct(Long id, String directionCode, String name, Long managerUserId,
|
private ProductDO createProduct(Long id, String directionCode, String name, Long managerUserId,
|
||||||
String description, String statusCode) {
|
String description, String statusCode) {
|
||||||
ProductDO product = new ProductDO();
|
ProductDO product = new ProductDO();
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ class ProjectMemberServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
|
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
|
||||||
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
|
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
|
||||||
.thenReturn(success(createRole(3102L, "project_member_configured", "项目成员")));
|
.thenReturn(success(createRole(3102L, "project_member_configured", "项目成员")));
|
||||||
when(userObjectRoleMapper.selectByObjectAndUserId("project", projectId, 2002L))
|
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3102L))
|
||||||
.thenReturn(createMember(9002L, projectId, 2002L, 3102L, 0));
|
.thenReturn(createMember(9002L, projectId, 2002L, 3102L, 0));
|
||||||
|
|
||||||
ServiceException ex = assertThrows(ServiceException.class,
|
ServiceException ex = assertThrows(ServiceException.class,
|
||||||
@@ -147,8 +147,15 @@ class ProjectMemberServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
.thenReturn(success(createRole(3101L, "project_manager", "项目经理")));
|
.thenReturn(success(createRole(3101L, "project_manager", "项目经理")));
|
||||||
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
|
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
|
||||||
.thenReturn(success(createRole(3102L, "project_member_configured", "项目成员")));
|
.thenReturn(success(createRole(3102L, "project_member_configured", "项目成员")));
|
||||||
when(userObjectRoleMapper.selectByObjectAndUserId("project", projectId, 2002L)).thenReturn(null);
|
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3101L)).thenReturn(null);
|
||||||
when(userObjectRoleMapper.selectByObjectAndUserId("project", projectId, 2001L)).thenReturn(previousManager);
|
// transferPreviousManager 内部:先校验目标 role(previousManagerRoleId=3102L)无任何行冲突(含 INACTIVE 历史)
|
||||||
|
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2001L, 3102L)).thenReturn(null);
|
||||||
|
// 再按 (user, object, manager_role_id=3101L) 三元组定位旧 manager 行
|
||||||
|
when(userObjectRoleMapper.selectActiveByObjectUserAndRole("project", projectId, 2001L, 3101L))
|
||||||
|
.thenReturn(previousManager);
|
||||||
|
// resolveProjectManagerRoleId() 需要拿 manager 内置角色
|
||||||
|
when(objectPermissionApi.getObjectRoleByCode("project_manager", "object", "project"))
|
||||||
|
.thenReturn(success(createRole(3101L, "project_manager", "项目经理")));
|
||||||
|
|
||||||
projectMemberService.createProjectMember(projectId, reqVO);
|
projectMemberService.createProjectMember(projectId, reqVO);
|
||||||
|
|
||||||
@@ -226,6 +233,58 @@ class ProjectMemberServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_OWNER_HANDOFF_REQUIRED.getCode(), ex.getCode());
|
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_OWNER_HANDOFF_REQUIRED.getCode(), ex.getCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createProjectMember_sameUserDifferentRole_shouldAllow() {
|
||||||
|
Long projectId = 1010L;
|
||||||
|
ProjectMemberSaveReqVO reqVO = new ProjectMemberSaveReqVO();
|
||||||
|
reqVO.setUserId(2002L);
|
||||||
|
reqVO.setRoleId(3102L);
|
||||||
|
when(projectMapper.selectById(projectId)).thenReturn(createProject(projectId, 2001L));
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active"))
|
||||||
|
.thenReturn(createStatus("active", true));
|
||||||
|
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
|
||||||
|
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
|
||||||
|
.thenReturn(success(createRole(3102L, "project_member_configured", "项目成员")));
|
||||||
|
// user=2002L 在项目里 (2002L, 3102L) 这个 role 不存在 —— 即便 user 已有别的 role,新 role 应允许
|
||||||
|
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3102L))
|
||||||
|
.thenReturn(null);
|
||||||
|
|
||||||
|
projectMemberService.createProjectMember(projectId, reqVO);
|
||||||
|
|
||||||
|
// 不抛 ALREADY_EXISTS,新加一行
|
||||||
|
verify(userObjectRoleMapper).insert(any(UserObjectRoleDO.class));
|
||||||
|
verify(userObjectRoleMapper, never()).updateById(any(UserObjectRoleDO.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createProjectMember_inactiveSameRoleExists_shouldReactivate() {
|
||||||
|
Long projectId = 1011L;
|
||||||
|
Long userId = 2002L;
|
||||||
|
Long roleId = 3102L;
|
||||||
|
ProjectMemberSaveReqVO reqVO = new ProjectMemberSaveReqVO();
|
||||||
|
reqVO.setUserId(userId);
|
||||||
|
reqVO.setRoleId(roleId);
|
||||||
|
UserObjectRoleDO inactiveOld = createMember(9099L, projectId, userId, roleId, 1);
|
||||||
|
inactiveOld.setLeftTime(LocalDateTime.now().minusDays(7));
|
||||||
|
when(projectMapper.selectById(projectId)).thenReturn(createProject(projectId, 2001L));
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active"))
|
||||||
|
.thenReturn(createStatus("active", true));
|
||||||
|
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
|
||||||
|
when(objectPermissionApi.getObjectRoleById(roleId, "object", "project"))
|
||||||
|
.thenReturn(success(createRole(roleId, "project_member_configured", "项目成员")));
|
||||||
|
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, userId, roleId))
|
||||||
|
.thenReturn(inactiveOld);
|
||||||
|
|
||||||
|
projectMemberService.createProjectMember(projectId, reqVO);
|
||||||
|
|
||||||
|
// 复活:updateById 被调,status=ACTIVE,leftTime=null;不调 insert
|
||||||
|
ArgumentCaptor<UserObjectRoleDO> captor = ArgumentCaptor.forClass(UserObjectRoleDO.class);
|
||||||
|
verify(userObjectRoleMapper).updateById(captor.capture());
|
||||||
|
assertEquals(0, captor.getValue().getStatus());
|
||||||
|
assertNull(captor.getValue().getLeftTime());
|
||||||
|
verify(userObjectRoleMapper, never()).insert(any(UserObjectRoleDO.class));
|
||||||
|
}
|
||||||
|
|
||||||
private ProjectDO createProject(Long projectId, Long managerUserId) {
|
private ProjectDO createProject(Long projectId, Long managerUserId) {
|
||||||
ProjectDO project = new ProjectDO();
|
ProjectDO project = new ProjectDO();
|
||||||
project.setId(projectId);
|
project.setId(projectId);
|
||||||
@@ -280,4 +339,121 @@ class ProjectMemberServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
return statusModel;
|
return statusModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============== review 遗留 4 条 issue 修复后的回归用例 ==============
|
||||||
|
|
||||||
|
/** Issue 1:transferPreviousManager 时原经理在目标 role 上有 INACTIVE 历史行 → 抛 DUPLICATE 异常(不是撞 SQL)。 */
|
||||||
|
@Test
|
||||||
|
void transferPreviousManager_targetRoleHasInactiveHistory_shouldThrowDuplicate() {
|
||||||
|
Long projectId = 1020L;
|
||||||
|
ProjectMemberSaveReqVO reqVO = new ProjectMemberSaveReqVO();
|
||||||
|
reqVO.setUserId(2002L);
|
||||||
|
reqVO.setRoleId(3101L);
|
||||||
|
reqVO.setPreviousManagerUserId(2001L);
|
||||||
|
reqVO.setPreviousManagerRoleId(3102L);
|
||||||
|
ProjectDO project = createProject(projectId, 2001L);
|
||||||
|
UserObjectRoleDO inactiveOldRow = createMember(9077L, projectId, 2001L, 3102L, 1);
|
||||||
|
inactiveOldRow.setLeftTime(LocalDateTime.now().minusDays(30));
|
||||||
|
when(projectMapper.selectById(projectId)).thenReturn(project);
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active"))
|
||||||
|
.thenReturn(createStatus("active", true));
|
||||||
|
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
|
||||||
|
when(objectPermissionApi.getObjectRoleById(3101L, "object", "project"))
|
||||||
|
.thenReturn(success(createRole(3101L, "project_manager", "项目经理")));
|
||||||
|
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
|
||||||
|
.thenReturn(success(createRole(3102L, "project_member_configured", "项目成员")));
|
||||||
|
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3101L)).thenReturn(null);
|
||||||
|
// 关键:原经理 (2001L, 3102L) 上有 INACTIVE 历史行 —— pre-check 用不带 status 的 select 命中
|
||||||
|
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2001L, 3102L))
|
||||||
|
.thenReturn(inactiveOldRow);
|
||||||
|
|
||||||
|
ServiceException ex = assertThrows(ServiceException.class,
|
||||||
|
() -> projectMemberService.createProjectMember(projectId, reqVO));
|
||||||
|
|
||||||
|
assertEquals(ErrorCodeConstants.PROJECT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE.getCode(), ex.getCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Issue 2:updateProjectMember 改 roleId 时目标 role 已被该 user 的另一行占据 → 抛 ALREADY_EXISTS(不是撞 SQL)。 */
|
||||||
|
@Test
|
||||||
|
void updateProjectMember_targetRoleHasOtherRow_shouldThrowAlreadyExists() {
|
||||||
|
Long projectId = 1021L;
|
||||||
|
Long memberId = 9011L;
|
||||||
|
ProjectMemberUpdateReqVO reqVO = new ProjectMemberUpdateReqVO();
|
||||||
|
reqVO.setRoleId(3103L);
|
||||||
|
UserObjectRoleDO member = createMember(memberId, projectId, 2002L, 3102L, 0);
|
||||||
|
// 该 user 已有另一行持有目标 role 3103L —— 直接 update 会撞唯一索引
|
||||||
|
UserObjectRoleDO conflictRow = createMember(9012L, projectId, 2002L, 3103L, 0);
|
||||||
|
when(projectMapper.selectById(projectId)).thenReturn(createProject(projectId, 2001L));
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active"))
|
||||||
|
.thenReturn(createStatus("active", true));
|
||||||
|
when(userObjectRoleMapper.selectByIdAndObject(memberId, "project", projectId)).thenReturn(member);
|
||||||
|
when(objectPermissionApi.getObjectRoleById(3103L, "object", "project"))
|
||||||
|
.thenReturn(success(createRole(3103L, "project_dev", "项目开发")));
|
||||||
|
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3103L))
|
||||||
|
.thenReturn(conflictRow);
|
||||||
|
|
||||||
|
ServiceException ex = assertThrows(ServiceException.class,
|
||||||
|
() -> projectMemberService.updateProjectMember(projectId, memberId, reqVO));
|
||||||
|
|
||||||
|
assertEquals(ErrorCodeConstants.PROJECT_MEMBER_ALREADY_EXISTS.getCode(), ex.getCode());
|
||||||
|
verify(userObjectRoleMapper, never()).updateById(any(UserObjectRoleDO.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Issue 3:复活分支审计动作应为 REACTIVATE,不是 ADD(语义区分新增 vs 软失效后再激活)。 */
|
||||||
|
@Test
|
||||||
|
void createProjectMember_inactiveSameRoleExists_auditActionShouldBeReactivate() {
|
||||||
|
Long projectId = 1022L;
|
||||||
|
Long userId = 2002L;
|
||||||
|
Long roleId = 3102L;
|
||||||
|
ProjectMemberSaveReqVO reqVO = new ProjectMemberSaveReqVO();
|
||||||
|
reqVO.setUserId(userId);
|
||||||
|
reqVO.setRoleId(roleId);
|
||||||
|
UserObjectRoleDO inactiveOld = createMember(9088L, projectId, userId, roleId, 1);
|
||||||
|
inactiveOld.setLeftTime(LocalDateTime.now().minusDays(7));
|
||||||
|
when(projectMapper.selectById(projectId)).thenReturn(createProject(projectId, 2001L));
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active"))
|
||||||
|
.thenReturn(createStatus("active", true));
|
||||||
|
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
|
||||||
|
when(objectPermissionApi.getObjectRoleById(roleId, "object", "project"))
|
||||||
|
.thenReturn(success(createRole(roleId, "project_member_configured", "项目成员")));
|
||||||
|
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, userId, roleId))
|
||||||
|
.thenReturn(inactiveOld);
|
||||||
|
|
||||||
|
projectMemberService.createProjectMember(projectId, reqVO);
|
||||||
|
|
||||||
|
ArgumentCaptor<BizAuditLogDO> auditCaptor = ArgumentCaptor.forClass(BizAuditLogDO.class);
|
||||||
|
verify(bizAuditLogMapper).insert(auditCaptor.capture());
|
||||||
|
// reactivate_member 来自 ObjectActivityConstants.MEMBER_ACTION_REACTIVATE,硬编码字面量避免引入额外 import
|
||||||
|
assertEquals("reactivate_member", auditCaptor.getValue().getActionType());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Issue 4:内置 manager 角色未在 system_role 找到 → 抛业务 ServiceException(不是 IllegalStateException 透出 500)。 */
|
||||||
|
@Test
|
||||||
|
void resolveProjectManagerRoleId_internalRoleMissing_shouldThrowBusinessException() {
|
||||||
|
Long projectId = 1023L;
|
||||||
|
ProjectMemberSaveReqVO reqVO = new ProjectMemberSaveReqVO();
|
||||||
|
reqVO.setUserId(2002L);
|
||||||
|
reqVO.setRoleId(3101L);
|
||||||
|
reqVO.setPreviousManagerUserId(2001L);
|
||||||
|
reqVO.setPreviousManagerRoleId(3102L);
|
||||||
|
ProjectDO project = createProject(projectId, 2001L);
|
||||||
|
when(projectMapper.selectById(projectId)).thenReturn(project);
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "active"))
|
||||||
|
.thenReturn(createStatus("active", true));
|
||||||
|
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
|
||||||
|
when(objectPermissionApi.getObjectRoleById(3101L, "object", "project"))
|
||||||
|
.thenReturn(success(createRole(3101L, "project_manager", "项目经理")));
|
||||||
|
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
|
||||||
|
.thenReturn(success(createRole(3102L, "project_member_configured", "项目成员")));
|
||||||
|
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3101L)).thenReturn(null);
|
||||||
|
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2001L, 3102L)).thenReturn(null);
|
||||||
|
// 关键:内置 project_manager 角色查询返回 null —— resolveProjectManagerRoleId 应抛业务异常
|
||||||
|
when(objectPermissionApi.getObjectRoleByCode("project_manager", "object", "project"))
|
||||||
|
.thenReturn(success(null));
|
||||||
|
|
||||||
|
ServiceException ex = assertThrows(ServiceException.class,
|
||||||
|
() -> projectMemberService.createProjectMember(projectId, reqVO));
|
||||||
|
|
||||||
|
assertEquals(ErrorCodeConstants.PROJECT_INTERNAL_ROLE_NOT_CONFIGURED.getCode(), ex.getCode());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ package com.njcn.rdms.module.project.service.project;
|
|||||||
import com.njcn.rdms.framework.common.exception.ServiceException;
|
import com.njcn.rdms.framework.common.exception.ServiceException;
|
||||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||||
|
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO;
|
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectContextRespVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectLifecycleActionRespVO;
|
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectLifecycleActionRespVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
|
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectOverviewSummaryRespVO;
|
||||||
|
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectPageReqVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectRespVO;
|
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectRespVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectSaveReqVO;
|
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectSaveReqVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectStatusActionReqVO;
|
import com.njcn.rdms.module.project.controller.admin.project.vo.project.ProjectStatusActionReqVO;
|
||||||
|
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
|
||||||
|
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
|
||||||
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
|
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
|
||||||
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
|
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
|
||||||
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
|
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
|
||||||
@@ -39,11 +43,13 @@ import org.mockito.Mock;
|
|||||||
import org.mockito.MockedStatic;
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
@@ -81,6 +87,8 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
private AdminUserApi adminUserApi;
|
private AdminUserApi adminUserApi;
|
||||||
@Mock
|
@Mock
|
||||||
private DictDataApi dictDataApi;
|
private DictDataApi dictDataApi;
|
||||||
|
@Mock
|
||||||
|
private ObjectDataScopeService objectDataScopeService;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getProjectDetail_shouldFillProductNameAndManagerNickname() {
|
void getProjectDetail_shouldFillProductNameAndManagerNickname() {
|
||||||
@@ -322,22 +330,28 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getProjectContext_whenCreatorHasNoObjectRole_shouldReturnVisitorResources() {
|
void getProjectContext_whenCreatorHasNoObjectRole_shouldReturnVisitorResources() {
|
||||||
|
// 改造后:无显式角色 + scope 命中 → 返回 implicit_observer_project 上下文(原 visitor 兜底已由 scope.contains 替代)
|
||||||
Long projectId = 1002L;
|
Long projectId = 1002L;
|
||||||
Long loginUserId = 3002L;
|
Long loginUserId = 3002L;
|
||||||
Long visitorRoleId = 9201L;
|
Long observerRoleId = 9202L;
|
||||||
ProjectDO project = createProject(projectId, null, "创建人项目", 2001L, "pending");
|
ProjectDO project = createProject(projectId, null, "创建人项目", 2001L, "pending");
|
||||||
project.setCreator(String.valueOf(loginUserId));
|
project.setCreator(String.valueOf(loginUserId));
|
||||||
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
|
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
|
||||||
detail.setCurrentRole(createRole(visitorRoleId, "visitor", "游客"));
|
detail.setCurrentRole(createRole(observerRoleId, "implicit_observer_project", "项目隐式观察者"));
|
||||||
detail.setMenus(List.of(
|
detail.setMenus(List.of(
|
||||||
createMenu(9301L, "概览", null, 2, 10, "/project/project/overview", "mdi:home-outline", true)
|
createMenu(9301L, "概览", null, 2, 10, "/project/project/overview", "mdi:home-outline", true)
|
||||||
));
|
));
|
||||||
detail.setPermissions(Set.of());
|
detail.setPermissions(Set.of());
|
||||||
|
detail.setAdditionalRoleNames(Collections.emptyList());
|
||||||
when(projectMapper.selectById(projectId)).thenReturn(project);
|
when(projectMapper.selectById(projectId)).thenReturn(project);
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId)).thenReturn(null);
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
|
||||||
when(objectPermissionApi.getObjectRoleByCode("visitor", "object", "project"))
|
.thenReturn(Collections.emptyList());
|
||||||
.thenReturn(success(createRole(visitorRoleId, "visitor", "游客")));
|
// scope 命中该项目 id
|
||||||
when(objectPermissionApi.getObjectRolePermissionDetail(visitorRoleId, "object", "project"))
|
when(objectDataScopeService.compute(loginUserId, "project"))
|
||||||
|
.thenReturn(ObjectDataScope.idList(Set.of(projectId), Set.of()));
|
||||||
|
when(objectPermissionApi.getObjectRoleByCode("implicit_observer_project", "object", "project"))
|
||||||
|
.thenReturn(success(createRole(observerRoleId, "implicit_observer_project", "项目隐式观察者")));
|
||||||
|
when(objectPermissionApi.getObjectRolePermissionDetailMerged(List.of(observerRoleId), "object", "project"))
|
||||||
.thenReturn(success(detail));
|
.thenReturn(success(detail));
|
||||||
when(projectStatusViewService.getLifecycle("pending"))
|
when(projectStatusViewService.getLifecycle("pending"))
|
||||||
.thenReturn(new ProjectStatusViewService.ProjectLifecycleView(
|
.thenReturn(new ProjectStatusViewService.ProjectLifecycleView(
|
||||||
@@ -357,14 +371,115 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
assertEquals(1, respVO.getCurrentProject().getAvailableActions().size());
|
assertEquals(1, respVO.getCurrentProject().getAvailableActions().size());
|
||||||
assertEquals("cancel", respVO.getCurrentProject().getAvailableActions().get(0).getActionCode());
|
assertEquals("cancel", respVO.getCurrentProject().getAvailableActions().get(0).getActionCode());
|
||||||
assertEquals(Boolean.TRUE, respVO.getCurrentRole().getGuestFlag());
|
assertEquals(Boolean.TRUE, respVO.getCurrentRole().getGuestFlag());
|
||||||
assertEquals(visitorRoleId, respVO.getCurrentRole().getRoleId());
|
assertEquals(observerRoleId, respVO.getCurrentRole().getRoleId());
|
||||||
assertEquals("visitor", respVO.getCurrentRole().getRoleCode());
|
assertEquals("implicit_observer_project", respVO.getCurrentRole().getRoleCode());
|
||||||
assertEquals("游客", respVO.getCurrentRole().getRoleName());
|
assertEquals("项目隐式观察者", respVO.getCurrentRole().getRoleName());
|
||||||
assertEquals(1, respVO.getNavs().size());
|
assertEquals(1, respVO.getNavs().size());
|
||||||
assertEquals(9301L, respVO.getNavs().get(0).getId());
|
assertEquals(9301L, respVO.getNavs().get(0).getId());
|
||||||
assertEquals(List.of(), respVO.getButtons());
|
assertEquals(List.of(), respVO.getButtons());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProjectContext_returnsImplicitObserver_whenNoExplicitRoleButScopeContains() {
|
||||||
|
Long projectId = 100L;
|
||||||
|
Long loginUserId = 1L;
|
||||||
|
Long observerRoleId = 9203L;
|
||||||
|
ProjectDO project = createProject(projectId, null, "观察者项目", 2001L, "active");
|
||||||
|
project.setDirectionCode("dc_obs");
|
||||||
|
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
|
||||||
|
detail.setCurrentRole(createRole(observerRoleId, "implicit_observer_project", "项目隐式观察者"));
|
||||||
|
detail.setMenus(List.of(
|
||||||
|
createMenu(9401L, "任务", null, 2, 20, "/project/project/task", "mdi:format-list-checks", true)
|
||||||
|
));
|
||||||
|
detail.setPermissions(Set.of());
|
||||||
|
detail.setAdditionalRoleNames(Collections.emptyList());
|
||||||
|
when(projectMapper.selectById(projectId)).thenReturn(project);
|
||||||
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
|
||||||
|
.thenReturn(Collections.emptyList());
|
||||||
|
when(objectDataScopeService.compute(loginUserId, "project"))
|
||||||
|
.thenReturn(ObjectDataScope.idList(Set.of(projectId), Set.of()));
|
||||||
|
when(objectPermissionApi.getObjectRoleByCode("implicit_observer_project", "object", "project"))
|
||||||
|
.thenReturn(success(createRole(observerRoleId, "implicit_observer_project", "项目隐式观察者")));
|
||||||
|
when(objectPermissionApi.getObjectRolePermissionDetailMerged(List.of(observerRoleId), "object", "project"))
|
||||||
|
.thenReturn(success(detail));
|
||||||
|
when(projectStatusViewService.getLifecycle("active"))
|
||||||
|
.thenReturn(new ProjectStatusViewService.ProjectLifecycleView(
|
||||||
|
"进行中", false, true, List.of()
|
||||||
|
));
|
||||||
|
|
||||||
|
ProjectContextRespVO respVO;
|
||||||
|
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "观察者")) {
|
||||||
|
respVO = projectService.getProjectContext(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNotNull(respVO);
|
||||||
|
assertEquals(Boolean.TRUE, respVO.getCurrentRole().getGuestFlag());
|
||||||
|
assertEquals("项目隐式观察者", respVO.getCurrentRole().getRoleName());
|
||||||
|
assertEquals("implicit_observer_project", respVO.getCurrentRole().getRoleCode());
|
||||||
|
assertEquals(1, respVO.getNavs().size());
|
||||||
|
assertEquals(List.of(), respVO.getButtons());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProjectContext_throws_whenNoExplicitRoleAndScopeMisses() {
|
||||||
|
Long projectId = 100L;
|
||||||
|
Long loginUserId = 1L;
|
||||||
|
ProjectDO project = createProject(projectId, null, "无权限项目", 2001L, "active");
|
||||||
|
project.setDirectionCode("dc_miss");
|
||||||
|
when(projectMapper.selectById(projectId)).thenReturn(project);
|
||||||
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
|
||||||
|
.thenReturn(Collections.emptyList());
|
||||||
|
when(objectDataScopeService.compute(loginUserId, "project"))
|
||||||
|
.thenReturn(ObjectDataScope.empty());
|
||||||
|
|
||||||
|
ServiceException ex;
|
||||||
|
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId, "无关用户")) {
|
||||||
|
ex = assertThrows(ServiceException.class, () -> projectService.getProjectContext(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProjectPage_returnsEmpty_whenScopeIsEmpty() {
|
||||||
|
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||||
|
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||||
|
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.empty());
|
||||||
|
|
||||||
|
PageResult<ProjectDO> result = projectService.getProjectPage(new ProjectPageReqVO());
|
||||||
|
|
||||||
|
assertThat(result.getList()).isEmpty();
|
||||||
|
verify(projectMapper, never()).selectPage(any(ProjectPageReqVO.class), any());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProjectPage_passesIdListIntoSql_whenScopeIsIdList() {
|
||||||
|
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||||
|
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||||
|
when(objectDataScopeService.compute(1L, "project"))
|
||||||
|
.thenReturn(ObjectDataScope.idList(Set.of(101L), Set.of()));
|
||||||
|
when(projectMapper.selectPage(any(ProjectPageReqVO.class), any())).thenReturn(new PageResult<>());
|
||||||
|
|
||||||
|
projectService.getProjectPage(new ProjectPageReqVO());
|
||||||
|
|
||||||
|
verify(projectMapper, times(1)).selectPage(any(ProjectPageReqVO.class), any());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProjectPage_skipsScopeFilter_whenScopeIsAll() {
|
||||||
|
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||||
|
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L);
|
||||||
|
when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.all());
|
||||||
|
when(projectMapper.selectPage(any(ProjectPageReqVO.class), any())).thenReturn(new PageResult<>());
|
||||||
|
|
||||||
|
projectService.getProjectPage(new ProjectPageReqVO());
|
||||||
|
|
||||||
|
verify(projectMapper, times(1)).selectPage(any(ProjectPageReqVO.class), any());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void changeProjectStatus_shouldUpdateStatusAndWriteStatusAndAuditLogs() {
|
void changeProjectStatus_shouldUpdateStatusAndWriteStatusAndAuditLogs() {
|
||||||
Long projectId = 1003L;
|
Long projectId = 1003L;
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ class ProjectExecutionAssigneeServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
@Test
|
@Test
|
||||||
void createExecutionAssignee_byUserNeverJoined_shouldInsertActiveAndWriteJoinLog() {
|
void createExecutionAssignee_byUserNeverJoined_shouldInsertActiveAndWriteJoinLog() {
|
||||||
stubEditableContext();
|
stubEditableContext();
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", PROJECT_ID, USER_ID))
|
||||||
.thenReturn(createProjectMember());
|
.thenReturn(List.of(createProjectMember()));
|
||||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
|
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
|
||||||
when(executionAssigneeMapper.insert(any(ExecutionAssigneeDO.class))).thenAnswer(inv -> {
|
when(executionAssigneeMapper.insert(any(ExecutionAssigneeDO.class))).thenAnswer(inv -> {
|
||||||
ExecutionAssigneeDO m = inv.getArgument(0);
|
ExecutionAssigneeDO m = inv.getArgument(0);
|
||||||
@@ -128,8 +128,8 @@ class ProjectExecutionAssigneeServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
void createExecutionAssignee_whenInactiveSegmentExists_shouldInsertNewSegmentAndPreserveHistoricalReason() {
|
void createExecutionAssignee_whenInactiveSegmentExists_shouldInsertNewSegmentAndPreserveHistoricalReason() {
|
||||||
// B 模型:用户曾失效,重新加入新插一段,旧段不动
|
// B 模型:用户曾失效,重新加入新插一段,旧段不动
|
||||||
stubEditableContext();
|
stubEditableContext();
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", PROJECT_ID, USER_ID))
|
||||||
.thenReturn(createProjectMember());
|
.thenReturn(List.of(createProjectMember()));
|
||||||
// 当前没有活跃段(旧段已失效),通过 active-only 校验
|
// 当前没有活跃段(旧段已失效),通过 active-only 校验
|
||||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
|
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
|
||||||
when(executionAssigneeMapper.insert(any(ExecutionAssigneeDO.class))).thenAnswer(inv -> {
|
when(executionAssigneeMapper.insert(any(ExecutionAssigneeDO.class))).thenAnswer(inv -> {
|
||||||
@@ -152,8 +152,8 @@ class ProjectExecutionAssigneeServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
@Test
|
@Test
|
||||||
void createExecutionAssignee_whenAlreadyActive_shouldThrowAlreadyExists() {
|
void createExecutionAssignee_whenAlreadyActive_shouldThrowAlreadyExists() {
|
||||||
stubEditableContext();
|
stubEditableContext();
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", PROJECT_ID, USER_ID))
|
||||||
.thenReturn(createProjectMember());
|
.thenReturn(List.of(createProjectMember()));
|
||||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
|
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
|
||||||
.thenReturn(createMember(7001L, USER_ID, null));
|
.thenReturn(createMember(7001L, USER_ID, null));
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.atLeastOnce;
|
import static org.mockito.Mockito.atLeastOnce;
|
||||||
@@ -119,12 +120,12 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
.thenReturn(createProjectStatus("pending", true));
|
.thenReturn(createProjectStatus("pending", true));
|
||||||
when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("execution"))
|
when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("execution"))
|
||||||
.thenReturn(createExecutionStatus("pending", true));
|
.thenReturn(createExecutionStatus("pending", true));
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
|
||||||
.thenReturn(createProjectMember(projectId, 3001L, 3101L));
|
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3002L))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3002L))
|
||||||
.thenReturn(createProjectMember(projectId, 3002L, 3102L));
|
.thenReturn(List.of(createProjectMember(projectId, 3002L, 3102L)));
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3003L))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3003L))
|
||||||
.thenReturn(createProjectMember(projectId, 3003L, 3102L));
|
.thenReturn(List.of(createProjectMember(projectId, 3003L, 3102L)));
|
||||||
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
|
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
|
||||||
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
|
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
|
||||||
.thenReturn(success(true));
|
.thenReturn(success(true));
|
||||||
@@ -180,8 +181,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
|
||||||
.thenReturn(createProjectStatus("pending", true));
|
.thenReturn(createProjectStatus("pending", true));
|
||||||
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
|
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
|
||||||
.thenReturn(createProjectMember(projectId, 3001L, 3101L));
|
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
|
||||||
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("unknown")))
|
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("unknown")))
|
||||||
.thenThrow(new RuntimeException("invalid dict"));
|
.thenThrow(new RuntimeException("invalid dict"));
|
||||||
|
|
||||||
@@ -204,8 +205,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
|
||||||
.thenReturn(createProjectStatus("pending", true));
|
.thenReturn(createProjectStatus("pending", true));
|
||||||
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
|
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
|
||||||
.thenReturn(createProjectMember(projectId, 3001L, 3101L));
|
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
|
||||||
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
|
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
|
||||||
.thenReturn(success(true));
|
.thenReturn(success(true));
|
||||||
|
|
||||||
@@ -233,8 +234,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
.thenReturn(createExecutionStatus("pending", true));
|
.thenReturn(createExecutionStatus("pending", true));
|
||||||
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
|
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
|
||||||
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调-修订")).thenReturn(null);
|
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调-修订")).thenReturn(null);
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
|
||||||
.thenReturn(createProjectMember(projectId, 3001L, 3101L));
|
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
|
||||||
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
|
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
|
||||||
.thenReturn(success(true));
|
.thenReturn(success(true));
|
||||||
|
|
||||||
@@ -255,8 +256,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
|
||||||
.thenReturn(createExecutionStatus("pending", true));
|
.thenReturn(createExecutionStatus("pending", true));
|
||||||
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
|
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
|
||||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3002L))
|
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3002L))
|
||||||
.thenReturn(createProjectMember(projectId, 3002L, 3102L));
|
.thenReturn(List.of(createProjectMember(projectId, 3002L, 3102L)));
|
||||||
|
|
||||||
ProjectExecutionOwnerChangeReqVO reqVO = new ProjectExecutionOwnerChangeReqVO();
|
ProjectExecutionOwnerChangeReqVO reqVO = new ProjectExecutionOwnerChangeReqVO();
|
||||||
reqVO.setNewOwnerId(3002L);
|
reqVO.setNewOwnerId(3002L);
|
||||||
@@ -417,15 +418,17 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
|
|
||||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||||
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
|
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
|
||||||
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId))
|
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
|
||||||
|
.thenReturn(List.of("cancelled"));
|
||||||
|
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled")))
|
||||||
.thenReturn(new BigDecimal("66.666"));
|
.thenReturn(new BigDecimal("66.666"));
|
||||||
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
|
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("66.67")), anyBoolean()))
|
||||||
.thenReturn(createLifecycleView());
|
.thenReturn(createLifecycleView());
|
||||||
|
|
||||||
ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);
|
ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);
|
||||||
|
|
||||||
assertEquals(new BigDecimal("66.67"), result.getProgressRate());
|
assertEquals(new BigDecimal("66.67"), result.getProgressRate());
|
||||||
verify(projectTaskMapper).selectRootTaskAvgProgressByExecutionId(projectId, executionId);
|
verify(projectTaskMapper).selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -437,8 +440,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
|
|
||||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||||
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
|
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
|
||||||
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId)).thenReturn(null);
|
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
|
||||||
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
|
.thenReturn(List.of("cancelled"));
|
||||||
|
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled")))
|
||||||
|
.thenReturn(null);
|
||||||
|
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("0.00")), anyBoolean()))
|
||||||
.thenReturn(createLifecycleView());
|
.thenReturn(createLifecycleView());
|
||||||
|
|
||||||
ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);
|
ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);
|
||||||
@@ -446,6 +452,27 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
assertEquals(new BigDecimal("0.00"), result.getProgressRate());
|
assertEquals(new BigDecimal("0.00"), result.getProgressRate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getExecutionRespVO_shouldPassConfiguredExcludedStatusesToProgressAggregation() {
|
||||||
|
Long projectId = 2001L;
|
||||||
|
Long executionId = 5001L;
|
||||||
|
ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
|
||||||
|
|
||||||
|
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||||
|
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
|
||||||
|
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
|
||||||
|
.thenReturn(List.of("cancelled"));
|
||||||
|
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled")))
|
||||||
|
.thenReturn(new BigDecimal("100.00"));
|
||||||
|
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("100.00")), anyBoolean()))
|
||||||
|
.thenReturn(createLifecycleView());
|
||||||
|
|
||||||
|
ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);
|
||||||
|
|
||||||
|
assertEquals(new BigDecimal("100.00"), result.getProgressRate());
|
||||||
|
verify(projectTaskMapper).selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getExecutionRespVOPage_shouldBatchOverwriteCachedProgress() {
|
void getExecutionRespVOPage_shouldBatchOverwriteCachedProgress() {
|
||||||
Long projectId = 2001L;
|
Long projectId = 2001L;
|
||||||
@@ -460,11 +487,13 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||||
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
|
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
|
||||||
.thenReturn(new PageResult<>(List.of(first, second), 2L));
|
.thenReturn(new PageResult<>(List.of(first, second), 2L));
|
||||||
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection()))
|
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
|
||||||
|
.thenReturn(List.of("cancelled"));
|
||||||
|
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection(), eq(List.of("cancelled"))))
|
||||||
.thenReturn(List.of(Map.of("execution_id", 5001L, "progress_rate", new BigDecimal("25.555"))));
|
.thenReturn(List.of(Map.of("execution_id", 5001L, "progress_rate", new BigDecimal("25.555"))));
|
||||||
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
|
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("25.56")), anyBoolean()))
|
||||||
.thenReturn(createLifecycleView());
|
.thenReturn(createLifecycleView());
|
||||||
when(projectExecutionStatusViewService.getLifecycle("pending", 3002L))
|
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3002L), eq(new BigDecimal("0.00")), anyBoolean()))
|
||||||
.thenReturn(createLifecycleView());
|
.thenReturn(createLifecycleView());
|
||||||
|
|
||||||
PageResult<ProjectExecutionRespVO> result = projectExecutionService.getExecutionRespVOPage(projectId, reqVO);
|
PageResult<ProjectExecutionRespVO> result = projectExecutionService.getExecutionRespVOPage(projectId, reqVO);
|
||||||
@@ -472,7 +501,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
assertEquals(new BigDecimal("25.56"), result.getList().get(0).getProgressRate());
|
assertEquals(new BigDecimal("25.56"), result.getList().get(0).getProgressRate());
|
||||||
assertEquals(new BigDecimal("0.00"), result.getList().get(1).getProgressRate());
|
assertEquals(new BigDecimal("0.00"), result.getList().get(1).getProgressRate());
|
||||||
ArgumentCaptor<Collection<Long>> executionIdsCaptor = ArgumentCaptor.forClass(Collection.class);
|
ArgumentCaptor<Collection<Long>> executionIdsCaptor = ArgumentCaptor.forClass(Collection.class);
|
||||||
verify(projectTaskMapper).selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), executionIdsCaptor.capture());
|
verify(projectTaskMapper).selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), executionIdsCaptor.capture(),
|
||||||
|
eq(List.of("cancelled")));
|
||||||
assertEquals(List.of(5001L, 5002L), List.copyOf(executionIdsCaptor.getValue()));
|
assertEquals(List.of(5001L, 5002L), List.copyOf(executionIdsCaptor.getValue()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,11 +523,13 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||||
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
|
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
|
||||||
.thenReturn(new PageResult<>(List.of(first, second), 2L));
|
.thenReturn(new PageResult<>(List.of(first, second), 2L));
|
||||||
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection()))
|
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
|
||||||
|
.thenReturn(List.of("cancelled"));
|
||||||
|
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection(), eq(List.of("cancelled"))))
|
||||||
.thenReturn(rows);
|
.thenReturn(rows);
|
||||||
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
|
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("25.56")), anyBoolean()))
|
||||||
.thenReturn(createLifecycleView());
|
.thenReturn(createLifecycleView());
|
||||||
when(projectExecutionStatusViewService.getLifecycle("pending", 3002L))
|
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3002L), eq(new BigDecimal("10.00")), anyBoolean()))
|
||||||
.thenReturn(createLifecycleView());
|
.thenReturn(createLifecycleView());
|
||||||
|
|
||||||
PageResult<ProjectExecutionRespVO> result = projectExecutionService.getExecutionRespVOPage(projectId, reqVO);
|
PageResult<ProjectExecutionRespVO> result = projectExecutionService.getExecutionRespVOPage(projectId, reqVO);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.njcn.rdms.module.project.service.project.execution;
|
|||||||
import com.njcn.rdms.framework.common.exception.ServiceException;
|
import com.njcn.rdms.framework.common.exception.ServiceException;
|
||||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||||
|
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionLifecycleActionRespVO;
|
||||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
|
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
|
||||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||||
@@ -13,6 +14,7 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.MockedStatic;
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
@@ -45,7 +47,7 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
|
|||||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
||||||
|
|
||||||
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
|
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
|
||||||
projectExecutionStatusViewService.getLifecycle("active", ownerId);
|
projectExecutionStatusViewService.getLifecycle("active", ownerId, BigDecimal.ZERO, false);
|
||||||
|
|
||||||
assertEquals("进行中", result.statusName());
|
assertEquals("进行中", result.statusName());
|
||||||
assertFalse(result.terminal());
|
assertFalse(result.terminal());
|
||||||
@@ -73,12 +75,83 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
|
|||||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(otherUserId);
|
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(otherUserId);
|
||||||
|
|
||||||
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
|
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
|
||||||
projectExecutionStatusViewService.getLifecycle("active", ownerId);
|
projectExecutionStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"), true);
|
||||||
|
|
||||||
assertTrue(result.availableActions().isEmpty());
|
assertTrue(result.availableActions().isEmpty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getLifecycle_whenOwnerButProgressBelow100_shouldFilterCompleteOnly() {
|
||||||
|
Long ownerId = 3001L;
|
||||||
|
ObjectStatusModelDO statusModel = createStatusModel();
|
||||||
|
ObjectStatusTransitionDO complete = createTransition("complete", "完成", false);
|
||||||
|
ObjectStatusTransitionDO pause = createTransition("pause", "暂停", true);
|
||||||
|
ObjectStatusTransitionDO cancel = createTransition("cancel", "取消", true);
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "active"))
|
||||||
|
.thenReturn(statusModel);
|
||||||
|
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("execution", "active"))
|
||||||
|
.thenReturn(List.of(complete, pause, cancel));
|
||||||
|
|
||||||
|
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||||
|
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
||||||
|
|
||||||
|
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
|
||||||
|
projectExecutionStatusViewService.getLifecycle("active", ownerId, new BigDecimal("99.99"), true);
|
||||||
|
|
||||||
|
assertEquals(List.of("pause", "cancel"), result.availableActions().stream()
|
||||||
|
.map(ProjectExecutionLifecycleActionRespVO::getActionCode)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getLifecycle_whenOwnerAndProgress100AndRootTasksAllCompleted_shouldReturnComplete() {
|
||||||
|
Long ownerId = 3001L;
|
||||||
|
ObjectStatusModelDO statusModel = createStatusModel();
|
||||||
|
ObjectStatusTransitionDO complete = createTransition("complete", "完成", false);
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "active"))
|
||||||
|
.thenReturn(statusModel);
|
||||||
|
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("execution", "active"))
|
||||||
|
.thenReturn(List.of(complete));
|
||||||
|
|
||||||
|
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||||
|
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
||||||
|
|
||||||
|
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
|
||||||
|
projectExecutionStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"), true);
|
||||||
|
|
||||||
|
assertEquals(1, result.availableActions().size());
|
||||||
|
assertEquals("complete", result.availableActions().get(0).getActionCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getLifecycle_whenOwnerAndProgress100ButRootTasksNotAllCompleted_shouldFilterComplete() {
|
||||||
|
// 任务进度算到 100 但任务状态尚未流转到 completed(或空集)时,complete 按钮不下发;
|
||||||
|
// 同时验证 pause / cancel 等不受根任务完成态影响。
|
||||||
|
Long ownerId = 3001L;
|
||||||
|
ObjectStatusModelDO statusModel = createStatusModel();
|
||||||
|
ObjectStatusTransitionDO complete = createTransition("complete", "完成", false);
|
||||||
|
ObjectStatusTransitionDO pause = createTransition("pause", "暂停", true);
|
||||||
|
ObjectStatusTransitionDO cancel = createTransition("cancel", "取消", true);
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "active"))
|
||||||
|
.thenReturn(statusModel);
|
||||||
|
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("execution", "active"))
|
||||||
|
.thenReturn(List.of(complete, pause, cancel));
|
||||||
|
|
||||||
|
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||||
|
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
||||||
|
|
||||||
|
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
|
||||||
|
projectExecutionStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"), false);
|
||||||
|
|
||||||
|
assertEquals(List.of("pause", "cancel"), result.availableActions().stream()
|
||||||
|
.map(ProjectExecutionLifecycleActionRespVO::getActionCode)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getLifecycle_shouldExcludeAutoStartAction() {
|
void getLifecycle_shouldExcludeAutoStartAction() {
|
||||||
Long ownerId = 3001L;
|
Long ownerId = 3001L;
|
||||||
@@ -98,7 +171,7 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
|
|||||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
||||||
|
|
||||||
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
|
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
|
||||||
projectExecutionStatusViewService.getLifecycle("pending", ownerId);
|
projectExecutionStatusViewService.getLifecycle("pending", ownerId, BigDecimal.ZERO, false);
|
||||||
|
|
||||||
assertTrue(result.availableActions().isEmpty());
|
assertTrue(result.availableActions().isEmpty());
|
||||||
}
|
}
|
||||||
@@ -110,7 +183,7 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
|
|||||||
.thenReturn(null);
|
.thenReturn(null);
|
||||||
|
|
||||||
ServiceException ex = assertThrows(ServiceException.class,
|
ServiceException ex = assertThrows(ServiceException.class,
|
||||||
() -> projectExecutionStatusViewService.getLifecycle("active", 3001L));
|
() -> projectExecutionStatusViewService.getLifecycle("active", 3001L, BigDecimal.ZERO, false));
|
||||||
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED.getCode(), ex.getCode());
|
assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED.getCode(), ex.getCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.njcn.rdms.module.project.service.project.task;
|
package com.njcn.rdms.module.project.service.project.task;
|
||||||
|
|
||||||
import com.njcn.rdms.framework.common.exception.ServiceException;
|
import com.njcn.rdms.framework.common.exception.ServiceException;
|
||||||
|
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskSaveReqVO;
|
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskSaveReqVO;
|
||||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusActionReqVO;
|
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusActionReqVO;
|
||||||
@@ -32,8 +33,10 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
@@ -43,6 +46,7 @@ import static org.mockito.ArgumentMatchers.any;
|
|||||||
import static org.mockito.ArgumentMatchers.argThat;
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.lenient;
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -319,6 +323,78 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
|
|
||||||
// -------------------- Phase 3 进度自动汇总 + 叶子转父限制 --------------------
|
// -------------------- Phase 3 进度自动汇总 + 叶子转父限制 --------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changeTaskStatus_whenCancelChild_shouldRecalculateParentProgressWithExcludedStatuses() {
|
||||||
|
Long projectId = 2001L;
|
||||||
|
Long executionId = 5001L;
|
||||||
|
Long parentTaskId = 8001L;
|
||||||
|
Long taskId = 9001L;
|
||||||
|
ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
|
||||||
|
task.setParentTaskId(parentTaskId);
|
||||||
|
ProjectTaskDO parent = createTask(projectId, executionId, parentTaskId, 3002L);
|
||||||
|
parent.setProgressRate(new BigDecimal("50.00"));
|
||||||
|
ProjectTaskDO remainingChild = createTask(projectId, executionId, 9002L, 3002L);
|
||||||
|
remainingChild.setProgressRate(new BigDecimal("100.00"));
|
||||||
|
ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO();
|
||||||
|
reqVO.setActionCode("cancel");
|
||||||
|
reqVO.setReason("任务取消");
|
||||||
|
|
||||||
|
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
|
||||||
|
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("task", "pending", "cancel"))
|
||||||
|
.thenReturn(createTransition("cancel", "cancelled", true));
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "cancelled"))
|
||||||
|
.thenReturn(createTerminalStatus("task", "cancelled"));
|
||||||
|
when(projectTaskMapper.updateStatusByIdAndStatus(taskId, "pending", "cancelled", "任务取消")).thenReturn(1);
|
||||||
|
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
|
||||||
|
.thenReturn(List.of("cancelled"));
|
||||||
|
when(projectTaskMapper.selectById(parentTaskId)).thenReturn(parent);
|
||||||
|
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId, List.of("cancelled")))
|
||||||
|
.thenReturn(List.of(remainingChild));
|
||||||
|
|
||||||
|
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3002L)) {
|
||||||
|
projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO);
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(projectTaskMapper).selectChildrenProgressByParentTaskId(parentTaskId, List.of("cancelled"));
|
||||||
|
verify(projectTaskMapper).updateProgressRateById(eq(parentTaskId),
|
||||||
|
argThat(v -> new BigDecimal("100.00").compareTo(v) == 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changeTaskStatus_whenAllChildrenExcluded_shouldResetParentProgressToZero() {
|
||||||
|
Long projectId = 2001L;
|
||||||
|
Long executionId = 5001L;
|
||||||
|
Long parentTaskId = 8001L;
|
||||||
|
Long taskId = 9001L;
|
||||||
|
ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
|
||||||
|
task.setParentTaskId(parentTaskId);
|
||||||
|
ProjectTaskDO parent = createTask(projectId, executionId, parentTaskId, 3002L);
|
||||||
|
parent.setProgressRate(new BigDecimal("80.00"));
|
||||||
|
ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO();
|
||||||
|
reqVO.setActionCode("cancel");
|
||||||
|
reqVO.setReason("任务取消");
|
||||||
|
|
||||||
|
when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
|
||||||
|
when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("task", "pending", "cancel"))
|
||||||
|
.thenReturn(createTransition("cancel", "cancelled", true));
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "cancelled"))
|
||||||
|
.thenReturn(createTerminalStatus("task", "cancelled"));
|
||||||
|
when(projectTaskMapper.updateStatusByIdAndStatus(taskId, "pending", "cancelled", "任务取消")).thenReturn(1);
|
||||||
|
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
|
||||||
|
.thenReturn(List.of("cancelled"));
|
||||||
|
when(projectTaskMapper.selectById(parentTaskId)).thenReturn(parent);
|
||||||
|
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId, List.of("cancelled")))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3002L)) {
|
||||||
|
projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO);
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(projectTaskMapper).selectChildrenProgressByParentTaskId(parentTaskId, List.of("cancelled"));
|
||||||
|
verify(projectTaskMapper).updateProgressRateById(eq(parentTaskId),
|
||||||
|
argThat(v -> new BigDecimal("0.00").compareTo(v) == 0));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createTask_whenParentIsLeafWithProgress_shouldThrowLeafToParentForbiddenProgress() {
|
void createTask_whenParentIsLeafWithProgress_shouldThrowLeafToParentForbiddenProgress() {
|
||||||
Long projectId = 2001L;
|
Long projectId = 2001L;
|
||||||
@@ -418,7 +494,7 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
existingChild.setProgressRate(new BigDecimal("60.00"));
|
existingChild.setProgressRate(new BigDecimal("60.00"));
|
||||||
ProjectTaskDO newChild = createTask(projectId, executionId, 9002L, 3002L);
|
ProjectTaskDO newChild = createTask(projectId, executionId, 9002L, 3002L);
|
||||||
newChild.setProgressRate(BigDecimal.ZERO);
|
newChild.setProgressRate(BigDecimal.ZERO);
|
||||||
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId))
|
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId, Collections.emptyList()))
|
||||||
.thenReturn(List.of(existingChild, newChild));
|
.thenReturn(List.of(existingChild, newChild));
|
||||||
|
|
||||||
projectTaskService.createTask(projectId, executionId, reqVO);
|
projectTaskService.createTask(projectId, executionId, reqVO);
|
||||||
@@ -470,9 +546,9 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
when(projectTaskMapper.selectById(grandparentId)).thenReturn(grandparent);
|
when(projectTaskMapper.selectById(grandparentId)).thenReturn(grandparent);
|
||||||
ProjectTaskDO newChild = createTask(projectId, executionId, 9002L, 3002L);
|
ProjectTaskDO newChild = createTask(projectId, executionId, 9002L, 3002L);
|
||||||
newChild.setProgressRate(new BigDecimal("80.00"));
|
newChild.setProgressRate(new BigDecimal("80.00"));
|
||||||
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId))
|
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId, Collections.emptyList()))
|
||||||
.thenReturn(List.of(newChild));
|
.thenReturn(List.of(newChild));
|
||||||
when(projectTaskMapper.selectChildrenProgressByParentTaskId(grandparentId))
|
when(projectTaskMapper.selectChildrenProgressByParentTaskId(grandparentId, Collections.emptyList()))
|
||||||
.thenReturn(List.of(parentTask));
|
.thenReturn(List.of(parentTask));
|
||||||
|
|
||||||
projectTaskService.createTask(projectId, executionId, reqVO);
|
projectTaskService.createTask(projectId, executionId, reqVO);
|
||||||
@@ -526,16 +602,16 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
// 递归刷新两条链:旧父 8001 与 新父 8002
|
// 递归刷新两条链:旧父 8001 与 新父 8002
|
||||||
when(projectTaskMapper.selectById(oldParentId)).thenReturn(oldParent);
|
when(projectTaskMapper.selectById(oldParentId)).thenReturn(oldParent);
|
||||||
when(projectTaskMapper.selectById(newParentId)).thenReturn(newParent);
|
when(projectTaskMapper.selectById(newParentId)).thenReturn(newParent);
|
||||||
when(projectTaskMapper.selectChildrenProgressByParentTaskId(oldParentId))
|
when(projectTaskMapper.selectChildrenProgressByParentTaskId(oldParentId, Collections.emptyList()))
|
||||||
.thenReturn(List.of()); // 旧父在迁移后无子,保留原值
|
.thenReturn(List.of()); // 旧父在迁移后无有效子任务,按新口径归零;当前值已为 0 不重复更新
|
||||||
when(projectTaskMapper.selectChildrenProgressByParentTaskId(newParentId))
|
when(projectTaskMapper.selectChildrenProgressByParentTaskId(newParentId, Collections.emptyList()))
|
||||||
.thenReturn(List.of(task));
|
.thenReturn(List.of(task));
|
||||||
|
|
||||||
projectTaskService.updateTask(projectId, executionId, reqVO);
|
projectTaskService.updateTask(projectId, executionId, reqVO);
|
||||||
|
|
||||||
verify(projectTaskMapper).updateProgressRateById(eq(newParentId),
|
verify(projectTaskMapper).updateProgressRateById(eq(newParentId),
|
||||||
argThat(v -> new BigDecimal("70.00").compareTo(v) == 0));
|
argThat(v -> new BigDecimal("70.00").compareTo(v) == 0));
|
||||||
// 旧父无子,recalcParentProgressFrom 早退,不调 updateProgressRateById
|
// 旧父无有效子任务且当前已为 0,不重复更新
|
||||||
verify(projectTaskMapper, never()).updateProgressRateById(eq(oldParentId), any(BigDecimal.class));
|
verify(projectTaskMapper, never()).updateProgressRateById(eq(oldParentId), any(BigDecimal.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,6 +667,12 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ObjectStatusModelDO createTerminalStatus(String objectType, String statusCode) {
|
||||||
|
ObjectStatusModelDO status = createStatus(objectType, statusCode, false);
|
||||||
|
status.setTerminalFlag(true);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
private ObjectStatusTransitionDO createTransition(String actionCode, String toStatus, boolean needReason) {
|
private ObjectStatusTransitionDO createTransition(String actionCode, String toStatus, boolean needReason) {
|
||||||
ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
|
ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
|
||||||
transition.setActionCode(actionCode);
|
transition.setActionCode(actionCode);
|
||||||
@@ -600,4 +682,10 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
|||||||
return transition;
|
return transition;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private MockedStatic<SecurityFrameworkUtils> mockLoginUser(Long loginUserId) {
|
||||||
|
MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class);
|
||||||
|
mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
|
||||||
|
return mockedStatic;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.njcn.rdms.module.project.service.project.task;
|
|||||||
import com.njcn.rdms.framework.common.exception.ServiceException;
|
import com.njcn.rdms.framework.common.exception.ServiceException;
|
||||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||||
|
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskLifecycleActionRespVO;
|
||||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
|
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
|
||||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||||
@@ -13,6 +14,7 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.MockedStatic;
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
@@ -45,7 +47,7 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
|
|||||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
||||||
|
|
||||||
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
|
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
|
||||||
projectTaskStatusViewService.getLifecycle("active", ownerId);
|
projectTaskStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"));
|
||||||
|
|
||||||
assertEquals("进行中", view.statusName());
|
assertEquals("进行中", view.statusName());
|
||||||
assertFalse(view.terminal());
|
assertFalse(view.terminal());
|
||||||
@@ -71,12 +73,55 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
|
|||||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(otherUserId);
|
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(otherUserId);
|
||||||
|
|
||||||
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
|
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
|
||||||
projectTaskStatusViewService.getLifecycle("active", ownerId);
|
projectTaskStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"));
|
||||||
|
|
||||||
assertTrue(view.availableActions().isEmpty());
|
assertTrue(view.availableActions().isEmpty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getLifecycle_whenOwnerButProgressBelow100_shouldFilterCompleteOnly() {
|
||||||
|
Long ownerId = 3001L;
|
||||||
|
ObjectStatusModelDO statusModel = createStatusModel();
|
||||||
|
ObjectStatusTransitionDO complete = createTransition("complete", "完成");
|
||||||
|
ObjectStatusTransitionDO pause = createTransition("pause", "暂停");
|
||||||
|
ObjectStatusTransitionDO resume = createTransition("resume", "恢复");
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "active")).thenReturn(statusModel);
|
||||||
|
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("task", "active"))
|
||||||
|
.thenReturn(List.of(complete, pause, resume));
|
||||||
|
|
||||||
|
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||||
|
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
||||||
|
|
||||||
|
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
|
||||||
|
projectTaskStatusViewService.getLifecycle("active", ownerId, new BigDecimal("99.99"));
|
||||||
|
|
||||||
|
assertEquals(List.of("pause", "resume"), view.availableActions().stream()
|
||||||
|
.map(ProjectTaskLifecycleActionRespVO::getActionCode)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getLifecycle_whenOwnerAndProgress100_shouldReturnComplete() {
|
||||||
|
Long ownerId = 3001L;
|
||||||
|
ObjectStatusModelDO statusModel = createStatusModel();
|
||||||
|
ObjectStatusTransitionDO complete = createTransition("complete", "完成");
|
||||||
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "active")).thenReturn(statusModel);
|
||||||
|
when(objectStatusTransitionMapper.selectListByObjectTypeAndFromStatus("task", "active"))
|
||||||
|
.thenReturn(List.of(complete));
|
||||||
|
|
||||||
|
try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
|
||||||
|
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
||||||
|
|
||||||
|
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
|
||||||
|
projectTaskStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"));
|
||||||
|
|
||||||
|
assertEquals(1, view.availableActions().size());
|
||||||
|
assertEquals("complete", view.availableActions().get(0).getActionCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getLifecycle_shouldExcludeAutoStartAction() {
|
void getLifecycle_shouldExcludeAutoStartAction() {
|
||||||
Long ownerId = 3001L;
|
Long ownerId = 3001L;
|
||||||
@@ -95,7 +140,7 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
|
|||||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
||||||
|
|
||||||
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
|
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
|
||||||
projectTaskStatusViewService.getLifecycle("pending", ownerId);
|
projectTaskStatusViewService.getLifecycle("pending", ownerId, BigDecimal.ZERO);
|
||||||
|
|
||||||
assertTrue(view.availableActions().isEmpty());
|
assertTrue(view.availableActions().isEmpty());
|
||||||
}
|
}
|
||||||
@@ -106,7 +151,7 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
|
|||||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "missing")).thenReturn(null);
|
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "missing")).thenReturn(null);
|
||||||
|
|
||||||
ServiceException ex = assertThrows(ServiceException.class,
|
ServiceException ex = assertThrows(ServiceException.class,
|
||||||
() -> projectTaskStatusViewService.getLifecycle("missing", 3001L));
|
() -> projectTaskStatusViewService.getLifecycle("missing", 3001L, BigDecimal.ZERO));
|
||||||
|
|
||||||
assertEquals(ErrorCodeConstants.PROJECT_TASK_STATUS_MODEL_NOT_EXISTS_OR_DISABLED.getCode(), ex.getCode());
|
assertEquals(ErrorCodeConstants.PROJECT_TASK_STATUS_MODEL_NOT_EXISTS_OR_DISABLED.getCode(), ex.getCode());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.njcn.rdms.module.system.api.dept;
|
||||||
|
|
||||||
|
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||||
|
import com.njcn.rdms.module.system.enums.ApiConstants;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
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;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@FeignClient(name = ApiConstants.NAME)
|
||||||
|
@Tag(name = "RPC 服务 - 组织负责人")
|
||||||
|
public interface OrgLeaderApi {
|
||||||
|
|
||||||
|
String PREFIX = ApiConstants.PREFIX + "/org-leader";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反推:当前 user 作为 leader 能"看到"的下属 user_id 集合(含递归子节点)。
|
||||||
|
* 自己默认不在结果里(自己看自己走通道 1)。
|
||||||
|
* 无 leader 关系返回空集。
|
||||||
|
*/
|
||||||
|
@GetMapping(PREFIX + "/get-reachable-user-ids")
|
||||||
|
@Operation(summary = "反推 leader 可见的下属 user 集合")
|
||||||
|
CommonResult<Set<Long>> getReachableUserIds(@RequestParam("currentUserId") Long currentUserId);
|
||||||
|
}
|
||||||
@@ -70,6 +70,15 @@ public interface ObjectPermissionApi {
|
|||||||
@RequestParam("scopeType") String scopeType,
|
@RequestParam("scopeType") String scopeType,
|
||||||
@RequestParam("objectType") String objectType);
|
@RequestParam("objectType") String objectType);
|
||||||
|
|
||||||
|
@GetMapping(PREFIX + "/role-permission-detail-merged")
|
||||||
|
@Operation(summary = "按 roleId 列表聚合菜单 + 权限码(多角色场景);主角色按 system_role.sort 升序取首个,菜单/权限取并集")
|
||||||
|
@Parameter(name = "roleIds", description = "角色 ID 集合", example = "1,2", required = true)
|
||||||
|
@Parameter(name = "scopeType", description = "权限作用域类型", example = "object", required = true)
|
||||||
|
@Parameter(name = "objectType", description = "对象类型", example = "product", required = true)
|
||||||
|
CommonResult<ObjectRolePermissionRespDTO> getObjectRolePermissionDetailMerged(@RequestParam("roleIds") Collection<Long> roleIds,
|
||||||
|
@RequestParam("scopeType") String scopeType,
|
||||||
|
@RequestParam("objectType") String objectType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按角色 ID 返回对象作用域角色摘要映射,便于业务模块批量对齐本地成员数据。
|
* 按角色 ID 返回对象作用域角色摘要映射,便于业务模块批量对齐本地成员数据。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -24,4 +24,8 @@ public interface PermissionApi extends PermissionCommonApi {
|
|||||||
@Parameter(name = "roleIds", description = "角色编号集合", example = "1,2", required = true)
|
@Parameter(name = "roleIds", description = "角色编号集合", example = "1,2", required = true)
|
||||||
CommonResult<Set<Long>> getUserRoleIdListByRoleIds(@RequestParam("roleIds") Collection<Long> roleIds);
|
CommonResult<Set<Long>> getUserRoleIdListByRoleIds(@RequestParam("roleIds") Collection<Long> roleIds);
|
||||||
|
|
||||||
|
@GetMapping(PREFIX + "/is-super-admin")
|
||||||
|
@Operation(summary = "判断用户是否超管")
|
||||||
|
CommonResult<Boolean> isSuperAdmin(@RequestParam("userId") Long userId);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.njcn.rdms.module.system.api.permission;
|
||||||
|
|
||||||
|
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||||
|
import com.njcn.rdms.module.system.api.permission.dto.UserVisibilityConfigRespDTO;
|
||||||
|
import com.njcn.rdms.module.system.enums.ApiConstants;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
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 UserVisibilityConfigApi {
|
||||||
|
|
||||||
|
String PREFIX = ApiConstants.PREFIX + "/permission/user-visibility-config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拿用户的可见性配置(通道 3)。
|
||||||
|
* 无配置返回 null(不抛异常)。
|
||||||
|
*/
|
||||||
|
@GetMapping(PREFIX + "/get-config")
|
||||||
|
@Operation(summary = "拿用户的可见性配置;无配置返回 null")
|
||||||
|
CommonResult<UserVisibilityConfigRespDTO> getConfig(@RequestParam("userId") Long userId);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.ArraySchema;
|
|||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@@ -20,4 +21,11 @@ public class ObjectRolePermissionRespDTO {
|
|||||||
@ArraySchema(schema = @Schema(description = "基于同一批有效菜单资源归一化提取出的权限标识集合,供对象权限校验直接消费", example = "project:product:query"))
|
@ArraySchema(schema = @Schema(description = "基于同一批有效菜单资源归一化提取出的权限标识集合,供对象权限校验直接消费", example = "project:product:query"))
|
||||||
private Set<String> permissions;
|
private Set<String> permissions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 非主角色的中文名列表(多角色场景)。单角色或无角色时为空数组。
|
||||||
|
* 前端展示"创建者"等次要角色标签时读这个字段;权限判断仍按 currentRole.code。
|
||||||
|
*/
|
||||||
|
@ArraySchema(schema = @Schema(description = "非主角色的中文名列表,多角色场景使用", example = "创建者"))
|
||||||
|
private List<String> additionalRoleNames = Collections.emptyList();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.njcn.rdms.module.system.api.permission.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户可见性配置(通道 3)跨模块响应 DTO。
|
||||||
|
*
|
||||||
|
* directions 类型已由 API 端把 directionId → directionCode 转换好;业务侧不用再 join system_dept。
|
||||||
|
* projects 类型字段保留,当前业务不消费。
|
||||||
|
*/
|
||||||
|
@Schema(description = "RPC 服务 - 用户可见性配置 Response DTO")
|
||||||
|
@Data
|
||||||
|
public class UserVisibilityConfigRespDTO {
|
||||||
|
|
||||||
|
@Schema(description = "\"all\" / \"directions\" / \"projects\"", example = "all")
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
@ArraySchema(schema = @Schema(description = "type=directions 时为方向 code 集合(API 端已转换)", example = "direction_code_1"))
|
||||||
|
private Set<String> directionCodes;
|
||||||
|
|
||||||
|
@ArraySchema(schema = @Schema(description = "type=projects 时为项目 id 集合(保留位,当前业务不消费)", example = "1"))
|
||||||
|
private Set<Long> projectIds;
|
||||||
|
}
|
||||||
@@ -152,4 +152,8 @@ public interface ErrorCodeConstants {
|
|||||||
ErrorCode ORG_LEADER_EFFECTIVE_RANGE_INVALID = new ErrorCode(1_002_004_102, "负责人生效时间区间不合法");
|
ErrorCode ORG_LEADER_EFFECTIVE_RANGE_INVALID = new ErrorCode(1_002_004_102, "负责人生效时间区间不合法");
|
||||||
ErrorCode ORG_LEADER_RELATION_OVERLAP = new ErrorCode(1_002_004_103, "同一组织下该用户的负责人时间区间存在重叠");
|
ErrorCode ORG_LEADER_RELATION_OVERLAP = new ErrorCode(1_002_004_103, "同一组织下该用户的负责人时间区间存在重叠");
|
||||||
|
|
||||||
|
// ========== 用户可见性配置 1-002-003-200 ==========
|
||||||
|
ErrorCode USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH = new ErrorCode(1_002_003_200,
|
||||||
|
"可见性类型与字段不匹配:type={}, 详情:{}");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.njcn.rdms.module.system.enums.permission;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内置对象角色清单。
|
||||||
|
*
|
||||||
|
* 业务代码按 code 查 system_role 拿 role_id(创建者自动落地、关心人、隐式 observer 兜底等都用);
|
||||||
|
* 启动时按本枚举校验 system_role 必须存在;这些 code 同时已加入 {@link RoleCodeEnum#isBuiltIn(String)} 锁住改 code / 删除。
|
||||||
|
*
|
||||||
|
* 不含 product_manager / project_manager / visitor —— 已分别定义在
|
||||||
|
* ProductObjectConstants / ProjectObjectConstants,避免重复维护 code 字面量。
|
||||||
|
*
|
||||||
|
* code / name 直接引用 {@link RoleCodeEnum},两处只有一份字符串。
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum ObjectRoleConstants {
|
||||||
|
|
||||||
|
PRODUCT_CREATOR(RoleCodeEnum.PRODUCT_CREATOR, "product"),
|
||||||
|
PRODUCT_WATCHER(RoleCodeEnum.PRODUCT_WATCHER, "product"),
|
||||||
|
IMPLICIT_OBSERVER_PRODUCT(RoleCodeEnum.IMPLICIT_OBSERVER_PRODUCT, "product"),
|
||||||
|
|
||||||
|
PROJECT_CREATOR(RoleCodeEnum.PROJECT_CREATOR, "project"),
|
||||||
|
PROJECT_WATCHER(RoleCodeEnum.PROJECT_WATCHER, "project"),
|
||||||
|
IMPLICIT_OBSERVER_PROJECT(RoleCodeEnum.IMPLICIT_OBSERVER_PROJECT, "project");
|
||||||
|
|
||||||
|
private final RoleCodeEnum roleCode;
|
||||||
|
private final String objectType;
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return roleCode.getCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return roleCode.getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -9,7 +9,18 @@ import lombok.Getter;
|
|||||||
public enum RoleCodeEnum {
|
public enum RoleCodeEnum {
|
||||||
|
|
||||||
SUPER_ADMIN("super_admin", "超级管理员"),
|
SUPER_ADMIN("super_admin", "超级管理员"),
|
||||||
CRM_ADMIN("crm_admin", "CRM 管理员");
|
CRM_ADMIN("crm_admin", "CRM 管理员"),
|
||||||
|
|
||||||
|
// 对象域内置角色:被业务代码硬编码引用(按 code 查 system_role),改 code 或删除会让对应业务功能炸
|
||||||
|
PRODUCT_MANAGER("product_manager", "产品经理"),
|
||||||
|
PRODUCT_CREATOR("product_creator", "产品创建者"),
|
||||||
|
PRODUCT_WATCHER("product_watcher", "产品关心人"),
|
||||||
|
IMPLICIT_OBSERVER_PRODUCT("implicit_observer_product", "产品隐式观察者"),
|
||||||
|
PROJECT_MANAGER("project_manager", "项目经理"),
|
||||||
|
PROJECT_CREATOR("project_creator", "项目创建者"),
|
||||||
|
PROJECT_WATCHER("project_watcher", "项目关心人"),
|
||||||
|
IMPLICIT_OBSERVER_PROJECT("implicit_observer_project", "项目隐式观察者"),
|
||||||
|
VISITOR("visitor", "游客");
|
||||||
|
|
||||||
private final String code;
|
private final String code;
|
||||||
private final String name;
|
private final String name;
|
||||||
@@ -19,7 +30,13 @@ public enum RoleCodeEnum {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static boolean isBuiltIn(String code) {
|
public static boolean isBuiltIn(String code) {
|
||||||
return ObjectUtils.equalsAny(code, SUPER_ADMIN.getCode(), CRM_ADMIN.getCode());
|
return ObjectUtils.equalsAny(code,
|
||||||
|
SUPER_ADMIN.getCode(), CRM_ADMIN.getCode(),
|
||||||
|
PRODUCT_MANAGER.getCode(), PRODUCT_CREATOR.getCode(),
|
||||||
|
PRODUCT_WATCHER.getCode(), IMPLICIT_OBSERVER_PRODUCT.getCode(),
|
||||||
|
PROJECT_MANAGER.getCode(), PROJECT_CREATOR.getCode(),
|
||||||
|
PROJECT_WATCHER.getCode(), IMPLICIT_OBSERVER_PROJECT.getCode(),
|
||||||
|
VISITOR.getCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.njcn.rdms.module.system.api.dept;
|
||||||
|
|
||||||
|
import cn.hutool.core.collection.CollUtil;
|
||||||
|
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||||
|
import com.njcn.rdms.module.system.service.dept.DeptService;
|
||||||
|
import com.njcn.rdms.module.system.service.dept.OrgLeaderRelationService;
|
||||||
|
import com.njcn.rdms.module.system.service.user.AdminUserService;
|
||||||
|
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 java.util.Collections;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组织负责人 RPC 接口实现
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@Validated
|
||||||
|
@Hidden
|
||||||
|
public class OrgLeaderApiImpl implements OrgLeaderApi {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private OrgLeaderRelationService orgLeaderRelationService;
|
||||||
|
@Resource
|
||||||
|
private DeptService deptService;
|
||||||
|
@Resource
|
||||||
|
private AdminUserService adminUserService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CommonResult<Set<Long>> getReachableUserIds(Long currentUserId) {
|
||||||
|
// 1. 当前用户作为 leader 生效中的 dept_id 集合
|
||||||
|
Set<Long> leaderDeptIds = orgLeaderRelationService.listEffectiveDeptIdsByUserId(currentUserId);
|
||||||
|
if (CollUtil.isEmpty(leaderDeptIds)) {
|
||||||
|
return success(Collections.emptySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 含递归子孙节点的 dept_id 集合(按 path 前缀匹配,一次 SQL 完成)
|
||||||
|
Set<Long> allDeptIds = deptService.listDescendantDeptIds(leaderDeptIds);
|
||||||
|
|
||||||
|
// 3. 这些 dept 下启用且未离职的 user_id 集合
|
||||||
|
Set<Long> userIds = adminUserService.listEnabledUserIdsByDeptIds(allDeptIds);
|
||||||
|
|
||||||
|
// 4. 移除自己(自己看自己走通道 1,不在本结果集里)
|
||||||
|
userIds.remove(currentUserId);
|
||||||
|
return success(userIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -17,8 +17,11 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||||
@@ -77,6 +80,62 @@ public class ObjectPermissionApiImpl implements ObjectPermissionApi {
|
|||||||
return success(detail);
|
return success(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CommonResult<ObjectRolePermissionRespDTO> getObjectRolePermissionDetailMerged(
|
||||||
|
Collection<Long> roleIds, String scopeType, String objectType) {
|
||||||
|
if (roleIds == null || roleIds.isEmpty()) {
|
||||||
|
return success(emptyPermissionDetail());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拿全部启用的角色,过滤 null 和未启用
|
||||||
|
List<RoleDO> activeRoles = roleIds.stream()
|
||||||
|
.distinct()
|
||||||
|
.map(id -> getEnabledScopedRole(id, scopeType, objectType))
|
||||||
|
.filter(r -> r != null)
|
||||||
|
.toList();
|
||||||
|
if (activeRoles.isEmpty()) {
|
||||||
|
return success(emptyPermissionDetail());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主角色:按 sort 升序,决胜按 id 升序
|
||||||
|
Comparator<RoleDO> rolePriority = Comparator
|
||||||
|
.comparingInt((RoleDO r) -> r.getSort() == null ? Integer.MAX_VALUE : r.getSort())
|
||||||
|
.thenComparingLong(RoleDO::getId);
|
||||||
|
RoleDO primaryRole = activeRoles.stream().min(rolePriority).orElseThrow();
|
||||||
|
|
||||||
|
// 非主角色名(按 sort 升序保持稳定顺序)
|
||||||
|
List<String> additionalRoleNames = activeRoles.stream()
|
||||||
|
.filter(r -> !r.getId().equals(primaryRole.getId()))
|
||||||
|
.sorted(rolePriority)
|
||||||
|
.map(RoleDO::getName)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// 菜单 union(按 menu.id 去重,按 menu.sort 排序)
|
||||||
|
Map<Long, MenuDO> mergedMenus = new LinkedHashMap<>();
|
||||||
|
for (RoleDO role : activeRoles) {
|
||||||
|
for (MenuDO menu : permissionService.getScopedMenusByRoleId(role.getId(), scopeType, objectType)) {
|
||||||
|
mergedMenus.putIfAbsent(menu.getId(), menu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<ObjectMenuRespDTO> menus = mergedMenus.values().stream()
|
||||||
|
.sorted(Comparator.comparingInt(m -> m.getSort() == null ? Integer.MAX_VALUE : m.getSort()))
|
||||||
|
.map(this::convertMenu)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// 权限码 union(LinkedHashSet 保持稳定顺序)
|
||||||
|
Set<String> permissions = new LinkedHashSet<>();
|
||||||
|
for (RoleDO role : activeRoles) {
|
||||||
|
permissions.addAll(permissionService.getScopedPermissionsByRoleId(role.getId(), scopeType, objectType));
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
|
||||||
|
detail.setCurrentRole(convertRole(primaryRole));
|
||||||
|
detail.setAdditionalRoleNames(additionalRoleNames);
|
||||||
|
detail.setMenus(menus);
|
||||||
|
detail.setPermissions(permissions);
|
||||||
|
return success(detail);
|
||||||
|
}
|
||||||
|
|
||||||
private ObjectRolePermissionRespDTO emptyPermissionDetail() {
|
private ObjectRolePermissionRespDTO emptyPermissionDetail() {
|
||||||
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
|
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
|
||||||
detail.setCurrentRole(null);
|
detail.setCurrentRole(null);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.njcn.rdms.module.system.api.permission;
|
|||||||
|
|
||||||
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||||
import com.njcn.rdms.module.system.service.permission.PermissionService;
|
import com.njcn.rdms.module.system.service.permission.PermissionService;
|
||||||
|
import com.njcn.rdms.module.system.service.permission.RoleService;
|
||||||
import io.swagger.v3.oas.annotations.Hidden;
|
import io.swagger.v3.oas.annotations.Hidden;
|
||||||
import org.springframework.context.annotation.Primary;
|
import org.springframework.context.annotation.Primary;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
@@ -22,6 +23,9 @@ public class PermissionApiImpl implements PermissionApi {
|
|||||||
@Resource
|
@Resource
|
||||||
private PermissionService permissionService;
|
private PermissionService permissionService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private RoleService roleService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CommonResult<Set<Long>> getUserRoleIdListByRoleIds(Collection<Long> roleIds) {
|
public CommonResult<Set<Long>> getUserRoleIdListByRoleIds(Collection<Long> roleIds) {
|
||||||
return success(permissionService.getUserRoleIdListByRoleId(roleIds));
|
return success(permissionService.getUserRoleIdListByRoleId(roleIds));
|
||||||
@@ -37,5 +41,14 @@ public class PermissionApiImpl implements PermissionApi {
|
|||||||
return success(permissionService.hasAnyRoles(userId, roles));
|
return success(permissionService.hasAnyRoles(userId, roles));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CommonResult<Boolean> isSuperAdmin(Long userId) {
|
||||||
|
Set<Long> roleIds = permissionService.getUserRoleIdListByUserId(userId);
|
||||||
|
if (roleIds.isEmpty()) {
|
||||||
|
return success(false);
|
||||||
|
}
|
||||||
|
return success(roleService.hasAnySuperAdmin(roleIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.njcn.rdms.module.system.api.permission;
|
||||||
|
|
||||||
|
import cn.hutool.core.collection.CollUtil;
|
||||||
|
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||||
|
import com.njcn.rdms.module.system.api.permission.dto.UserVisibilityConfigRespDTO;
|
||||||
|
import com.njcn.rdms.module.system.dal.dataobject.permission.UserVisibilityConfigDO;
|
||||||
|
import com.njcn.rdms.module.system.service.dept.DeptService;
|
||||||
|
import com.njcn.rdms.module.system.service.permission.UserVisibilityConfigService;
|
||||||
|
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 java.util.HashSet;
|
||||||
|
|
||||||
|
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户可见性配置 RPC 接口实现。
|
||||||
|
*
|
||||||
|
* directionId → directionCode 的转换在 API 端完成,
|
||||||
|
* 业务侧(rdms-project)不需要再 join system_dept。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@Validated
|
||||||
|
@Hidden
|
||||||
|
public class UserVisibilityConfigApiImpl implements UserVisibilityConfigApi {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private UserVisibilityConfigService userVisibilityConfigService;
|
||||||
|
@Resource
|
||||||
|
private DeptService deptService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CommonResult<UserVisibilityConfigRespDTO> getConfig(Long userId) {
|
||||||
|
UserVisibilityConfigDO cfg = userVisibilityConfigService.getByUserId(userId);
|
||||||
|
if (cfg == null) {
|
||||||
|
return success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
UserVisibilityConfigRespDTO dto = new UserVisibilityConfigRespDTO();
|
||||||
|
dto.setType(cfg.getVisibilityType());
|
||||||
|
|
||||||
|
if ("directions".equals(cfg.getVisibilityType())
|
||||||
|
&& CollUtil.isNotEmpty(cfg.getVisibleDirectionIds())) {
|
||||||
|
// directionId → directionCode 转换:在 API 端做,避免 rdms-project 再 join system_dept
|
||||||
|
dto.setDirectionCodes(deptService.listCodesByIds(cfg.getVisibleDirectionIds()));
|
||||||
|
}
|
||||||
|
if ("projects".equals(cfg.getVisibilityType())
|
||||||
|
&& CollUtil.isNotEmpty(cfg.getVisibleProjectIds())) {
|
||||||
|
dto.setProjectIds(new HashSet<>(cfg.getVisibleProjectIds()));
|
||||||
|
}
|
||||||
|
return success(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.njcn.rdms.module.system.controller.admin.permission;
|
||||||
|
|
||||||
|
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||||
|
import com.njcn.rdms.framework.common.util.object.BeanUtils;
|
||||||
|
import com.njcn.rdms.module.system.controller.admin.permission.vo.userVisibilityConfig.UserVisibilityConfigRespVO;
|
||||||
|
import com.njcn.rdms.module.system.controller.admin.permission.vo.userVisibilityConfig.UserVisibilityConfigSaveReqVO;
|
||||||
|
import com.njcn.rdms.module.system.dal.dataobject.permission.UserVisibilityConfigDO;
|
||||||
|
import com.njcn.rdms.module.system.service.permission.UserVisibilityConfigService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||||
|
|
||||||
|
@Tag(name = "管理后台 - 用户数据可见性配置")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/system/user-visibility-config")
|
||||||
|
@Validated
|
||||||
|
public class UserVisibilityConfigController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private UserVisibilityConfigService userVisibilityConfigService;
|
||||||
|
|
||||||
|
@GetMapping("/get")
|
||||||
|
@Operation(summary = "查询用户可见性配置")
|
||||||
|
@Parameter(name = "userId", description = "用户ID", required = true, example = "1")
|
||||||
|
@PreAuthorize("@ss.hasPermission('system:user-visibility-config:query')")
|
||||||
|
public CommonResult<UserVisibilityConfigRespVO> getUserVisibilityConfig(@RequestParam("userId") Long userId) {
|
||||||
|
UserVisibilityConfigDO config = userVisibilityConfigService.getByUserId(userId);
|
||||||
|
return success(BeanUtils.toBean(config, UserVisibilityConfigRespVO.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/save")
|
||||||
|
@Operation(summary = "保存用户可见性配置(存在则更新,不存在则新增)")
|
||||||
|
@PreAuthorize("@ss.hasPermission('system:user-visibility-config:save')")
|
||||||
|
public CommonResult<Long> saveUserVisibilityConfig(@Valid @RequestBody UserVisibilityConfigSaveReqVO saveReqVO) {
|
||||||
|
return success(userVisibilityConfigService.saveOrUpdate(saveReqVO));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/delete")
|
||||||
|
@Operation(summary = "删除用户可见性配置")
|
||||||
|
@Parameter(name = "userId", description = "用户ID", required = true, example = "1")
|
||||||
|
@PreAuthorize("@ss.hasPermission('system:user-visibility-config:delete')")
|
||||||
|
public CommonResult<Boolean> deleteUserVisibilityConfig(@RequestParam("userId") Long userId) {
|
||||||
|
userVisibilityConfigService.deleteByUserId(userId);
|
||||||
|
return success(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.njcn.rdms.module.system.controller.admin.permission.vo.userVisibilityConfig;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Schema(description = "管理后台 - 用户可见性配置 Response VO")
|
||||||
|
@Data
|
||||||
|
public class UserVisibilityConfigRespVO {
|
||||||
|
|
||||||
|
@Schema(description = "配置ID", example = "1")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Schema(description = "用户ID", example = "100")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Schema(description = "可见范围类型:all / directions / projects", example = "directions")
|
||||||
|
private String visibilityType;
|
||||||
|
|
||||||
|
@Schema(description = "补充可见方向ID集合", example = "[107, 103]")
|
||||||
|
private List<Long> visibleDirectionIds;
|
||||||
|
|
||||||
|
@Schema(description = "补充可见项目ID集合", example = "[1001, 1002]")
|
||||||
|
private List<Long> visibleProjectIds;
|
||||||
|
|
||||||
|
@Schema(description = "备注")
|
||||||
|
private String remark;
|
||||||
|
|
||||||
|
@Schema(description = "创建时间")
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
@Schema(description = "更新时间")
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.njcn.rdms.module.system.controller.admin.permission.vo.userVisibilityConfig;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Schema(description = "管理后台 - 用户可见性配置 Save Request VO")
|
||||||
|
@Data
|
||||||
|
public class UserVisibilityConfigSaveReqVO {
|
||||||
|
|
||||||
|
@Schema(description = "用户ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
@NotNull(message = "用户ID不能为空")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Schema(description = "可见范围类型:all / directions / projects",
|
||||||
|
requiredMode = Schema.RequiredMode.REQUIRED, example = "directions")
|
||||||
|
@NotBlank(message = "可见范围类型不能为空")
|
||||||
|
private String visibilityType;
|
||||||
|
|
||||||
|
@Schema(description = "补充可见方向ID集合(type=directions 时必填)", example = "[107, 103]")
|
||||||
|
private List<Long> visibleDirectionIds;
|
||||||
|
|
||||||
|
@Schema(description = "补充可见项目ID集合(type=projects 时必填,业务暂不引导)", example = "[1001, 1002]")
|
||||||
|
private List<Long> visibleProjectIds;
|
||||||
|
|
||||||
|
@Schema(description = "备注")
|
||||||
|
private String remark;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.njcn.rdms.module.system.dal.dataobject.permission;
|
||||||
|
|
||||||
|
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 lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户数据可见性配置 DO
|
||||||
|
*
|
||||||
|
* 每个用户至多一条记录(user_id 有唯一索引),记录该用户在数据可见性维度的配置。
|
||||||
|
*/
|
||||||
|
@TableName(value = "system_user_visibility_config", autoResultMap = true)
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class UserVisibilityConfigDO extends BaseDO {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键 ID
|
||||||
|
*/
|
||||||
|
@TableId
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户 ID
|
||||||
|
*
|
||||||
|
* 唯一约束,每个用户至多一条可见性配置。
|
||||||
|
*/
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可见性类型
|
||||||
|
*
|
||||||
|
* 取值:all(全部可见)/ directions(按方向)/ projects(按项目)
|
||||||
|
*/
|
||||||
|
private String visibilityType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可见的方向 ID 列表(JSON 存储)
|
||||||
|
*
|
||||||
|
* visibilityType = "directions" 时有效,存储用户有权查看的方向 ID 集合。
|
||||||
|
* autoResultMap = true 已在 @TableName 上声明,typeHandler 才能正常反序列化。
|
||||||
|
*/
|
||||||
|
@TableField(typeHandler = JacksonTypeHandler.class)
|
||||||
|
private List<Long> visibleDirectionIds;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可见的项目 ID 列表(JSON 存储)
|
||||||
|
*
|
||||||
|
* visibilityType = "projects" 时有效,存储用户有权查看的项目 ID 集合。
|
||||||
|
* 业务暂未消费,预留字段。
|
||||||
|
*/
|
||||||
|
@TableField(typeHandler = JacksonTypeHandler.class)
|
||||||
|
private List<Long> visibleProjectIds;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 备注
|
||||||
|
*/
|
||||||
|
private String remark;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
|||||||
import com.njcn.rdms.module.system.dal.dataobject.dept.OrgLeaderRelationDO;
|
import com.njcn.rdms.module.system.dal.dataobject.dept.OrgLeaderRelationDO;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
@@ -23,4 +24,22 @@ public interface OrgLeaderRelationMapper extends BaseMapperX<OrgLeaderRelationDO
|
|||||||
.orderByDesc(OrgLeaderRelationDO::getId));
|
.orderByDesc(OrgLeaderRelationDO::getId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定用户在指定时间点生效的负责人记录列表
|
||||||
|
*
|
||||||
|
* @param userId 用户 ID
|
||||||
|
* @param now 当前时间(生效期判断基准)
|
||||||
|
* @return 生效中的负责人关系列表
|
||||||
|
*/
|
||||||
|
default List<OrgLeaderRelationDO> selectEffectiveListByUserId(Long userId, LocalDateTime now) {
|
||||||
|
return selectList(new LambdaQueryWrapperX<OrgLeaderRelationDO>()
|
||||||
|
.eq(OrgLeaderRelationDO::getUserId, userId)
|
||||||
|
// effectiveFrom 为空或 <= now
|
||||||
|
.and(w -> w.isNull(OrgLeaderRelationDO::getEffectiveFrom)
|
||||||
|
.or().le(OrgLeaderRelationDO::getEffectiveFrom, now))
|
||||||
|
// effectiveUntil 为空或 >= now
|
||||||
|
.and(w -> w.isNull(OrgLeaderRelationDO::getEffectiveUntil)
|
||||||
|
.or().ge(OrgLeaderRelationDO::getEffectiveUntil, now)));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.njcn.rdms.module.system.dal.mysql.permission;
|
||||||
|
|
||||||
|
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
|
||||||
|
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||||
|
import com.njcn.rdms.module.system.dal.dataobject.permission.UserVisibilityConfigDO;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户数据可见性配置 Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface UserVisibilityConfigMapper extends BaseMapperX<UserVisibilityConfigDO> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 user_id 查单条配置(唯一索引保证一人一条)。
|
||||||
|
*/
|
||||||
|
default UserVisibilityConfigDO selectByUserId(Long userId) {
|
||||||
|
return selectOne(new LambdaQueryWrapperX<UserVisibilityConfigDO>()
|
||||||
|
.eq(UserVisibilityConfigDO::getUserId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.njcn.rdms.module.system.framework.permission;
|
||||||
|
|
||||||
|
import com.njcn.rdms.framework.common.enums.CommonStatusEnum;
|
||||||
|
import com.njcn.rdms.module.system.dal.dataobject.permission.RoleDO;
|
||||||
|
import com.njcn.rdms.module.system.enums.permission.ObjectRoleConstants;
|
||||||
|
import com.njcn.rdms.module.system.enums.permission.PermissionScopeTypeEnum;
|
||||||
|
import com.njcn.rdms.module.system.service.permission.RoleService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动时校验 {@link ObjectRoleConstants} 列出的内置对象角色,要求 system_role 表里全部存在、启用、object_type 匹配。
|
||||||
|
* 缺一抛 IllegalStateException 让进程退出 —— 避免运行期按 code 查不到才暴雷。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ObjectRoleStartupValidator implements ApplicationRunner {
|
||||||
|
|
||||||
|
private final RoleService roleService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
List<String> errors = new ArrayList<>();
|
||||||
|
for (ObjectRoleConstants required : ObjectRoleConstants.values()) {
|
||||||
|
RoleDO role = roleService.getRoleByCode(
|
||||||
|
required.getCode(),
|
||||||
|
PermissionScopeTypeEnum.OBJECT.getScopeType(),
|
||||||
|
required.getObjectType());
|
||||||
|
if (role == null) {
|
||||||
|
errors.add(String.format("缺失 [%s/%s] (object_type=%s)",
|
||||||
|
required.getCode(), required.getName(), required.getObjectType()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!CommonStatusEnum.ENABLE.getStatus().equals(role.getStatus())) {
|
||||||
|
errors.add(String.format("已停用 [%s/%s]", required.getCode(), required.getName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
String detail = String.join("\n - ", errors);
|
||||||
|
log.error("[ObjectRoleStartupValidator] 内置对象角色校验失败:\n - {}", detail);
|
||||||
|
throw new IllegalStateException("内置对象角色校验失败,请检查 system_role 后重启:\n - " + detail);
|
||||||
|
}
|
||||||
|
log.info("[ObjectRoleStartupValidator] 内置对象角色 {} 条全部就位",
|
||||||
|
ObjectRoleConstants.values().length);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -108,6 +108,15 @@ public interface DeptService {
|
|||||||
*/
|
*/
|
||||||
Set<Long> getChildDeptIdListFromCache(Long id);
|
Set<Long> getChildDeptIdListFromCache(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获得指定部门集合及其所有子孙部门的 ID 集合。
|
||||||
|
* 基于 system_dept.path 字段前缀匹配,一次 SQL 查询完成,避免递归。
|
||||||
|
*
|
||||||
|
* @param rootDeptIds 根部门 ID 集合
|
||||||
|
* @return 含根节点本身及所有子孙节点的 ID 集合
|
||||||
|
*/
|
||||||
|
Set<Long> listDescendantDeptIds(Collection<Long> rootDeptIds);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验部门们是否有效
|
* 校验部门们是否有效
|
||||||
*
|
*
|
||||||
@@ -115,4 +124,15 @@ public interface DeptService {
|
|||||||
*/
|
*/
|
||||||
void validateDeptList(Collection<Long> ids);
|
void validateDeptList(Collection<Long> ids);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 id 集合批量查询部门 code 集合。
|
||||||
|
*
|
||||||
|
* code 为空(null / 空字符串)的记录会被过滤掉。
|
||||||
|
* 用于 API 端将 directionId → directionCode 转换,避免业务侧再 join system_dept。
|
||||||
|
*
|
||||||
|
* @param ids 部门 id 集合
|
||||||
|
* @return 非空 code 的集合
|
||||||
|
*/
|
||||||
|
Set<String> listCodesByIds(Collection<Long> ids);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ import jakarta.annotation.Resource;
|
|||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
|
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||||
import static com.njcn.rdms.framework.common.util.collection.CollectionUtils.convertSet;
|
import static com.njcn.rdms.framework.common.util.collection.CollectionUtils.convertSet;
|
||||||
@@ -251,6 +253,36 @@ public class DeptServiceImpl implements DeptService {
|
|||||||
return convertSet(children, DeptDO::getId);
|
return convertSet(children, DeptDO::getId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<Long> listDescendantDeptIds(Collection<Long> rootDeptIds) {
|
||||||
|
if (CollUtil.isEmpty(rootDeptIds)) {
|
||||||
|
return Collections.emptySet();
|
||||||
|
}
|
||||||
|
Set<Long> result = new HashSet<>(rootDeptIds);
|
||||||
|
// 逐个根节点按 path 前缀匹配子孙节点,避免递归查询
|
||||||
|
for (Long rootId : rootDeptIds) {
|
||||||
|
DeptDO root = deptMapper.selectById(rootId);
|
||||||
|
if (root == null || StrUtil.isBlank(root.getPath())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<DeptDO> descendants = deptMapper.selectListByPathPrefix(root.getPath());
|
||||||
|
descendants.forEach(d -> result.add(d.getId()));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<String> listCodesByIds(Collection<Long> ids) {
|
||||||
|
if (CollUtil.isEmpty(ids)) {
|
||||||
|
return Collections.emptySet();
|
||||||
|
}
|
||||||
|
// 复用 getDeptList 已有的空判断与批量查询,过滤 code 为空的记录
|
||||||
|
return getDeptList(ids).stream()
|
||||||
|
.filter(d -> StrUtil.isNotBlank(d.getCode()))
|
||||||
|
.map(DeptDO::getCode)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void validateDeptList(Collection<Long> ids) {
|
public void validateDeptList(Collection<Long> ids) {
|
||||||
if (CollUtil.isEmpty(ids)) {
|
if (CollUtil.isEmpty(ids)) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.njcn.rdms.module.system.dal.dataobject.dept.OrgLeaderRelationDO;
|
|||||||
import com.njcn.rdms.module.system.dal.dataobject.user.AdminUserDO;
|
import com.njcn.rdms.module.system.dal.dataobject.user.AdminUserDO;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 组织负责人关系 Service
|
* 组织负责人关系 Service
|
||||||
@@ -49,4 +50,12 @@ public interface OrgLeaderRelationService {
|
|||||||
*/
|
*/
|
||||||
List<AdminUserDO> getCandidateUsersByDeptId(Long deptId);
|
List<AdminUserDO> getCandidateUsersByDeptId(Long deptId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定用户当前生效的负责人关系所对应的 dept_id 集合
|
||||||
|
*
|
||||||
|
* @param userId 用户 ID
|
||||||
|
* @return 当前生效的组织 ID 集合,无关系时返回空集
|
||||||
|
*/
|
||||||
|
Set<Long> listEffectiveDeptIdsByUserId(Long userId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import java.util.List;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static com.njcn.rdms.framework.common.util.collection.CollectionUtils.convertSet;
|
||||||
|
|
||||||
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
|
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||||
import static com.njcn.rdms.module.system.enums.ErrorCodeConstants.*;
|
import static com.njcn.rdms.module.system.enums.ErrorCodeConstants.*;
|
||||||
import static java.util.Collections.singleton;
|
import static java.util.Collections.singleton;
|
||||||
@@ -68,6 +70,12 @@ public class OrgLeaderRelationServiceImpl implements OrgLeaderRelationService {
|
|||||||
return orgLeaderRelationMapper.selectListByDeptId(deptId);
|
return orgLeaderRelationMapper.selectListByDeptId(deptId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<Long> listEffectiveDeptIdsByUserId(Long userId) {
|
||||||
|
List<OrgLeaderRelationDO> relations = orgLeaderRelationMapper.selectEffectiveListByUserId(userId, LocalDateTime.now());
|
||||||
|
return convertSet(relations, OrgLeaderRelationDO::getDeptId);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AdminUserDO> getCandidateUsersByDeptId(Long deptId) {
|
public List<AdminUserDO> getCandidateUsersByDeptId(Long deptId) {
|
||||||
validateDeptExists(deptId);
|
validateDeptExists(deptId);
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.njcn.rdms.module.system.service.permission;
|
||||||
|
|
||||||
|
import com.njcn.rdms.module.system.controller.admin.permission.vo.userVisibilityConfig.UserVisibilityConfigSaveReqVO;
|
||||||
|
import com.njcn.rdms.module.system.dal.dataobject.permission.UserVisibilityConfigDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户数据可见性配置 Service 接口
|
||||||
|
*/
|
||||||
|
public interface UserVisibilityConfigService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 userId 查询可见性配置(可为 null,表示该用户未配置)。
|
||||||
|
*/
|
||||||
|
UserVisibilityConfigDO getByUserId(Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存或更新用户可见性配置。
|
||||||
|
*
|
||||||
|
* 一人一条配置(user_id 唯一索引):已有记录则 update,否则 insert。
|
||||||
|
* 保存前校验 visibilityType 与字段的一致性:
|
||||||
|
* - all → visibleDirectionIds / visibleProjectIds 均须为 null
|
||||||
|
* - directions → visibleDirectionIds 非空;visibleProjectIds 须为 null
|
||||||
|
* - projects → visibleProjectIds 非空;visibleDirectionIds 须为 null
|
||||||
|
*
|
||||||
|
* @return 配置记录 id
|
||||||
|
*/
|
||||||
|
Long saveOrUpdate(UserVisibilityConfigSaveReqVO reqVO);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 userId 删除配置(唯一索引,无需按 id 删)。
|
||||||
|
*/
|
||||||
|
void deleteByUserId(Long userId);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.njcn.rdms.module.system.service.permission;
|
||||||
|
|
||||||
|
import cn.hutool.core.collection.CollUtil;
|
||||||
|
import com.njcn.rdms.framework.common.util.object.BeanUtils;
|
||||||
|
import com.njcn.rdms.module.system.controller.admin.permission.vo.userVisibilityConfig.UserVisibilityConfigSaveReqVO;
|
||||||
|
import com.njcn.rdms.module.system.dal.dataobject.permission.UserVisibilityConfigDO;
|
||||||
|
import com.njcn.rdms.module.system.dal.mysql.permission.UserVisibilityConfigMapper;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||||
|
import static com.njcn.rdms.module.system.enums.ErrorCodeConstants.USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户数据可见性配置 Service 实现
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class UserVisibilityConfigServiceImpl implements UserVisibilityConfigService {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private UserVisibilityConfigMapper userVisibilityConfigMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserVisibilityConfigDO getByUserId(Long userId) {
|
||||||
|
return userVisibilityConfigMapper.selectByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public Long saveOrUpdate(UserVisibilityConfigSaveReqVO reqVO) {
|
||||||
|
// 校验 visibilityType 与字段的一致性
|
||||||
|
validateTypeFieldConsistency(reqVO);
|
||||||
|
|
||||||
|
UserVisibilityConfigDO existing = userVisibilityConfigMapper.selectByUserId(reqVO.getUserId());
|
||||||
|
if (existing == null) {
|
||||||
|
// 不存在 → insert
|
||||||
|
UserVisibilityConfigDO configDO = BeanUtils.toBean(reqVO, UserVisibilityConfigDO.class);
|
||||||
|
userVisibilityConfigMapper.insert(configDO);
|
||||||
|
return configDO.getId();
|
||||||
|
} else {
|
||||||
|
// 已存在 → update(覆盖全部字段)
|
||||||
|
UserVisibilityConfigDO configDO = BeanUtils.toBean(reqVO, UserVisibilityConfigDO.class);
|
||||||
|
configDO.setId(existing.getId());
|
||||||
|
userVisibilityConfigMapper.updateById(configDO);
|
||||||
|
return existing.getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteByUserId(Long userId) {
|
||||||
|
UserVisibilityConfigDO existing = userVisibilityConfigMapper.selectByUserId(userId);
|
||||||
|
if (existing != null) {
|
||||||
|
userVisibilityConfigMapper.deleteById(existing.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 visibilityType 与关联字段的一致性:
|
||||||
|
* - all:directionIds / projectIds 均须为 null
|
||||||
|
* - directions:directionIds 非空;projectIds 须为 null
|
||||||
|
* - projects:projectIds 非空;directionIds 须为 null
|
||||||
|
*/
|
||||||
|
private void validateTypeFieldConsistency(UserVisibilityConfigSaveReqVO reqVO) {
|
||||||
|
String type = reqVO.getVisibilityType();
|
||||||
|
boolean hasDirectionIds = CollUtil.isNotEmpty(reqVO.getVisibleDirectionIds());
|
||||||
|
boolean hasProjectIds = CollUtil.isNotEmpty(reqVO.getVisibleProjectIds());
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "all" -> {
|
||||||
|
if (hasDirectionIds || hasProjectIds) {
|
||||||
|
throw exception(USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH, type,
|
||||||
|
"type=all 时,visibleDirectionIds 和 visibleProjectIds 均须为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "directions" -> {
|
||||||
|
if (!hasDirectionIds) {
|
||||||
|
throw exception(USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH, type,
|
||||||
|
"type=directions 时,visibleDirectionIds 不能为空");
|
||||||
|
}
|
||||||
|
if (hasProjectIds) {
|
||||||
|
throw exception(USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH, type,
|
||||||
|
"type=directions 时,visibleProjectIds 须为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "projects" -> {
|
||||||
|
if (!hasProjectIds) {
|
||||||
|
throw exception(USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH, type,
|
||||||
|
"type=projects 时,visibleProjectIds 不能为空");
|
||||||
|
}
|
||||||
|
if (hasDirectionIds) {
|
||||||
|
throw exception(USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH, type,
|
||||||
|
"type=projects 时,visibleDirectionIds 须为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> throw exception(USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH, type,
|
||||||
|
"不支持的 visibilityType,合法值:all / directions / projects");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import java.util.Collection;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台用户 Service 接口
|
* 后台用户 Service 接口
|
||||||
@@ -243,4 +244,12 @@ public interface AdminUserService {
|
|||||||
* @return 用户列表
|
* @return 用户列表
|
||||||
*/
|
*/
|
||||||
List<AdminUserDO> getAllUserByDeptId(Long deptId);
|
List<AdminUserDO> getAllUserByDeptId(Long deptId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获得指定部门集合下启用且未离职的用户 ID 集合
|
||||||
|
*
|
||||||
|
* @param deptIds 部门 ID 集合
|
||||||
|
* @return 可用用户 ID 集合
|
||||||
|
*/
|
||||||
|
Set<Long> listEnabledUserIdsByDeptIds(Collection<Long> deptIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -496,6 +496,15 @@ public class AdminUserServiceImpl implements AdminUserService {
|
|||||||
&& !isUserResigned(user);
|
&& !isUserResigned(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<Long> listEnabledUserIdsByDeptIds(Collection<Long> deptIds) {
|
||||||
|
List<AdminUserDO> users = getUserListByDeptIds(deptIds);
|
||||||
|
return users.stream()
|
||||||
|
.filter(this::isUserAvailable)
|
||||||
|
.map(AdminUserDO::getId)
|
||||||
|
.collect(java.util.stream.Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AdminUserDO> getAllUserByDeptId(Long deptId) {
|
public List<AdminUserDO> getAllUserByDeptId(Long deptId) {
|
||||||
Set<Long> deptCondition = getDeptCondition(deptId);
|
Set<Long> deptCondition = getDeptCondition(deptId);
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.njcn.rdms.module.system.api.dept;
|
||||||
|
|
||||||
|
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||||
|
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||||
|
import com.njcn.rdms.module.system.service.dept.DeptService;
|
||||||
|
import com.njcn.rdms.module.system.service.dept.OrgLeaderRelationService;
|
||||||
|
import com.njcn.rdms.module.system.service.user.AdminUserService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
|
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;
|
||||||
|
|
||||||
|
class OrgLeaderApiImplTest extends BaseMockitoUnitTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private OrgLeaderRelationService orgLeaderRelationService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private DeptService deptService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AdminUserService adminUserService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private OrgLeaderApiImpl orgLeaderApi;
|
||||||
|
|
||||||
|
/** 用户没有任何生效的负责人关系,直接返回空集 */
|
||||||
|
@Test
|
||||||
|
void getReachableUserIds_returnsEmpty_whenUserIsNotLeader() {
|
||||||
|
Long currentUserId = 1L;
|
||||||
|
when(orgLeaderRelationService.listEffectiveDeptIdsByUserId(currentUserId))
|
||||||
|
.thenReturn(Collections.emptySet());
|
||||||
|
|
||||||
|
CommonResult<Set<Long>> result = orgLeaderApi.getReachableUserIds(currentUserId);
|
||||||
|
|
||||||
|
assertTrue(result.getCheckedData().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 叶子节点 leader:只有直属部门用户,无子孙部门 */
|
||||||
|
@Test
|
||||||
|
void getReachableUserIds_returnsDirectSubordinates_whenUserIsLeafLeader() {
|
||||||
|
Long currentUserId = 10L;
|
||||||
|
Long deptId = 100L;
|
||||||
|
Long subordinateId = 20L;
|
||||||
|
|
||||||
|
when(orgLeaderRelationService.listEffectiveDeptIdsByUserId(currentUserId))
|
||||||
|
.thenReturn(Set.of(deptId));
|
||||||
|
// 叶子节点:listDescendantDeptIds 仅返回自身
|
||||||
|
when(deptService.listDescendantDeptIds(Set.of(deptId)))
|
||||||
|
.thenReturn(Set.of(deptId));
|
||||||
|
when(adminUserService.listEnabledUserIdsByDeptIds(Set.of(deptId)))
|
||||||
|
.thenReturn(new HashSet<>(Set.of(subordinateId)));
|
||||||
|
|
||||||
|
CommonResult<Set<Long>> result = orgLeaderApi.getReachableUserIds(currentUserId);
|
||||||
|
|
||||||
|
assertEquals(Set.of(subordinateId), result.getCheckedData());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 父节点 leader:结果包含所有子孙部门的可用用户 */
|
||||||
|
@Test
|
||||||
|
void getReachableUserIds_returnsRecursiveSubordinates_whenUserIsParentLeader() {
|
||||||
|
Long currentUserId = 10L;
|
||||||
|
Long rootDeptId = 100L;
|
||||||
|
Long childDeptId = 200L;
|
||||||
|
Long user1 = 21L;
|
||||||
|
Long user2 = 22L;
|
||||||
|
|
||||||
|
when(orgLeaderRelationService.listEffectiveDeptIdsByUserId(currentUserId))
|
||||||
|
.thenReturn(Set.of(rootDeptId));
|
||||||
|
// 父节点:listDescendantDeptIds 返回自身 + 子孙
|
||||||
|
when(deptService.listDescendantDeptIds(Set.of(rootDeptId)))
|
||||||
|
.thenReturn(Set.of(rootDeptId, childDeptId));
|
||||||
|
when(adminUserService.listEnabledUserIdsByDeptIds(Set.of(rootDeptId, childDeptId)))
|
||||||
|
.thenReturn(new HashSet<>(Set.of(user1, user2)));
|
||||||
|
|
||||||
|
CommonResult<Set<Long>> result = orgLeaderApi.getReachableUserIds(currentUserId);
|
||||||
|
|
||||||
|
assertEquals(Set.of(user1, user2), result.getCheckedData());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自己(currentUserId)即使在部门用户列表中,也不应出现在结果集里 */
|
||||||
|
@Test
|
||||||
|
void getReachableUserIds_excludesSelf() {
|
||||||
|
Long currentUserId = 10L;
|
||||||
|
Long deptId = 100L;
|
||||||
|
Long otherId = 20L;
|
||||||
|
|
||||||
|
when(orgLeaderRelationService.listEffectiveDeptIdsByUserId(currentUserId))
|
||||||
|
.thenReturn(Set.of(deptId));
|
||||||
|
when(deptService.listDescendantDeptIds(Set.of(deptId)))
|
||||||
|
.thenReturn(Set.of(deptId));
|
||||||
|
// 返回集合包含 leader 自己
|
||||||
|
when(adminUserService.listEnabledUserIdsByDeptIds(Set.of(deptId)))
|
||||||
|
.thenReturn(new HashSet<>(Set.of(currentUserId, otherId)));
|
||||||
|
|
||||||
|
CommonResult<Set<Long>> result = orgLeaderApi.getReachableUserIds(currentUserId);
|
||||||
|
|
||||||
|
assertFalse(result.getCheckedData().contains(currentUserId), "结果集不能包含 currentUserId 自身");
|
||||||
|
assertTrue(result.getCheckedData().contains(otherId));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.njcn.rdms.module.system.api.permission;
|
||||||
|
|
||||||
|
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||||
|
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||||
|
import com.njcn.rdms.module.system.dal.dataobject.permission.RoleDO;
|
||||||
|
import com.njcn.rdms.module.system.enums.permission.RoleCodeEnum;
|
||||||
|
import com.njcn.rdms.module.system.service.permission.PermissionService;
|
||||||
|
import com.njcn.rdms.module.system.service.permission.RoleService;
|
||||||
|
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.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class PermissionApiImplTest extends BaseMockitoUnitTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PermissionService permissionService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private RoleService roleService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private PermissionApiImpl permissionApi;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isSuperAdmin_returnsTrue_whenUserHasSuperAdminRole() {
|
||||||
|
Long userId = 1L;
|
||||||
|
Set<Long> roleIds = Set.of(100L);
|
||||||
|
when(permissionService.getUserRoleIdListByUserId(userId)).thenReturn(roleIds);
|
||||||
|
when(roleService.hasAnySuperAdmin(roleIds)).thenReturn(true);
|
||||||
|
|
||||||
|
CommonResult<Boolean> result = permissionApi.isSuperAdmin(userId);
|
||||||
|
|
||||||
|
assertTrue(result.getCheckedData());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isSuperAdmin_returnsFalse_otherwise() {
|
||||||
|
Long userId = 2L;
|
||||||
|
Set<Long> roleIds = Set.of(200L);
|
||||||
|
when(permissionService.getUserRoleIdListByUserId(userId)).thenReturn(roleIds);
|
||||||
|
when(roleService.hasAnySuperAdmin(roleIds)).thenReturn(false);
|
||||||
|
|
||||||
|
CommonResult<Boolean> result = permissionApi.isSuperAdmin(userId);
|
||||||
|
|
||||||
|
assertFalse(result.getCheckedData());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.njcn.rdms.module.system.api.permission;
|
||||||
|
|
||||||
|
import com.njcn.rdms.framework.common.pojo.CommonResult;
|
||||||
|
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||||
|
import com.njcn.rdms.module.system.api.permission.dto.UserVisibilityConfigRespDTO;
|
||||||
|
import com.njcn.rdms.module.system.dal.dataobject.permission.UserVisibilityConfigDO;
|
||||||
|
import com.njcn.rdms.module.system.service.dept.DeptService;
|
||||||
|
import com.njcn.rdms.module.system.service.permission.UserVisibilityConfigService;
|
||||||
|
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.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class UserVisibilityConfigApiImplTest extends BaseMockitoUnitTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserVisibilityConfigService userVisibilityConfigService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private DeptService deptService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private UserVisibilityConfigApiImpl userVisibilityConfigApi;
|
||||||
|
|
||||||
|
/** 用户无配置时返回 null data,不抛异常 */
|
||||||
|
@Test
|
||||||
|
void getConfig_returnsNull_whenNoConfig() {
|
||||||
|
when(userVisibilityConfigService.getByUserId(1L)).thenReturn(null);
|
||||||
|
|
||||||
|
CommonResult<UserVisibilityConfigRespDTO> result = userVisibilityConfigApi.getConfig(1L);
|
||||||
|
|
||||||
|
assertNull(result.getCheckedData());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** type=all 时,directionCodes / projectIds 均不填充 */
|
||||||
|
@Test
|
||||||
|
void getConfig_returnsAllType_withoutDirectionsOrProjects() {
|
||||||
|
UserVisibilityConfigDO cfg = buildDO(1L, "all", null, null);
|
||||||
|
when(userVisibilityConfigService.getByUserId(1L)).thenReturn(cfg);
|
||||||
|
|
||||||
|
CommonResult<UserVisibilityConfigRespDTO> result = userVisibilityConfigApi.getConfig(1L);
|
||||||
|
|
||||||
|
UserVisibilityConfigRespDTO dto = result.getCheckedData();
|
||||||
|
assertEquals("all", dto.getType());
|
||||||
|
assertNull(dto.getDirectionCodes());
|
||||||
|
assertNull(dto.getProjectIds());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** type=directions 时,directionIds 经 deptService.listCodesByIds 转换为 code 集合 */
|
||||||
|
@Test
|
||||||
|
void getConfig_returnsDirectionsTypeWithCodes() {
|
||||||
|
List<Long> directionIds = List.of(101L, 102L);
|
||||||
|
UserVisibilityConfigDO cfg = buildDO(2L, "directions", directionIds, null);
|
||||||
|
when(userVisibilityConfigService.getByUserId(2L)).thenReturn(cfg);
|
||||||
|
when(deptService.listCodesByIds(directionIds)).thenReturn(Set.of("dir_code_A", "dir_code_B"));
|
||||||
|
|
||||||
|
CommonResult<UserVisibilityConfigRespDTO> result = userVisibilityConfigApi.getConfig(2L);
|
||||||
|
|
||||||
|
UserVisibilityConfigRespDTO dto = result.getCheckedData();
|
||||||
|
assertEquals("directions", dto.getType());
|
||||||
|
assertEquals(Set.of("dir_code_A", "dir_code_B"), dto.getDirectionCodes());
|
||||||
|
assertNull(dto.getProjectIds());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** type=projects 时,projectIds 直接透传到 DTO,不调用 deptService */
|
||||||
|
@Test
|
||||||
|
void getConfig_returnsProjectsType() {
|
||||||
|
List<Long> projectIds = List.of(201L, 202L);
|
||||||
|
UserVisibilityConfigDO cfg = buildDO(3L, "projects", null, projectIds);
|
||||||
|
when(userVisibilityConfigService.getByUserId(3L)).thenReturn(cfg);
|
||||||
|
|
||||||
|
CommonResult<UserVisibilityConfigRespDTO> result = userVisibilityConfigApi.getConfig(3L);
|
||||||
|
|
||||||
|
UserVisibilityConfigRespDTO dto = result.getCheckedData();
|
||||||
|
assertEquals("projects", dto.getType());
|
||||||
|
assertNull(dto.getDirectionCodes());
|
||||||
|
assertTrue(dto.getProjectIds().containsAll(Set.of(201L, 202L)));
|
||||||
|
assertEquals(2, dto.getProjectIds().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 辅助方法 =====
|
||||||
|
|
||||||
|
private static UserVisibilityConfigDO buildDO(Long userId, String type,
|
||||||
|
List<Long> directionIds,
|
||||||
|
List<Long> projectIds) {
|
||||||
|
UserVisibilityConfigDO do_ = new UserVisibilityConfigDO();
|
||||||
|
do_.setUserId(userId);
|
||||||
|
do_.setVisibilityType(type);
|
||||||
|
do_.setVisibleDirectionIds(directionIds);
|
||||||
|
do_.setVisibleProjectIds(projectIds);
|
||||||
|
return do_;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.njcn.rdms.module.system.service.permission;
|
||||||
|
|
||||||
|
import com.njcn.rdms.framework.common.exception.ServiceException;
|
||||||
|
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||||
|
import com.njcn.rdms.module.system.controller.admin.permission.vo.userVisibilityConfig.UserVisibilityConfigSaveReqVO;
|
||||||
|
import com.njcn.rdms.module.system.dal.dataobject.permission.UserVisibilityConfigDO;
|
||||||
|
import com.njcn.rdms.module.system.dal.mysql.permission.UserVisibilityConfigMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static com.njcn.rdms.module.system.enums.ErrorCodeConstants.USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class UserVisibilityConfigServiceImplTest extends BaseMockitoUnitTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserVisibilityConfigMapper userVisibilityConfigMapper;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private UserVisibilityConfigServiceImpl userVisibilityConfigService;
|
||||||
|
|
||||||
|
/** 用户无配置时执行 insert,返回新记录 id */
|
||||||
|
@Test
|
||||||
|
void saveOrUpdate_insertNew_whenUserHasNoConfig() {
|
||||||
|
UserVisibilityConfigSaveReqVO reqVO = buildReqVO(1L, "all", null, null);
|
||||||
|
when(userVisibilityConfigMapper.selectByUserId(1L)).thenReturn(null);
|
||||||
|
|
||||||
|
// insert 由 BaseMapper 填充 id,这里验证确实调了 insert(而非 updateById)
|
||||||
|
userVisibilityConfigService.saveOrUpdate(reqVO);
|
||||||
|
|
||||||
|
verify(userVisibilityConfigMapper).insert(any(UserVisibilityConfigDO.class));
|
||||||
|
verify(userVisibilityConfigMapper, never()).updateById(any(UserVisibilityConfigDO.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户已有配置时执行 update,返回已有记录 id */
|
||||||
|
@Test
|
||||||
|
void saveOrUpdate_updateExisting_whenUserAlreadyHasConfig() {
|
||||||
|
Long existingId = 999L;
|
||||||
|
UserVisibilityConfigDO existing = buildDO(existingId, 2L, "all");
|
||||||
|
UserVisibilityConfigSaveReqVO reqVO = buildReqVO(2L, "directions", List.of(101L, 102L), null);
|
||||||
|
when(userVisibilityConfigMapper.selectByUserId(2L)).thenReturn(existing);
|
||||||
|
|
||||||
|
Long returnedId = userVisibilityConfigService.saveOrUpdate(reqVO);
|
||||||
|
|
||||||
|
assertEquals(existingId, returnedId);
|
||||||
|
verify(userVisibilityConfigMapper).updateById(any(UserVisibilityConfigDO.class));
|
||||||
|
verify(userVisibilityConfigMapper, never()).insert(any(UserVisibilityConfigDO.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** type=all 但传了 visibleDirectionIds,应抛类型字段不匹配异常 */
|
||||||
|
@Test
|
||||||
|
void saveOrUpdate_typeAllWithDirectionIds_shouldThrowMismatch() {
|
||||||
|
UserVisibilityConfigSaveReqVO reqVO = buildReqVO(3L, "all", List.of(101L), null);
|
||||||
|
|
||||||
|
ServiceException ex = assertThrows(ServiceException.class,
|
||||||
|
() -> userVisibilityConfigService.saveOrUpdate(reqVO));
|
||||||
|
|
||||||
|
assertEquals(USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH.getCode(), ex.getCode());
|
||||||
|
verify(userVisibilityConfigMapper, never()).selectByUserId(any(Long.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** type=directions 但未传 visibleDirectionIds,应抛类型字段不匹配异常 */
|
||||||
|
@Test
|
||||||
|
void saveOrUpdate_typeDirectionsWithoutDirectionIds_shouldThrowMismatch() {
|
||||||
|
UserVisibilityConfigSaveReqVO reqVO = buildReqVO(4L, "directions", null, null);
|
||||||
|
|
||||||
|
ServiceException ex = assertThrows(ServiceException.class,
|
||||||
|
() -> userVisibilityConfigService.saveOrUpdate(reqVO));
|
||||||
|
|
||||||
|
assertEquals(USER_VISIBILITY_CONFIG_TYPE_FIELD_MISMATCH.getCode(), ex.getCode());
|
||||||
|
verify(userVisibilityConfigMapper, never()).selectByUserId(any(Long.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 辅助方法 =====
|
||||||
|
|
||||||
|
private static UserVisibilityConfigSaveReqVO buildReqVO(Long userId, String type,
|
||||||
|
List<Long> directionIds,
|
||||||
|
List<Long> projectIds) {
|
||||||
|
UserVisibilityConfigSaveReqVO reqVO = new UserVisibilityConfigSaveReqVO();
|
||||||
|
reqVO.setUserId(userId);
|
||||||
|
reqVO.setVisibilityType(type);
|
||||||
|
reqVO.setVisibleDirectionIds(directionIds);
|
||||||
|
reqVO.setVisibleProjectIds(projectIds);
|
||||||
|
return reqVO;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserVisibilityConfigDO buildDO(Long id, Long userId, String type) {
|
||||||
|
UserVisibilityConfigDO config = new UserVisibilityConfigDO();
|
||||||
|
config.setId(id);
|
||||||
|
config.setUserId(userId);
|
||||||
|
config.setVisibilityType(type);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user