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 '-Dsurefire.failIfNoSpecifiedTests=false' | Select-String -Pattern 'Tests run|BUILD|ERROR|FAILED|FAIL' | Select-Object -Last 100)",
|
||||
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot -am test-compile '-Dsurefire.failIfNoSpecifiedTests=false' | Select-String -Pattern 'ERROR|BUILD|FAIL' | Select-Object -Last 40)",
|
||||
"PowerShell($env:JAVA_HOME = 'C:\\\\Program Files\\\\Java\\\\jdk-17'; & 'C:\\\\software\\\\apache-maven-3.8.9\\\\bin\\\\mvn.cmd' -pl rdms-project/rdms-project-boot test '-Dsurefire.failIfNoSpecifiedTests=false' | Select-String -Pattern 'Tests run|BUILD|FAILED|ERROR' | Select-Object -Last 80)"
|
||||
"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` 契约。
|
||||
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` 契约同步更新。
|
||||
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_MEMBER_DUPLICATE = new ErrorCode(1_008_001_025, "初始团队成员存在重复");
|
||||
ErrorCode PRODUCT_INITIAL_TEAM_ROLE_INVALID = new ErrorCode(1_008_001_026, "初始团队中存在非法角色");
|
||||
ErrorCode PRODUCT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE = new ErrorCode(1_008_001_027, "原产品经理在该产品已持有目标角色【{}】(含历史失效行),不能直接转交,请先清理后重试");
|
||||
ErrorCode PRODUCT_INTERNAL_ROLE_NOT_CONFIGURED = new ErrorCode(1_008_001_028, "内置产品角色【{}】未在 system_role 找到,请联系管理员");
|
||||
|
||||
// ========== 产品需求 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_ROLE_INVALID = new ErrorCode(1_008_002_031, "初始团队中存在非法角色");
|
||||
ErrorCode PROJECT_DIRECTION_NOT_MATCH_PRODUCT = new ErrorCode(1_008_002_032, "项目方向与所属产品方向不一致");
|
||||
ErrorCode PROJECT_MANAGER_TRANSFER_TARGET_ROLE_DUPLICATE = new ErrorCode(1_008_002_033, "原项目经理在该项目已持有目标角色【{}】(含历史失效行),不能直接转交,请先清理后重试");
|
||||
ErrorCode PROJECT_INTERNAL_ROLE_NOT_CONFIGURED = new ErrorCode(1_008_002_034, "内置项目角色【{}】未在 system_role 找到,请联系管理员");
|
||||
|
||||
// ========== 执行管理 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_UPDATE = "update_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_UPDATE = "update_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);
|
||||
|
||||
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);
|
||||
|
||||
@@ -145,6 +150,7 @@ public final class ObjectActivityConstants {
|
||||
case MEMBER_ACTION_ADD -> "新增成员";
|
||||
case MEMBER_ACTION_UPDATE -> "调整成员";
|
||||
case MEMBER_ACTION_REMOVE -> "移出成员";
|
||||
case MEMBER_ACTION_REACTIVATE -> "重新激活成员";
|
||||
default -> normalizedActionType;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ public final class ProjectTaskConstants {
|
||||
*/
|
||||
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;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "管理后台 - 产品团队成员 Response VO")
|
||||
@Data
|
||||
@@ -42,4 +45,7 @@ public class ProductMemberRespVO {
|
||||
@Schema(description = "备注", example = "当前负责需求收敛")
|
||||
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;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "管理后台 - 产品上下文中的当前角色 Response VO")
|
||||
@Data
|
||||
public class ProductContextRoleRespVO {
|
||||
@@ -10,10 +14,16 @@ public class ProductContextRoleRespVO {
|
||||
@Schema(description = "对象角色编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3201")
|
||||
private Long roleId;
|
||||
|
||||
@Schema(description = "对象角色编码", example = "product_manager")
|
||||
@Schema(description = "对象角色编码(主角色 code,权限判断兼容字段)", example = "product_manager")
|
||||
private String roleCode;
|
||||
|
||||
@Schema(description = "对象角色名称", example = "产品经理")
|
||||
@Schema(description = "对象角色名称(主角色 name)", example = "产品经理")
|
||||
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
|
||||
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.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.ProjectTaskPageReqVO;
|
||||
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));
|
||||
}
|
||||
|
||||
@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")
|
||||
@Operation(summary = "变更任务状态")
|
||||
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;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "管理后台 - 项目上下文中的当前角色 Response VO")
|
||||
@Data
|
||||
public class ProjectContextRoleRespVO {
|
||||
|
||||
@Schema(description = "对象角色编号", example = "3201")
|
||||
private Long roleId;
|
||||
@Schema(description = "对象角色编码", example = "project_manager")
|
||||
@Schema(description = "对象角色编码(主角色 code,权限判断兼容字段)", example = "project_manager")
|
||||
private String roleCode;
|
||||
@Schema(description = "对象角色名称", example = "项目经理")
|
||||
@Schema(description = "对象角色名称(主角色 name)", example = "项目经理")
|
||||
private String roleName;
|
||||
@Schema(description = "是否游客上下文", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
|
||||
private Boolean guestFlag;
|
||||
@ArraySchema(schema = @Schema(description = "非主角色的中文名列表,多角色场景使用;单角色时为空数组", example = "创建者"))
|
||||
private List<String> additionalRoleNames = Collections.emptyList();
|
||||
|
||||
}
|
||||
|
||||
@@ -40,4 +40,14 @@ public class ProjectCreateWithTeamReqVO {
|
||||
@Valid
|
||||
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 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 org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@@ -42,6 +43,19 @@ public interface UserObjectRoleMapper extends BaseMapperX<UserObjectRoleDO> {
|
||||
.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) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
@@ -59,4 +73,42 @@ public interface UserObjectRoleMapper extends BaseMapperX<UserObjectRoleDO> {
|
||||
.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;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
|
||||
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -16,21 +12,6 @@ import java.util.Map;
|
||||
@Mapper
|
||||
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) {
|
||||
return selectOne(ProductDO::getCode, code);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
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.mapper.BaseMapperX;
|
||||
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 org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
@@ -17,23 +14,6 @@ import java.util.Map;
|
||||
@Mapper
|
||||
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) {
|
||||
return selectOne(ProjectDO::getProjectCode, projectCode);
|
||||
}
|
||||
|
||||
@@ -125,28 +125,40 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
|
||||
* 取直接子任务的 progressRate 列表(用于父任务进度简单平均汇总)。
|
||||
* 只读取必要字段,避免拉回整行;逻辑删除的子任务由 BaseMapper 自动过滤。
|
||||
*/
|
||||
default List<ProjectTaskDO> selectChildrenProgressByParentTaskId(Long parentTaskId) {
|
||||
default List<ProjectTaskDO> selectChildrenProgressByParentTaskId(Long parentTaskId,
|
||||
Collection<String> excludedStatusCodes) {
|
||||
if (parentTaskId == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return selectList(new LambdaQueryWrapperX<ProjectTaskDO>()
|
||||
.select(ProjectTaskDO::getId, ProjectTaskDO::getProgressRate)
|
||||
.eq(ProjectTaskDO::getParentTaskId, parentTaskId));
|
||||
LambdaQueryWrapperX<ProjectTaskDO> queryWrapper = new LambdaQueryWrapperX<>();
|
||||
queryWrapper.select(ProjectTaskDO::getId, ProjectTaskDO::getProgressRate);
|
||||
queryWrapper.eq(ProjectTaskDO::getParentTaskId, parentTaskId);
|
||||
if (excludedStatusCodes != null && !excludedStatusCodes.isEmpty()) {
|
||||
queryWrapper.notIn(ProjectTaskDO::getStatusCode, excludedStatusCodes);
|
||||
}
|
||||
return selectList(queryWrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行详情进度:按当前执行下一级任务 progressRate 简单平均;无一级任务时 SQL 返回 null。
|
||||
*/
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT AVG(COALESCE(progress_rate, 0))
|
||||
FROM rdms_task
|
||||
WHERE deleted = b'0'
|
||||
AND project_id = #{projectId}
|
||||
AND execution_id = #{executionId}
|
||||
AND parent_task_id IS NULL
|
||||
<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,
|
||||
@Param("executionId") Long executionId);
|
||||
@Param("executionId") Long executionId,
|
||||
@Param("excludedStatusCodes") Collection<String> excludedStatusCodes);
|
||||
|
||||
/**
|
||||
* 执行分页进度:按当前页 executionId 批量聚合一级任务 progressRate,避免列表 N+1。
|
||||
@@ -160,12 +172,70 @@ public interface ProjectTaskMapper extends BaseMapperX<ProjectTaskDO> {
|
||||
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>> selectRootTaskAvgProgressGroupByExecutionIds(
|
||||
@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 等)。
|
||||
|
||||
@@ -57,4 +57,17 @@ public interface ObjectStatusModelMapper extends BaseMapperX<ObjectStatusModelDO
|
||||
.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;
|
||||
|
||||
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.file.FileApi;
|
||||
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 org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -11,6 +14,6 @@ import org.springframework.context.annotation.Configuration;
|
||||
* Project 模块的 RPC 配置
|
||||
*/
|
||||
@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 {
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -41,9 +42,10 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
|
||||
throw invalidParamException("对象编号不能为空");
|
||||
}
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
UserObjectRoleDO currentMember = userObjectRoleMapper
|
||||
.selectActiveByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, objectId, loginUserId);
|
||||
if (currentMember == null) {
|
||||
// 多角色支持:拿 user 在对象内全部 ACTIVE 角色行
|
||||
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
|
||||
.selectActiveListByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, objectId, loginUserId);
|
||||
if (userRoles.isEmpty()) {
|
||||
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED,
|
||||
buildDeniedPermission(permission, memberOnly));
|
||||
}
|
||||
@@ -51,7 +53,12 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -41,9 +42,10 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
|
||||
throw invalidParamException("对象编号不能为空");
|
||||
}
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
UserObjectRoleDO currentMember = userObjectRoleMapper
|
||||
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, objectId, loginUserId);
|
||||
if (currentMember == null) {
|
||||
// 多角色支持:拿 user 在对象内全部 ACTIVE 角色行
|
||||
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
|
||||
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, objectId, loginUserId);
|
||||
if (userRoles.isEmpty()) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED,
|
||||
buildDeniedPermission(permission, memberOnly));
|
||||
}
|
||||
@@ -51,7 +53,12 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -65,7 +67,61 @@ public class ProductMemberServiceImpl implements ProductMemberService {
|
||||
List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(ProductObjectConstants.OBJECT_TYPE, productId);
|
||||
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()));
|
||||
return members.stream().map(member -> {
|
||||
|
||||
// 拆分 ACTIVE / INACTIVE:
|
||||
// - ACTIVE 行按 userId 聚合,同人多角色合并成一行(manager 优先做主),非主角色名放 additionalRoleNames
|
||||
// - INACTIVE 行保持独立(历史角色行各自留痕,便于审计)
|
||||
List<UserObjectRoleDO> activeRows = new ArrayList<>();
|
||||
List<UserObjectRoleDO> inactiveRows = new ArrayList<>();
|
||||
for (UserObjectRoleDO m : members) {
|
||||
if (Objects.equals(m.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
|
||||
activeRows.add(m);
|
||||
} else {
|
||||
inactiveRows.add(m);
|
||||
}
|
||||
}
|
||||
Map<Long, List<UserObjectRoleDO>> activeByUser = activeRows.stream()
|
||||
.collect(Collectors.groupingBy(UserObjectRoleDO::getUserId, LinkedHashMap::new, Collectors.toList()));
|
||||
|
||||
List<ProductMemberRespVO> result = new ArrayList<>();
|
||||
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());
|
||||
@@ -81,8 +137,8 @@ public class ProductMemberServiceImpl implements ProductMemberService {
|
||||
respVO.setJoinedTime(member.getJoinedTime());
|
||||
respVO.setLeftTime(member.getLeftTime());
|
||||
respVO.setRemark(member.getRemark());
|
||||
respVO.setAdditionalRoleNames(additionalRoleNames);
|
||||
return respVO;
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -92,8 +148,10 @@ public class ProductMemberServiceImpl implements ProductMemberService {
|
||||
public Long createProductMember(Long productId, ProductMemberSaveReqVO reqVO) {
|
||||
ProductDO product = validateProductEditable(productId);
|
||||
ObjectRoleRespDTO targetRole = validateProductRole(reqVO.getRoleId());
|
||||
// 多角色支持:按 (user, object, role) 三元组判存在;不带 status 过滤以便 INACTIVE 行复活(避免唯一索引 INSERT 冲突)
|
||||
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)) {
|
||||
throw exception(ErrorCodeConstants.PRODUCT_MEMBER_ALREADY_EXISTS);
|
||||
}
|
||||
@@ -101,6 +159,8 @@ public class ProductMemberServiceImpl implements ProductMemberService {
|
||||
UserObjectRoleDO member;
|
||||
UserObjectRoleDO before = null;
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
// 物理 INSERT vs INACTIVE 复活的 audit 语义区分:null=新增;INACTIVE 复活=REACTIVATE
|
||||
String actionType;
|
||||
if (existingMember == null) {
|
||||
member = new UserObjectRoleDO();
|
||||
member.setUserId(reqVO.getUserId());
|
||||
@@ -112,6 +172,7 @@ public class ProductMemberServiceImpl implements ProductMemberService {
|
||||
member.setLeftTime(null);
|
||||
member.setRemark(normalizeNullableText(reqVO.getRemark()));
|
||||
userObjectRoleMapper.insert(member);
|
||||
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
|
||||
} else {
|
||||
before = cloneMember(existingMember);
|
||||
member = existingMember;
|
||||
@@ -121,9 +182,10 @@ public class ProductMemberServiceImpl implements ProductMemberService {
|
||||
member.setLeftTime(null);
|
||||
member.setRemark(normalizeNullableText(reqVO.getRemark()));
|
||||
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)) {
|
||||
transferManager(product, member, reqVO.getPreviousManagerUserId(), reqVO.getPreviousManagerRoleId(), null);
|
||||
}
|
||||
@@ -145,6 +207,16 @@ public class ProductMemberServiceImpl implements ProductMemberService {
|
||||
UserObjectRoleDO before = cloneMember(member);
|
||||
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)) {
|
||||
member.setRoleId(targetRole.getId());
|
||||
userObjectRoleMapper.updateById(member);
|
||||
@@ -268,13 +340,26 @@ public class ProductMemberServiceImpl implements ProductMemberService {
|
||||
}
|
||||
|
||||
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
|
||||
.selectByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, productId, previousManagerUserId);
|
||||
.selectActiveByObjectUserAndRole(ProductObjectConstants.OBJECT_TYPE, productId,
|
||||
previousManagerUserId, productManagerRoleId);
|
||||
UserObjectRoleDO before = existingMember == null ? null : cloneMember(existingMember);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
UserObjectRoleDO member;
|
||||
String actionType;
|
||||
if (existingMember == null) {
|
||||
// user 当前没有 manager 角色 ACTIVE 行 —— 兼容老逻辑:插入 previousManagerRoleId 行
|
||||
member = new UserObjectRoleDO();
|
||||
member.setUserId(previousManagerUserId);
|
||||
member.setObjectType(ProductObjectConstants.OBJECT_TYPE);
|
||||
@@ -287,21 +372,30 @@ public class ProductMemberServiceImpl implements ProductMemberService {
|
||||
userObjectRoleMapper.insert(member);
|
||||
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
|
||||
} else {
|
||||
// existingMember 是 manager 行 ACTIVE,update 改 role_id 成 previousManagerRoleId("降级"该行)
|
||||
member = existingMember;
|
||||
member.setRoleId(previousManagerRoleId);
|
||||
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
|
||||
member.setLeftTime(null);
|
||||
if (!Objects.equals(before.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
|
||||
member.setJoinedTime(now);
|
||||
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
|
||||
} else {
|
||||
actionType = ObjectActivityConstants.MEMBER_ACTION_UPDATE;
|
||||
}
|
||||
userObjectRoleMapper.updateById(member);
|
||||
}
|
||||
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) {
|
||||
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.util.json.JsonUtils;
|
||||
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.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.ObjectRoleConstants;
|
||||
import com.njcn.rdms.module.project.constant.ProductObjectConstants;
|
||||
@@ -81,6 +85,10 @@ public class ProductServiceImpl implements ProductService {
|
||||
private AdminUserApi adminUserApi;
|
||||
@Resource
|
||||
private ProductRequirementModuleMapper requirementModuleMapper;
|
||||
@Resource
|
||||
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
|
||||
@Resource
|
||||
private ObjectDataScopeService objectDataScopeService;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -99,6 +107,10 @@ public class ProductServiceImpl implements ProductService {
|
||||
productMapper.insert(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);
|
||||
writeBizAuditLog(product, ObjectActivityConstants.PRODUCT_ACTION_CREATE, null, initialStatus,
|
||||
buildProductFieldChanges(null, product), null);
|
||||
@@ -150,7 +162,17 @@ public class ProductServiceImpl implements ProductService {
|
||||
// 5) 产品维度的"指定经理"审计:与旧 initManagerMemberRelation 末尾一致,保留活动时间线一致性
|
||||
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,
|
||||
buildProductFieldChanges(null, product), null);
|
||||
return product.getId();
|
||||
@@ -241,43 +263,173 @@ public class ProductServiceImpl implements ProductService {
|
||||
ProductDO product = validateProductExists(id);
|
||||
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
UserObjectRoleDO currentMember = userObjectRoleMapper
|
||||
.selectActiveByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, id, loginUserId);
|
||||
ProductContextRespVO respVO = new ProductContextRespVO();
|
||||
respVO.setCurrentProduct(buildCurrentProduct(product));
|
||||
if (currentMember == null) {
|
||||
respVO.setNavs(Collections.emptyList());
|
||||
respVO.setButtons(Collections.emptyList());
|
||||
return respVO;
|
||||
// 多角色支持:拿全部 ACTIVE 角色 ID 传聚合 API;主角色由 API 端按 sort 升序挑选 + 附 additionalRoleNames
|
||||
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
|
||||
.selectActiveListByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, id, loginUserId);
|
||||
if (!userRoles.isEmpty()) {
|
||||
List<Long> roleIds = userRoles.stream().map(UserObjectRoleDO::getRoleId).distinct().toList();
|
||||
return buildProductContext(product, roleIds, false, null);
|
||||
}
|
||||
|
||||
ObjectRolePermissionRespDTO permissionDetail = objectPermissionApi
|
||||
.getObjectRolePermissionDetail(currentMember.getRoleId(), ObjectRoleConstants.ROLE_SCOPE_OBJECT,
|
||||
// 显式角色为空:走 scope.contains 判定隐式 observer 兜底(设计文档 2.1 节末段)
|
||||
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProductObjectConstants.OBJECT_TYPE);
|
||||
if (!scope.contains(id, product.getDirectionCode())) {
|
||||
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED, "查看");
|
||||
}
|
||||
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)
|
||||
.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
|
||||
? Collections.emptyList()
|
||||
: 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.setButtons(buildContextButtons(menus));
|
||||
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
|
||||
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
|
||||
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();
|
||||
respVO.setStatusCounts(buildProductStatusCounts(objectStatusModelMapper
|
||||
.selectListByObjectType(ProductObjectConstants.OBJECT_TYPE), productMapper.selectStatusCountList()));
|
||||
.selectListByObjectType(ProductObjectConstants.OBJECT_TYPE), rows));
|
||||
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
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@CheckObjectPermission(objectType = ProductObjectConstants.OBJECT_TYPE, objectId = "#reqVO.id",
|
||||
@@ -520,13 +672,16 @@ public class ProductServiceImpl implements ProductService {
|
||||
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();
|
||||
roleRespVO.setRoleId(currentMember.getRoleId());
|
||||
roleRespVO.setRoleId(roleId);
|
||||
roleRespVO.setGuestFlag(guestFlag);
|
||||
if (currentRole != null) {
|
||||
roleRespVO.setRoleCode(currentRole.getCode());
|
||||
roleRespVO.setRoleName(currentRole.getName());
|
||||
}
|
||||
roleRespVO.setAdditionalRoleNames(additionalRoleNames == null ? Collections.emptyList() : additionalRoleNames);
|
||||
return roleRespVO;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,9 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -69,7 +71,61 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
|
||||
List<UserObjectRoleDO> members = userObjectRoleMapper.selectListByObject(ProjectObjectConstants.OBJECT_TYPE, projectId);
|
||||
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()));
|
||||
return members.stream().map(member -> {
|
||||
|
||||
// 拆分 ACTIVE / INACTIVE:
|
||||
// - ACTIVE 行按 userId 聚合,同人多角色合并成一行(manager 优先做主),非主角色名放 additionalRoleNames
|
||||
// - INACTIVE 行保持独立(历史角色行各自留痕,便于审计)
|
||||
List<UserObjectRoleDO> activeRows = new ArrayList<>();
|
||||
List<UserObjectRoleDO> inactiveRows = new ArrayList<>();
|
||||
for (UserObjectRoleDO m : members) {
|
||||
if (Objects.equals(m.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
|
||||
activeRows.add(m);
|
||||
} else {
|
||||
inactiveRows.add(m);
|
||||
}
|
||||
}
|
||||
Map<Long, List<UserObjectRoleDO>> activeByUser = activeRows.stream()
|
||||
.collect(Collectors.groupingBy(UserObjectRoleDO::getUserId, LinkedHashMap::new, Collectors.toList()));
|
||||
|
||||
List<ProjectMemberRespVO> result = new ArrayList<>();
|
||||
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());
|
||||
@@ -85,8 +141,8 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
|
||||
respVO.setJoinedTime(member.getJoinedTime());
|
||||
respVO.setLeftTime(member.getLeftTime());
|
||||
respVO.setRemark(member.getRemark());
|
||||
respVO.setAdditionalRoleNames(additionalRoleNames);
|
||||
return respVO;
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -97,8 +153,10 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
|
||||
ProjectDO project = validateProjectEditable(projectId);
|
||||
validateMemberUser(reqVO.getUserId());
|
||||
ObjectRoleRespDTO targetRole = validateProjectRole(reqVO.getRoleId());
|
||||
// 多角色支持:按 (user, object, role) 三元组判存在;不带 status 过滤以便 INACTIVE 行复活(避免唯一索引 INSERT 冲突)
|
||||
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)) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_MEMBER_ALREADY_EXISTS);
|
||||
}
|
||||
@@ -113,12 +171,16 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
|
||||
member.setJoinedTime(LocalDateTime.now());
|
||||
member.setLeftTime(null);
|
||||
member.setRemark(normalizeNullableText(reqVO.getRemark()));
|
||||
// 物理 INSERT vs INACTIVE 复活的 audit 语义区分:null=新增;INACTIVE 复活=REACTIVATE
|
||||
String actionType;
|
||||
if (existingMember == null) {
|
||||
userObjectRoleMapper.insert(member);
|
||||
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
|
||||
} else {
|
||||
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)) {
|
||||
transferManager(project, member, reqVO.getPreviousManagerUserId(), reqVO.getPreviousManagerRoleId(), null);
|
||||
@@ -140,6 +202,16 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
|
||||
UserObjectRoleDO before = cloneMember(member);
|
||||
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)) {
|
||||
// 项目经理交接只切换负责人并调整原经理角色,不再把原经理自动移出项目团队。
|
||||
member.setRoleId(targetRole.getId());
|
||||
@@ -302,13 +374,26 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
|
||||
}
|
||||
|
||||
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
|
||||
.selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, previousManagerUserId);
|
||||
.selectActiveByObjectUserAndRole(ProjectObjectConstants.OBJECT_TYPE, projectId,
|
||||
previousManagerUserId, projectManagerRoleId);
|
||||
UserObjectRoleDO before = existingMember == null ? null : cloneMember(existingMember);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
UserObjectRoleDO member;
|
||||
String actionType;
|
||||
if (existingMember == null) {
|
||||
// user 当前没有 manager 角色 ACTIVE 行(罕见,可能业务上不该走到这)—— 仍兼容老逻辑:插入 previousManagerRoleId 行
|
||||
member = new UserObjectRoleDO();
|
||||
member.setUserId(previousManagerUserId);
|
||||
member.setObjectType(ProjectObjectConstants.OBJECT_TYPE);
|
||||
@@ -321,21 +406,30 @@ public class ProjectMemberServiceImpl implements ProjectMemberService {
|
||||
userObjectRoleMapper.insert(member);
|
||||
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
|
||||
} else {
|
||||
// existingMember 是 manager 行 ACTIVE,update 改 role_id 成 previousManagerRoleId("降级"该行)
|
||||
member = existingMember;
|
||||
member.setRoleId(previousManagerRoleId);
|
||||
member.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
|
||||
member.setLeftTime(null);
|
||||
if (!Objects.equals(before.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
|
||||
member.setJoinedTime(now);
|
||||
actionType = ObjectActivityConstants.MEMBER_ACTION_ADD;
|
||||
} else {
|
||||
actionType = ObjectActivityConstants.MEMBER_ACTION_UPDATE;
|
||||
}
|
||||
userObjectRoleMapper.updateById(member);
|
||||
}
|
||||
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) {
|
||||
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.enums.ErrorCodeConstants;
|
||||
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.permission.ObjectPermissionApi;
|
||||
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.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -102,6 +107,10 @@ class ProjectServiceImpl implements ProjectService {
|
||||
private DictDataApi dictDataApi;
|
||||
@Resource
|
||||
private ProjectRequirementModuleMapper projectRequirementModuleMapper;
|
||||
@Resource
|
||||
private com.njcn.rdms.module.project.service.member.ObjectRoleAutoAssignService objectRoleAutoAssignService;
|
||||
@Resource
|
||||
private ObjectDataScopeService objectDataScopeService;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -132,6 +141,10 @@ class ProjectServiceImpl implements ProjectService {
|
||||
projectMapper.insert(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);
|
||||
writeBizAuditLog(project, ObjectActivityConstants.PROJECT_ACTION_CREATE, null, initialStatus,
|
||||
buildProjectFieldChanges(null, project), null);
|
||||
@@ -195,7 +208,17 @@ class ProjectServiceImpl implements ProjectService {
|
||||
// 5) 项目维度的"指定经理"审计:与旧 initManagerMemberRelation 末尾一致,保留活动时间线一致性
|
||||
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,
|
||||
buildProjectFieldChanges(null, project), null);
|
||||
return project.getId();
|
||||
@@ -351,29 +374,46 @@ class ProjectServiceImpl implements ProjectService {
|
||||
public ProjectContextRespVO getProjectContext(Long id) {
|
||||
ProjectDO project = validateProjectExists(id);
|
||||
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
|
||||
UserObjectRoleDO currentMember = userObjectRoleMapper
|
||||
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, id, loginUserId);
|
||||
if (currentMember != null) {
|
||||
return buildProjectContext(project, currentMember.getRoleId(), false, null);
|
||||
// 多角色支持:拿全部 ACTIVE 角色 ID 传聚合 API;主角色按 sort 升序由 API 挑选 + 附 additionalRoleNames
|
||||
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
|
||||
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, id, loginUserId);
|
||||
if (!userRoles.isEmpty()) {
|
||||
List<Long> roleIds = userRoles.stream().map(UserObjectRoleDO::getRoleId).distinct().toList();
|
||||
return buildProjectContext(project, roleIds, false, null);
|
||||
}
|
||||
|
||||
ObjectRoleRespDTO visitorRole = objectPermissionApi
|
||||
.getObjectRoleByCode(ProjectObjectConstants.VISITOR_ROLE_CODE, ObjectRoleConstants.ROLE_SCOPE_OBJECT,
|
||||
// 显式角色为空:走 scope.contains 判定隐式 observer 兜底(设计文档 2.1 节末段)
|
||||
ObjectDataScope scope = objectDataScopeService.compute(loginUserId, ProjectObjectConstants.OBJECT_TYPE);
|
||||
if (!scope.contains(id, project.getDirectionCode())) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED, "查看");
|
||||
}
|
||||
return buildImplicitObserverContext(project);
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐式 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 (visitorRole == null || visitorRole.getId() == null) {
|
||||
if (observerRole == null || observerRole.getId() == null) {
|
||||
// 角色未配置,降级为无菜单的 guest 上下文(不应发生,属配置缺失)
|
||||
return buildProjectContextWithoutMenus(project, true);
|
||||
}
|
||||
return buildProjectContext(project, visitorRole.getId(), true, visitorRole);
|
||||
return buildProjectContext(project, List.of(observerRole.getId()), true, observerRole);
|
||||
}
|
||||
|
||||
private ProjectContextRespVO buildProjectContext(ProjectDO project, Long roleId, boolean guestFlag,
|
||||
private ProjectContextRespVO buildProjectContext(ProjectDO project, List<Long> roleIds, boolean guestFlag,
|
||||
ObjectRoleRespDTO fallbackRole) {
|
||||
ProjectContextRespVO respVO = new ProjectContextRespVO();
|
||||
respVO.setCurrentProject(buildCurrentProject(project));
|
||||
|
||||
ObjectRolePermissionRespDTO permissionDetail = objectPermissionApi
|
||||
.getObjectRolePermissionDetail(roleId, ObjectRoleConstants.ROLE_SCOPE_OBJECT,
|
||||
.getObjectRolePermissionDetailMerged(roleIds, ObjectRoleConstants.ROLE_SCOPE_OBJECT,
|
||||
ProjectObjectConstants.OBJECT_TYPE)
|
||||
.getCheckedData();
|
||||
ObjectRoleRespDTO currentRole = permissionDetail == null || permissionDetail.getCurrentRole() == null
|
||||
@@ -382,7 +422,11 @@ class ProjectServiceImpl implements ProjectService {
|
||||
List<ObjectMenuRespDTO> menus = permissionDetail == null || permissionDetail.getMenus() == null
|
||||
? Collections.emptyList()
|
||||
: 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.setButtons(buildContextButtons(menus));
|
||||
return respVO;
|
||||
@@ -390,17 +434,106 @@ class ProjectServiceImpl implements ProjectService {
|
||||
|
||||
@Override
|
||||
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
|
||||
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();
|
||||
respVO.setStatusCounts(buildProjectStatusCounts(objectStatusModelMapper
|
||||
.selectListByObjectType(ProjectObjectConstants.OBJECT_TYPE), projectMapper.selectStatusCountList()));
|
||||
.selectListByObjectType(ProjectObjectConstants.OBJECT_TYPE), rows));
|
||||
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) {
|
||||
if (productId == null) {
|
||||
return null;
|
||||
@@ -771,9 +904,11 @@ class ProjectServiceImpl implements ProjectService {
|
||||
if (oldManagerUserId == null) {
|
||||
return;
|
||||
}
|
||||
UserObjectRoleDO oldMember = userObjectRoleMapper.selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE,
|
||||
projectId, oldManagerUserId);
|
||||
if (oldMember == null || !Objects.equals(oldMember.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) {
|
||||
// 多角色支持:只 INACTIVATE manager 角色那一行;user 在项目内的 creator/dev 等其他角色行不动
|
||||
Long managerRoleId = resolveProjectManagerRoleId();
|
||||
UserObjectRoleDO oldMember = userObjectRoleMapper.selectActiveByObjectUserAndRole(
|
||||
ProjectObjectConstants.OBJECT_TYPE, projectId, oldManagerUserId, managerRoleId);
|
||||
if (oldMember == null) {
|
||||
return;
|
||||
}
|
||||
UserObjectRoleDO before = cloneMember(oldMember);
|
||||
@@ -784,10 +919,13 @@ class ProjectServiceImpl implements ProjectService {
|
||||
}
|
||||
|
||||
private void ensureManagerRelation(Long projectId, Long managerUserId, Long managerRoleId, String reason) {
|
||||
UserObjectRoleDO existingMember = userObjectRoleMapper.selectByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE,
|
||||
projectId, managerUserId);
|
||||
// 多角色支持:按 (user, object, manager_role_id) 三元组定位 manager 行;
|
||||
// 用 selectByObjectUserAndRole(不带 status 过滤)拿 INACTIVE 老行复活,避免 INSERT 冲突唯一索引
|
||||
UserObjectRoleDO existingMember = userObjectRoleMapper.selectByObjectUserAndRole(
|
||||
ProjectObjectConstants.OBJECT_TYPE, projectId, managerUserId, managerRoleId);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (existingMember == null) {
|
||||
// user 在项目内还没有 manager 角色行(可能已有 creator/dev 等其他角色,不影响)→ insert
|
||||
UserObjectRoleDO member = new UserObjectRoleDO();
|
||||
member.setUserId(managerUserId);
|
||||
member.setObjectType(ProjectObjectConstants.OBJECT_TYPE);
|
||||
@@ -800,8 +938,8 @@ class ProjectServiceImpl implements ProjectService {
|
||||
writeMemberAuditLog(member, ObjectActivityConstants.MEMBER_ACTION_ADD, null, member, reason);
|
||||
return;
|
||||
}
|
||||
// existingMember 已是 (user, object, manager_role_id) 行(可能 ACTIVE 或 INACTIVE)→ 激活
|
||||
UserObjectRoleDO before = cloneMember(existingMember);
|
||||
existingMember.setRoleId(managerRoleId);
|
||||
existingMember.setStatus(ObjectRoleConstants.MEMBER_STATUS_ACTIVE);
|
||||
existingMember.setLeftTime(null);
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
String fromStatus = project.getStatusCode();
|
||||
ObjectStatusTransitionDO transition = validateProjectTransition(fromStatus, actionCode, reason);
|
||||
@@ -856,13 +1006,14 @@ class ProjectServiceImpl implements ProjectService {
|
||||
private ProjectContextRespVO buildProjectContextWithoutMenus(ProjectDO project, boolean guestFlag) {
|
||||
ProjectContextRespVO respVO = new ProjectContextRespVO();
|
||||
respVO.setCurrentProject(buildCurrentProject(project));
|
||||
respVO.setCurrentRole(buildCurrentRole(null, null, guestFlag));
|
||||
respVO.setCurrentRole(buildCurrentRole(null, null, guestFlag, Collections.emptyList()));
|
||||
respVO.setNavs(Collections.emptyList());
|
||||
respVO.setButtons(Collections.emptyList());
|
||||
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();
|
||||
roleRespVO.setRoleId(roleId);
|
||||
roleRespVO.setGuestFlag(guestFlag);
|
||||
@@ -870,6 +1021,7 @@ class ProjectServiceImpl implements ProjectService {
|
||||
roleRespVO.setRoleCode(currentRole.getCode());
|
||||
roleRespVO.setRoleName(currentRole.getName());
|
||||
}
|
||||
roleRespVO.setAdditionalRoleNames(additionalRoleNames == null ? Collections.emptyList() : additionalRoleNames);
|
||||
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.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.ProjectTaskStatusBoardRespVO;
|
||||
|
||||
@@ -11,4 +13,12 @@ public interface ProjectStatusBoardService {
|
||||
|
||||
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;
|
||||
|
||||
import com.njcn.rdms.framework.common.pojo.PageResult;
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
|
||||
import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusBoardRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.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.ProjectTaskStatusBoardRespVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
import com.njcn.rdms.module.project.service.project.permission.VisibilityScope;
|
||||
import com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver;
|
||||
import com.njcn.rdms.module.project.service.project.task.ProjectTaskService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService {
|
||||
@@ -31,6 +43,8 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
|
||||
private ProjectTaskMapper projectTaskMapper;
|
||||
@Resource
|
||||
private VisibilityScopeResolver visibilityScopeResolver;
|
||||
@Resource
|
||||
private ProjectTaskService projectTaskService;
|
||||
|
||||
@Override
|
||||
public ProjectExecutionStatusBoardRespVO getExecutionStatusBoard(Long projectId, ProjectExecutionStatusBoardReqVO reqVO) {
|
||||
@@ -42,19 +56,97 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
|
||||
|
||||
@Override
|
||||
public ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
VisibilityScope scope = visibilityScopeResolver.resolveForExecution(projectId, executionId, userId);
|
||||
// 执行 owner = 当前用户 → 看本执行下全部任务,等价于 seesAll。
|
||||
if (!scope.seesAll()) {
|
||||
ProjectExecutionDO exec = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
|
||||
if (exec != null && Objects.equals(exec.getOwnerId(), userId)) {
|
||||
scope = VisibilityScope.all();
|
||||
}
|
||||
}
|
||||
VisibilityScope scope = resolveTaskScope(projectId, executionId);
|
||||
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
|
||||
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,
|
||||
VisibilityScope scope,
|
||||
ProjectExecutionStatusBoardReqVO reqVO,
|
||||
|
||||
@@ -207,9 +207,10 @@ public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssi
|
||||
|
||||
@VisibleForTesting
|
||||
void validateProjectMember(Long projectId, Long userId) {
|
||||
UserObjectRoleDO projectMember = userObjectRoleMapper
|
||||
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId);
|
||||
if (projectMember == null) {
|
||||
// 多角色支持:user 在项目内有任意 ACTIVE 角色即可作为 assignee
|
||||
if (userObjectRoleMapper
|
||||
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId)
|
||||
.isEmpty()) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +211,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
}
|
||||
ProjectExecutionRespVO respVO = BeanUtils.toBean(execution, ProjectExecutionRespVO.class);
|
||||
respVO.setProgressRate(loadExecutionProgress(projectId, executionId));
|
||||
applyLifecycle(respVO);
|
||||
boolean rootTasksAllCompleted = loadExecutionRootTasksAllCompleted(projectId, executionId);
|
||||
applyLifecycle(respVO, rootTasksAllCompleted);
|
||||
respVO.setOwnerNickname(loadOwnerNickname(execution.getOwnerId()));
|
||||
return respVO;
|
||||
}
|
||||
@@ -225,6 +226,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
return voPageResult;
|
||||
}
|
||||
fillExecutionProgress(projectId, list);
|
||||
// 批量预聚合根任务完成态,避免列表 N+1。未命中执行 → 缺省 false,complete 按钮不下发。
|
||||
Map<Long, Boolean> rootTasksAllCompletedMap = loadExecutionRootTasksAllCompletedMap(projectId, list);
|
||||
// 批量补负责人昵称,避免 N+1
|
||||
Set<Long> ownerIds = list.stream()
|
||||
.map(ProjectExecutionRespVO::getOwnerId)
|
||||
@@ -236,7 +239,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
// 列表行 cancel/pause/resume/complete 按钮依赖 availableActions,与详情同款装配 lifecycle。
|
||||
// 单行装配失败做兜底降级(status_model 缺失等脏数据),避免影响整页返回。
|
||||
try {
|
||||
applyLifecycle(vo);
|
||||
applyLifecycle(vo, rootTasksAllCompletedMap.getOrDefault(vo.getId(), false));
|
||||
} catch (Exception e) {
|
||||
log.warn("execution lifecycle apply failed in page assembly. executionId={}, statusCode={}, error={}",
|
||||
vo.getId(), vo.getStatusCode(), e.getMessage());
|
||||
@@ -392,9 +395,10 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
|
||||
@VisibleForTesting
|
||||
void validateOwnerIsActiveProjectMember(Long projectId, Long ownerId) {
|
||||
UserObjectRoleDO member = userObjectRoleMapper
|
||||
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, ownerId);
|
||||
if (member == null) {
|
||||
// 多角色支持:user 在项目内有任意 ACTIVE 角色即视为项目成员
|
||||
if (userObjectRoleMapper
|
||||
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, ownerId)
|
||||
.isEmpty()) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_OWNER_INVALID);
|
||||
}
|
||||
}
|
||||
@@ -489,9 +493,10 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
}
|
||||
|
||||
private void validateExecutionAssigneeProjectScope(Long projectId, Long userId) {
|
||||
UserObjectRoleDO member = userObjectRoleMapper
|
||||
.selectActiveByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId);
|
||||
if (member == null) {
|
||||
// 多角色支持:user 在项目内有任意 ACTIVE 角色即可作为 assignee
|
||||
if (userObjectRoleMapper
|
||||
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId)
|
||||
.isEmpty()) {
|
||||
throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_INVALID);
|
||||
}
|
||||
}
|
||||
@@ -670,10 +675,12 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
return StringUtils.hasText(value) ? value : "";
|
||||
}
|
||||
|
||||
private void applyLifecycle(ProjectExecutionRespVO respVO) {
|
||||
// 传入 ownerId 用于 availableActions 的 owner-only 字段硬卡过滤(spec §7.1)
|
||||
private void applyLifecycle(ProjectExecutionRespVO respVO, boolean rootTasksAllCompleted) {
|
||||
// 传入 ownerId / progressRate / rootTasksAllCompleted 用于 availableActions 的 owner-only、完成进度、
|
||||
// 根任务完成态过滤。rootTasksAllCompleted=false 时不下发 complete,避免任务仍进行中时执行被闭环。
|
||||
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView lifecycle =
|
||||
projectExecutionStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId());
|
||||
projectExecutionStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId(),
|
||||
respVO.getProgressRate(), rootTasksAllCompleted);
|
||||
respVO.setStatusName(lifecycle.statusName());
|
||||
respVO.setTerminal(lifecycle.terminal());
|
||||
respVO.setAllowEdit(lifecycle.allowEdit());
|
||||
@@ -690,7 +697,9 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -698,7 +707,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
.map(ProjectExecutionRespVO::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.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))));
|
||||
}
|
||||
|
||||
@@ -706,12 +716,13 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
* 列表口径批量聚合:一次 GROUP BY 取本页所有执行的一级任务平均进度。
|
||||
* 未命中的 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()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
List<Map<String, Object>> rows = projectTaskMapper
|
||||
.selectRootTaskAvgProgressGroupByExecutionIds(projectId, executionIds);
|
||||
.selectRootTaskAvgProgressGroupByExecutionIds(projectId, executionIds, excludedStatusCodes);
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
@@ -729,6 +740,65 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
|
||||
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) {
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
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>对 owner-only 状态推进动作(complete / cancel / pause / resume),按 {@code execution.ownerId == currentUserId} 字段硬卡过滤,
|
||||
* 与 {@link ProjectExecutionServiceImpl} 中 {@code validateOwnerForAction} 同款判定。</li>
|
||||
* <li>对 {@code complete} 动作叠加进度过滤,执行进度未达到 100 时不下发完成按钮。</li>
|
||||
* <li>对 {@code complete} 动作再叠加根任务完成态过滤:要求该执行下"参与聚合的根任务"全部为 completed
|
||||
* 才下发;筛选口径与进度聚合同源(execution_id + parent_task_id IS NULL + excludedStatusCodes),
|
||||
* 空集(无参与聚合的根任务)视为"未全部完成",不下发完成按钮,避免任务仍在进行中时执行被闭环。</li>
|
||||
* </ol>
|
||||
* 非状态动作(delete / change-owner / update / assignee)的权限码 / 字段过滤未纳入本字段,
|
||||
* 前端按各动作对应权限码与 owner 字段独立判断(spec §6.5 允许条件矩阵);
|
||||
@@ -39,13 +44,16 @@ public class ProjectExecutionStatusViewService {
|
||||
* 状态推进动作中要求 owner-only 字段硬卡的集合,与 ProjectExecutionServiceImpl#validateOwnerForAction 一致。
|
||||
*/
|
||||
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
|
||||
private ObjectStatusModelMapper objectStatusModelMapper;
|
||||
@Resource
|
||||
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
|
||||
|
||||
public ProjectExecutionLifecycleView getLifecycle(String statusCode, Long ownerId) {
|
||||
public ProjectExecutionLifecycleView getLifecycle(String statusCode, Long ownerId, BigDecimal progressRate,
|
||||
boolean rootTasksAllCompleted) {
|
||||
ObjectStatusModelDO statusModel = objectStatusModelMapper
|
||||
.selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, statusCode);
|
||||
if (statusModel == null) {
|
||||
@@ -55,11 +63,13 @@ public class ProjectExecutionStatusViewService {
|
||||
statusModel.getStatusName(),
|
||||
statusModel.getTerminalFlag(),
|
||||
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
|
||||
.selectListByObjectTypeAndFromStatus(ProjectExecutionConstants.OBJECT_TYPE, statusCode);
|
||||
if (transitions == null || transitions.isEmpty()) {
|
||||
@@ -72,6 +82,10 @@ public class ProjectExecutionStatusViewService {
|
||||
// owner-only 字段硬卡:非负责人本人时不返回 complete / cancel / pause / resume 动作
|
||||
.filter(transition -> !OWNER_ONLY_ACTIONS.contains(transition.getActionCode())
|
||||
|| (currentUserId != null && Objects.equals(ownerId, currentUserId)))
|
||||
// 完成动作额外要求:执行进度已达 100,且参与聚合的根任务全部已完成(避免任务仍进行中时执行被闭环);
|
||||
// 暂停、恢复、取消不受进度 / 根任务状态影响。
|
||||
.filter(transition -> !ACTION_COMPLETE.equals(transition.getActionCode())
|
||||
|| (isCompleteProgressSatisfied(progressRate) && rootTasksAllCompleted))
|
||||
.map(transition -> {
|
||||
ProjectExecutionLifecycleActionRespVO action = new ProjectExecutionLifecycleActionRespVO();
|
||||
action.setActionCode(transition.getActionCode());
|
||||
@@ -82,6 +96,10 @@ public class ProjectExecutionStatusViewService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
private boolean isCompleteProgressSatisfied(BigDecimal progressRate) {
|
||||
return progressRate != null && progressRate.compareTo(COMPLETE_PROGRESS_THRESHOLD) >= 0;
|
||||
}
|
||||
|
||||
public record ProjectExecutionLifecycleView(String statusName,
|
||||
Boolean terminal,
|
||||
Boolean allowEdit,
|
||||
|
||||
@@ -31,6 +31,14 @@ public interface ProjectTaskService {
|
||||
*/
|
||||
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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -402,6 +402,17 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
@Override
|
||||
public PageResult<ProjectTaskRespVO> getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO 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);
|
||||
List<ProjectTaskRespVO> list = voPageResult.getList();
|
||||
if (list == null || list.isEmpty()) {
|
||||
@@ -708,6 +719,8 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
// 完成动作:兜底把任务进度刷到 100%,并触发父任务 AVG 重算
|
||||
if ("complete".equals(actionCode)) {
|
||||
forceCompleteProgress(task);
|
||||
} else if (task.getParentTaskId() != null) {
|
||||
recalcParentProgressFrom(task.getParentTaskId());
|
||||
}
|
||||
|
||||
// 取消 / 暂停 / 恢复:触发子任务级联(每个子任务的内部链路自身会再级联自己的子,链式实现整棵子树)
|
||||
@@ -1024,21 +1037,22 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
*/
|
||||
private void recalcParentProgressFrom(Long parentTaskId) {
|
||||
Long current = parentTaskId;
|
||||
List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
|
||||
while (current != null) {
|
||||
ProjectTaskDO parent = projectTaskMapper.selectById(current);
|
||||
if (parent == null) {
|
||||
return;
|
||||
}
|
||||
List<ProjectTaskDO> children = projectTaskMapper.selectChildrenProgressByParentTaskId(current);
|
||||
if (children.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<ProjectTaskDO> children = projectTaskMapper.selectChildrenProgressByParentTaskId(current,
|
||||
excludedStatusCodes);
|
||||
BigDecimal sum = BigDecimal.ZERO;
|
||||
for (ProjectTaskDO child : children) {
|
||||
BigDecimal cp = child.getProgressRate() == null ? BigDecimal.ZERO : child.getProgressRate();
|
||||
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())) {
|
||||
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) {
|
||||
if (a == null && b == null) {
|
||||
return true;
|
||||
@@ -1083,9 +1103,10 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
|
||||
}
|
||||
|
||||
private void applyLifecycle(ProjectTaskRespVO respVO) {
|
||||
// 传入 ownerId 用于 availableActions 的 owner-only 字段硬卡过滤(spec §7.1)
|
||||
// 传入 ownerId / progressRate 用于 availableActions 的 owner-only 与完成进度过滤。
|
||||
ProjectTaskStatusViewService.ProjectTaskLifecycleView lifecycle =
|
||||
projectTaskStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId());
|
||||
projectTaskStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId(),
|
||||
respVO.getProgressRate());
|
||||
respVO.setStatusName(lifecycle.statusName());
|
||||
respVO.setTerminal(lifecycle.terminal());
|
||||
respVO.setAllowEdit(lifecycle.allowEdit());
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
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>对 owner-only 状态推进动作(complete / cancel / pause / resume),按 {@code task.ownerId == currentUserId} 字段硬卡过滤,
|
||||
* 与 {@link ProjectTaskServiceImpl#validateOwnerForAction} 同款判定。</li>
|
||||
* <li>对 {@code complete} 动作叠加进度过滤,任务进度未达到 100 时不下发完成按钮。</li>
|
||||
* </ol>
|
||||
* 非状态动作(delete / 加协办人 / 工时填报等)的权限码 / 字段过滤未纳入本字段,前端按各动作对应权限码与 owner 字段独立判断;
|
||||
* 全量化纳入 {@code availableActions} 留待后续阶段统一改造。
|
||||
@@ -38,13 +40,15 @@ public class ProjectTaskStatusViewService {
|
||||
* 状态推进动作中要求 owner-only 字段硬卡的集合,与 ProjectTaskServiceImpl#validateOwnerForAction 一致。
|
||||
*/
|
||||
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
|
||||
private ObjectStatusModelMapper objectStatusModelMapper;
|
||||
@Resource
|
||||
private ObjectStatusTransitionMapper objectStatusTransitionMapper;
|
||||
|
||||
public ProjectTaskLifecycleView getLifecycle(String statusCode, Long ownerId) {
|
||||
public ProjectTaskLifecycleView getLifecycle(String statusCode, Long ownerId, BigDecimal progressRate) {
|
||||
ObjectStatusModelDO statusModel = objectStatusModelMapper
|
||||
.selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, statusCode);
|
||||
if (statusModel == null) {
|
||||
@@ -54,11 +58,12 @@ public class ProjectTaskStatusViewService {
|
||||
statusModel.getStatusName(),
|
||||
statusModel.getTerminalFlag(),
|
||||
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
|
||||
.selectListByObjectTypeAndFromStatus(ProjectTaskConstants.OBJECT_TYPE, statusCode);
|
||||
if (transitions == null || transitions.isEmpty()) {
|
||||
@@ -71,6 +76,9 @@ public class ProjectTaskStatusViewService {
|
||||
// owner-only 字段硬卡:非负责人本人时不返回 complete / cancel / pause / resume 动作
|
||||
.filter(transition -> !OWNER_ONLY_ACTIONS.contains(transition.getActionCode())
|
||||
|| (currentUserId != null && Objects.equals(ownerId, currentUserId)))
|
||||
// 完成动作额外要求任务进度已达到 100;暂停、恢复、取消不受进度影响
|
||||
.filter(transition -> !ACTION_COMPLETE.equals(transition.getActionCode())
|
||||
|| isCompleteProgressSatisfied(progressRate))
|
||||
.map(transition -> {
|
||||
ProjectTaskLifecycleActionRespVO action = new ProjectTaskLifecycleActionRespVO();
|
||||
action.setActionCode(transition.getActionCode());
|
||||
@@ -81,6 +89,10 @@ public class ProjectTaskStatusViewService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
private boolean isCompleteProgressSatisfied(BigDecimal progressRate) {
|
||||
return progressRate != null && progressRate.compareTo(COMPLETE_PROGRESS_THRESHOLD) >= 0;
|
||||
}
|
||||
|
||||
public record ProjectTaskLifecycleView(String statusName,
|
||||
Boolean terminal,
|
||||
Boolean allowEdit,
|
||||
|
||||
@@ -13,6 +13,8 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||
@@ -36,8 +38,8 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
|
||||
void checkPermission_whenMemberOnlyAndCurrentUserIsMember_shouldPass() {
|
||||
Long productId = 1001L;
|
||||
Long loginUserId = 2001L;
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
|
||||
.thenReturn(createMember(productId, loginUserId, 3001L));
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
|
||||
.thenReturn(List.of(createMember(productId, loginUserId, 3001L)));
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
|
||||
assertDoesNotThrow(() -> permissionService.checkPermission(productId, null, true));
|
||||
@@ -50,8 +52,8 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
|
||||
void checkPermission_whenCurrentRolePermissionsContainTarget_shouldPass() {
|
||||
Long productId = 1002L;
|
||||
Long loginUserId = 2002L;
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
|
||||
.thenReturn(createMember(productId, loginUserId, 3002L));
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
|
||||
.thenReturn(List.of(createMember(productId, loginUserId, 3002L)));
|
||||
when(objectPermissionApi.getObjectRolePermissions(3002L,
|
||||
PermissionScopeTypeEnum.OBJECT.getScopeType(), "product"))
|
||||
.thenReturn(success(Set.of("project:product:query")));
|
||||
@@ -65,8 +67,8 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
|
||||
void checkPermission_whenCurrentRoleDoesNotContainPermission_shouldThrowException() {
|
||||
Long productId = 1003L;
|
||||
Long loginUserId = 2003L;
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
|
||||
.thenReturn(createMember(productId, loginUserId, 3003L));
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
|
||||
.thenReturn(List.of(createMember(productId, loginUserId, 3003L)));
|
||||
when(objectPermissionApi.getObjectRolePermissions(3003L,
|
||||
PermissionScopeTypeEnum.OBJECT.getScopeType(), "product"))
|
||||
.thenReturn(success(Set.of("project:product:update")));
|
||||
@@ -82,8 +84,8 @@ class ProductObjectPermissionServiceTest extends BaseMockitoUnitTest {
|
||||
void checkPermission_whenCurrentUserIsNotMember_shouldThrowException() {
|
||||
Long productId = 1004L;
|
||||
Long loginUserId = 2004L;
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
|
||||
.thenReturn(null);
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
|
||||
@@ -13,6 +13,8 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||
@@ -36,8 +38,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
|
||||
void checkPermission_whenMemberOnlyAndActiveMember_shouldPass() {
|
||||
Long projectId = 1001L;
|
||||
Long loginUserId = 2001L;
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId))
|
||||
.thenReturn(createMember(projectId, loginUserId, 3001L));
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
|
||||
.thenReturn(List.of(createMember(projectId, loginUserId, 3001L)));
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
|
||||
assertDoesNotThrow(() -> permissionService.checkPermission(projectId, null, true));
|
||||
@@ -50,8 +52,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
|
||||
void checkPermission_whenNoActiveMember_shouldThrowProjectPermissionDenied() {
|
||||
Long projectId = 1002L;
|
||||
Long loginUserId = 2002L;
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId))
|
||||
.thenReturn(null);
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(loginUserId)) {
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
@@ -64,8 +66,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
|
||||
void checkPermission_whenPermissionPresent_shouldPass() {
|
||||
Long projectId = 1003L;
|
||||
Long loginUserId = 2003L;
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId))
|
||||
.thenReturn(createMember(projectId, loginUserId, 3003L));
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
|
||||
.thenReturn(List.of(createMember(projectId, loginUserId, 3003L)));
|
||||
when(objectPermissionApi.getObjectRolePermissions(3003L,
|
||||
PermissionScopeTypeEnum.OBJECT.getScopeType(), "project"))
|
||||
.thenReturn(success(Set.of("project:project:update")));
|
||||
@@ -79,8 +81,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
|
||||
void checkPermission_whenPermissionMissing_shouldThrowProjectPermissionDenied() {
|
||||
Long projectId = 1004L;
|
||||
Long loginUserId = 2004L;
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId))
|
||||
.thenReturn(createMember(projectId, loginUserId, 3004L));
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
|
||||
.thenReturn(List.of(createMember(projectId, loginUserId, 3004L)));
|
||||
when(objectPermissionApi.getObjectRolePermissions(3004L,
|
||||
PermissionScopeTypeEnum.OBJECT.getScopeType(), "project"))
|
||||
.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.dto.AdminUserRespDTO;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
@@ -30,6 +31,7 @@ import java.util.Set;
|
||||
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.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -97,7 +99,7 @@ class ProductMemberServiceImplTest extends BaseMockitoUnitTest {
|
||||
() -> productMemberService.createProductMember(productId, reqVO));
|
||||
|
||||
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
|
||||
@@ -153,6 +155,56 @@ class ProductMemberServiceImplTest extends BaseMockitoUnitTest {
|
||||
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) {
|
||||
ProductDO product = new ProductDO();
|
||||
product.setId(productId);
|
||||
@@ -199,4 +251,118 @@ class ProductMemberServiceImplTest extends BaseMockitoUnitTest {
|
||||
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.common.util.json.JsonUtils;
|
||||
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.ProductDeleteReqVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductOverviewSummaryRespVO;
|
||||
import com.njcn.rdms.module.project.controller.admin.product.vo.product.ProductPageReqVO;
|
||||
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.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.dal.dataobject.audit.BizAuditLogDO;
|
||||
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.Mock;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
@@ -74,6 +80,10 @@ class ProductServiceImplTest extends BaseMockitoUnitTest {
|
||||
private AdminUserApi adminUserApi;
|
||||
@Mock
|
||||
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
|
||||
void createProduct_shouldCreateDefaultRequirementModule() {
|
||||
@@ -460,9 +470,9 @@ class ProductServiceImplTest extends BaseMockitoUnitTest {
|
||||
detail.setPermissions(Set.of("project:product:update"));
|
||||
|
||||
when(productMapper.selectById(productId)).thenReturn(product);
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("product", productId, loginUserId))
|
||||
.thenReturn(currentMember);
|
||||
when(objectPermissionApi.getObjectRolePermissionDetail(roleId, "object", "product"))
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("product", productId, loginUserId))
|
||||
.thenReturn(List.of(currentMember));
|
||||
when(objectPermissionApi.getObjectRolePermissionDetailMerged(List.of(roleId), "object", "product"))
|
||||
.thenReturn(success(detail));
|
||||
|
||||
ProductContextRespVO respVO;
|
||||
@@ -479,6 +489,107 @@ class ProductServiceImplTest extends BaseMockitoUnitTest {
|
||||
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,
|
||||
String description, String statusCode) {
|
||||
ProductDO product = new ProductDO();
|
||||
|
||||
@@ -99,7 +99,7 @@ class ProjectMemberServiceImplTest extends BaseMockitoUnitTest {
|
||||
when(adminUserApi.validateUserList(any())).thenReturn(success(true));
|
||||
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
|
||||
.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));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
@@ -147,8 +147,15 @@ class ProjectMemberServiceImplTest extends BaseMockitoUnitTest {
|
||||
.thenReturn(success(createRole(3101L, "project_manager", "项目经理")));
|
||||
when(objectPermissionApi.getObjectRoleById(3102L, "object", "project"))
|
||||
.thenReturn(success(createRole(3102L, "project_member_configured", "项目成员")));
|
||||
when(userObjectRoleMapper.selectByObjectAndUserId("project", projectId, 2002L)).thenReturn(null);
|
||||
when(userObjectRoleMapper.selectByObjectAndUserId("project", projectId, 2001L)).thenReturn(previousManager);
|
||||
when(userObjectRoleMapper.selectByObjectUserAndRole("project", projectId, 2002L, 3101L)).thenReturn(null);
|
||||
// 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);
|
||||
|
||||
@@ -226,6 +233,58 @@ class ProjectMemberServiceImplTest extends BaseMockitoUnitTest {
|
||||
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) {
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setId(projectId);
|
||||
@@ -280,4 +339,121 @@ class ProjectMemberServiceImplTest extends BaseMockitoUnitTest {
|
||||
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.security.core.util.SecurityFrameworkUtils;
|
||||
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.ProjectLifecycleActionRespVO;
|
||||
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.ProjectSaveReqVO;
|
||||
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.member.UserObjectRoleDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.product.ProductDO;
|
||||
@@ -39,11 +43,13 @@ import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
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.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@@ -81,6 +87,8 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
private AdminUserApi adminUserApi;
|
||||
@Mock
|
||||
private DictDataApi dictDataApi;
|
||||
@Mock
|
||||
private ObjectDataScopeService objectDataScopeService;
|
||||
|
||||
@Test
|
||||
void getProjectDetail_shouldFillProductNameAndManagerNickname() {
|
||||
@@ -322,22 +330,28 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
@Test
|
||||
void getProjectContext_whenCreatorHasNoObjectRole_shouldReturnVisitorResources() {
|
||||
// 改造后:无显式角色 + scope 命中 → 返回 implicit_observer_project 上下文(原 visitor 兜底已由 scope.contains 替代)
|
||||
Long projectId = 1002L;
|
||||
Long loginUserId = 3002L;
|
||||
Long visitorRoleId = 9201L;
|
||||
Long observerRoleId = 9202L;
|
||||
ProjectDO project = createProject(projectId, null, "创建人项目", 2001L, "pending");
|
||||
project.setCreator(String.valueOf(loginUserId));
|
||||
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
|
||||
detail.setCurrentRole(createRole(visitorRoleId, "visitor", "游客"));
|
||||
detail.setCurrentRole(createRole(observerRoleId, "implicit_observer_project", "项目隐式观察者"));
|
||||
detail.setMenus(List.of(
|
||||
createMenu(9301L, "概览", null, 2, 10, "/project/project/overview", "mdi:home-outline", true)
|
||||
));
|
||||
detail.setPermissions(Set.of());
|
||||
detail.setAdditionalRoleNames(Collections.emptyList());
|
||||
when(projectMapper.selectById(projectId)).thenReturn(project);
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, loginUserId)).thenReturn(null);
|
||||
when(objectPermissionApi.getObjectRoleByCode("visitor", "object", "project"))
|
||||
.thenReturn(success(createRole(visitorRoleId, "visitor", "游客")));
|
||||
when(objectPermissionApi.getObjectRolePermissionDetail(visitorRoleId, "object", "project"))
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
|
||||
.thenReturn(Collections.emptyList());
|
||||
// scope 命中该项目 id
|
||||
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("pending"))
|
||||
.thenReturn(new ProjectStatusViewService.ProjectLifecycleView(
|
||||
@@ -357,14 +371,115 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
assertEquals(1, respVO.getCurrentProject().getAvailableActions().size());
|
||||
assertEquals("cancel", respVO.getCurrentProject().getAvailableActions().get(0).getActionCode());
|
||||
assertEquals(Boolean.TRUE, respVO.getCurrentRole().getGuestFlag());
|
||||
assertEquals(visitorRoleId, respVO.getCurrentRole().getRoleId());
|
||||
assertEquals("visitor", respVO.getCurrentRole().getRoleCode());
|
||||
assertEquals("游客", respVO.getCurrentRole().getRoleName());
|
||||
assertEquals(observerRoleId, respVO.getCurrentRole().getRoleId());
|
||||
assertEquals("implicit_observer_project", respVO.getCurrentRole().getRoleCode());
|
||||
assertEquals("项目隐式观察者", respVO.getCurrentRole().getRoleName());
|
||||
assertEquals(1, respVO.getNavs().size());
|
||||
assertEquals(9301L, respVO.getNavs().get(0).getId());
|
||||
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
|
||||
void changeProjectStatus_shouldUpdateStatusAndWriteStatusAndAuditLogs() {
|
||||
Long projectId = 1003L;
|
||||
|
||||
@@ -96,8 +96,8 @@ class ProjectExecutionAssigneeServiceImplTest extends BaseMockitoUnitTest {
|
||||
@Test
|
||||
void createExecutionAssignee_byUserNeverJoined_shouldInsertActiveAndWriteJoinLog() {
|
||||
stubEditableContext();
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
|
||||
.thenReturn(createProjectMember());
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", PROJECT_ID, USER_ID))
|
||||
.thenReturn(List.of(createProjectMember()));
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
|
||||
when(executionAssigneeMapper.insert(any(ExecutionAssigneeDO.class))).thenAnswer(inv -> {
|
||||
ExecutionAssigneeDO m = inv.getArgument(0);
|
||||
@@ -128,8 +128,8 @@ class ProjectExecutionAssigneeServiceImplTest extends BaseMockitoUnitTest {
|
||||
void createExecutionAssignee_whenInactiveSegmentExists_shouldInsertNewSegmentAndPreserveHistoricalReason() {
|
||||
// B 模型:用户曾失效,重新加入新插一段,旧段不动
|
||||
stubEditableContext();
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
|
||||
.thenReturn(createProjectMember());
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", PROJECT_ID, USER_ID))
|
||||
.thenReturn(List.of(createProjectMember()));
|
||||
// 当前没有活跃段(旧段已失效),通过 active-only 校验
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID)).thenReturn(null);
|
||||
when(executionAssigneeMapper.insert(any(ExecutionAssigneeDO.class))).thenAnswer(inv -> {
|
||||
@@ -152,8 +152,8 @@ class ProjectExecutionAssigneeServiceImplTest extends BaseMockitoUnitTest {
|
||||
@Test
|
||||
void createExecutionAssignee_whenAlreadyActive_shouldThrowAlreadyExists() {
|
||||
stubEditableContext();
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", PROJECT_ID, USER_ID))
|
||||
.thenReturn(createProjectMember());
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", PROJECT_ID, USER_ID))
|
||||
.thenReturn(List.of(createProjectMember()));
|
||||
when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(EXECUTION_ID, USER_ID))
|
||||
.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.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
@@ -119,12 +120,12 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
.thenReturn(createProjectStatus("pending", true));
|
||||
when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("execution"))
|
||||
.thenReturn(createExecutionStatus("pending", true));
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
|
||||
.thenReturn(createProjectMember(projectId, 3001L, 3101L));
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3002L))
|
||||
.thenReturn(createProjectMember(projectId, 3002L, 3102L));
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3003L))
|
||||
.thenReturn(createProjectMember(projectId, 3003L, 3102L));
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
|
||||
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3002L))
|
||||
.thenReturn(List.of(createProjectMember(projectId, 3002L, 3102L)));
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3003L))
|
||||
.thenReturn(List.of(createProjectMember(projectId, 3003L, 3102L)));
|
||||
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
|
||||
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
|
||||
.thenReturn(success(true));
|
||||
@@ -180,8 +181,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
|
||||
.thenReturn(createProjectStatus("pending", true));
|
||||
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
|
||||
.thenReturn(createProjectMember(projectId, 3001L, 3101L));
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
|
||||
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
|
||||
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("unknown")))
|
||||
.thenThrow(new RuntimeException("invalid dict"));
|
||||
|
||||
@@ -204,8 +205,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
|
||||
.thenReturn(createProjectStatus("pending", true));
|
||||
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
|
||||
.thenReturn(createProjectMember(projectId, 3001L, 3101L));
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
|
||||
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
|
||||
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
|
||||
.thenReturn(success(true));
|
||||
|
||||
@@ -233,8 +234,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
.thenReturn(createExecutionStatus("pending", true));
|
||||
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
|
||||
when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调-修订")).thenReturn(null);
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
|
||||
.thenReturn(createProjectMember(projectId, 3001L, 3101L));
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
|
||||
.thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
|
||||
when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
|
||||
.thenReturn(success(true));
|
||||
|
||||
@@ -255,8 +256,8 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
|
||||
.thenReturn(createExecutionStatus("pending", true));
|
||||
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
|
||||
when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3002L))
|
||||
.thenReturn(createProjectMember(projectId, 3002L, 3102L));
|
||||
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3002L))
|
||||
.thenReturn(List.of(createProjectMember(projectId, 3002L, 3102L)));
|
||||
|
||||
ProjectExecutionOwnerChangeReqVO reqVO = new ProjectExecutionOwnerChangeReqVO();
|
||||
reqVO.setNewOwnerId(3002L);
|
||||
@@ -417,15 +418,17 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
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"));
|
||||
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
|
||||
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("66.67")), anyBoolean()))
|
||||
.thenReturn(createLifecycleView());
|
||||
|
||||
ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);
|
||||
|
||||
assertEquals(new BigDecimal("66.67"), result.getProgressRate());
|
||||
verify(projectTaskMapper).selectRootTaskAvgProgressByExecutionId(projectId, executionId);
|
||||
verify(projectTaskMapper).selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -437,8 +440,11 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
|
||||
when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId)).thenReturn(null);
|
||||
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
|
||||
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
|
||||
.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());
|
||||
|
||||
ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);
|
||||
@@ -446,6 +452,27 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
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
|
||||
void getExecutionRespVOPage_shouldBatchOverwriteCachedProgress() {
|
||||
Long projectId = 2001L;
|
||||
@@ -460,11 +487,13 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
|
||||
.thenReturn(new PageResult<>(List.of(first, second), 2L));
|
||||
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection()))
|
||||
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"))));
|
||||
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
|
||||
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("25.56")), anyBoolean()))
|
||||
.thenReturn(createLifecycleView());
|
||||
when(projectExecutionStatusViewService.getLifecycle("pending", 3002L))
|
||||
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3002L), eq(new BigDecimal("0.00")), anyBoolean()))
|
||||
.thenReturn(createLifecycleView());
|
||||
|
||||
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("0.00"), result.getList().get(1).getProgressRate());
|
||||
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()));
|
||||
}
|
||||
|
||||
@@ -493,11 +523,13 @@ class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {
|
||||
when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
|
||||
when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
|
||||
.thenReturn(new PageResult<>(List.of(first, second), 2L));
|
||||
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection()))
|
||||
when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
|
||||
.thenReturn(List.of("cancelled"));
|
||||
when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection(), eq(List.of("cancelled"))))
|
||||
.thenReturn(rows);
|
||||
when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
|
||||
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("25.56")), anyBoolean()))
|
||||
.thenReturn(createLifecycleView());
|
||||
when(projectExecutionStatusViewService.getLifecycle("pending", 3002L))
|
||||
when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3002L), eq(new BigDecimal("10.00")), anyBoolean()))
|
||||
.thenReturn(createLifecycleView());
|
||||
|
||||
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.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionLifecycleActionRespVO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
|
||||
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
|
||||
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
|
||||
@@ -13,6 +14,7 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -45,7 +47,7 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
||||
|
||||
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
|
||||
projectExecutionStatusViewService.getLifecycle("active", ownerId);
|
||||
projectExecutionStatusViewService.getLifecycle("active", ownerId, BigDecimal.ZERO, false);
|
||||
|
||||
assertEquals("进行中", result.statusName());
|
||||
assertFalse(result.terminal());
|
||||
@@ -73,12 +75,83 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(otherUserId);
|
||||
|
||||
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
|
||||
projectExecutionStatusViewService.getLifecycle("active", ownerId);
|
||||
projectExecutionStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"), true);
|
||||
|
||||
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
|
||||
void getLifecycle_shouldExcludeAutoStartAction() {
|
||||
Long ownerId = 3001L;
|
||||
@@ -98,7 +171,7 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
||||
|
||||
ProjectExecutionStatusViewService.ProjectExecutionLifecycleView result =
|
||||
projectExecutionStatusViewService.getLifecycle("pending", ownerId);
|
||||
projectExecutionStatusViewService.getLifecycle("pending", ownerId, BigDecimal.ZERO, false);
|
||||
|
||||
assertTrue(result.availableActions().isEmpty());
|
||||
}
|
||||
@@ -110,7 +183,7 @@ class ProjectExecutionStatusViewServiceTest extends BaseMockitoUnitTest {
|
||||
.thenReturn(null);
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.njcn.rdms.module.project.service.project.task;
|
||||
|
||||
import com.njcn.rdms.framework.common.exception.ServiceException;
|
||||
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskSaveReqVO;
|
||||
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.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
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.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -319,6 +323,78 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
// -------------------- 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
|
||||
void createTask_whenParentIsLeafWithProgress_shouldThrowLeafToParentForbiddenProgress() {
|
||||
Long projectId = 2001L;
|
||||
@@ -418,7 +494,7 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
existingChild.setProgressRate(new BigDecimal("60.00"));
|
||||
ProjectTaskDO newChild = createTask(projectId, executionId, 9002L, 3002L);
|
||||
newChild.setProgressRate(BigDecimal.ZERO);
|
||||
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId))
|
||||
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId, Collections.emptyList()))
|
||||
.thenReturn(List.of(existingChild, newChild));
|
||||
|
||||
projectTaskService.createTask(projectId, executionId, reqVO);
|
||||
@@ -470,9 +546,9 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
when(projectTaskMapper.selectById(grandparentId)).thenReturn(grandparent);
|
||||
ProjectTaskDO newChild = createTask(projectId, executionId, 9002L, 3002L);
|
||||
newChild.setProgressRate(new BigDecimal("80.00"));
|
||||
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId))
|
||||
when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId, Collections.emptyList()))
|
||||
.thenReturn(List.of(newChild));
|
||||
when(projectTaskMapper.selectChildrenProgressByParentTaskId(grandparentId))
|
||||
when(projectTaskMapper.selectChildrenProgressByParentTaskId(grandparentId, Collections.emptyList()))
|
||||
.thenReturn(List.of(parentTask));
|
||||
|
||||
projectTaskService.createTask(projectId, executionId, reqVO);
|
||||
@@ -526,16 +602,16 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
// 递归刷新两条链:旧父 8001 与 新父 8002
|
||||
when(projectTaskMapper.selectById(oldParentId)).thenReturn(oldParent);
|
||||
when(projectTaskMapper.selectById(newParentId)).thenReturn(newParent);
|
||||
when(projectTaskMapper.selectChildrenProgressByParentTaskId(oldParentId))
|
||||
.thenReturn(List.of()); // 旧父在迁移后无子,保留原值
|
||||
when(projectTaskMapper.selectChildrenProgressByParentTaskId(newParentId))
|
||||
when(projectTaskMapper.selectChildrenProgressByParentTaskId(oldParentId, Collections.emptyList()))
|
||||
.thenReturn(List.of()); // 旧父在迁移后无有效子任务,按新口径归零;当前值已为 0 不重复更新
|
||||
when(projectTaskMapper.selectChildrenProgressByParentTaskId(newParentId, Collections.emptyList()))
|
||||
.thenReturn(List.of(task));
|
||||
|
||||
projectTaskService.updateTask(projectId, executionId, reqVO);
|
||||
|
||||
verify(projectTaskMapper).updateProgressRateById(eq(newParentId),
|
||||
argThat(v -> new BigDecimal("70.00").compareTo(v) == 0));
|
||||
// 旧父无子,recalcParentProgressFrom 早退,不调 updateProgressRateById
|
||||
// 旧父无有效子任务且当前已为 0,不重复更新
|
||||
verify(projectTaskMapper, never()).updateProgressRateById(eq(oldParentId), any(BigDecimal.class));
|
||||
}
|
||||
|
||||
@@ -591,6 +667,12 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
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) {
|
||||
ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
|
||||
transition.setActionCode(actionCode);
|
||||
@@ -600,4 +682,10 @@ class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
|
||||
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.security.core.util.SecurityFrameworkUtils;
|
||||
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.ObjectStatusTransitionDO;
|
||||
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.MockedStatic;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -45,7 +47,7 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
||||
|
||||
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
|
||||
projectTaskStatusViewService.getLifecycle("active", ownerId);
|
||||
projectTaskStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"));
|
||||
|
||||
assertEquals("进行中", view.statusName());
|
||||
assertFalse(view.terminal());
|
||||
@@ -71,12 +73,55 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(otherUserId);
|
||||
|
||||
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
|
||||
projectTaskStatusViewService.getLifecycle("active", ownerId);
|
||||
projectTaskStatusViewService.getLifecycle("active", ownerId, new BigDecimal("100.00"));
|
||||
|
||||
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
|
||||
void getLifecycle_shouldExcludeAutoStartAction() {
|
||||
Long ownerId = 3001L;
|
||||
@@ -95,7 +140,7 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
|
||||
mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(ownerId);
|
||||
|
||||
ProjectTaskStatusViewService.ProjectTaskLifecycleView view =
|
||||
projectTaskStatusViewService.getLifecycle("pending", ownerId);
|
||||
projectTaskStatusViewService.getLifecycle("pending", ownerId, BigDecimal.ZERO);
|
||||
|
||||
assertTrue(view.availableActions().isEmpty());
|
||||
}
|
||||
@@ -106,7 +151,7 @@ class ProjectTaskStatusViewServiceTest extends BaseMockitoUnitTest {
|
||||
when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "missing")).thenReturn(null);
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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("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 返回对象作用域角色摘要映射,便于业务模块批量对齐本地成员数据。
|
||||
*
|
||||
|
||||
@@ -24,4 +24,8 @@ public interface PermissionApi extends PermissionCommonApi {
|
||||
@Parameter(name = "roleIds", description = "角色编号集合", example = "1,2", required = true)
|
||||
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 lombok.Data;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -20,4 +21,11 @@ public class ObjectRolePermissionRespDTO {
|
||||
@ArraySchema(schema = @Schema(description = "基于同一批有效菜单资源归一化提取出的权限标识集合,供对象权限校验直接消费", example = "project:product:query"))
|
||||
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_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 {
|
||||
|
||||
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 name;
|
||||
@@ -19,7 +30,13 @@ public enum RoleCodeEnum {
|
||||
}
|
||||
|
||||
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.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
|
||||
@@ -77,6 +80,62 @@ public class ObjectPermissionApiImpl implements ObjectPermissionApi {
|
||||
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() {
|
||||
ObjectRolePermissionRespDTO detail = new ObjectRolePermissionRespDTO();
|
||||
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.module.system.service.permission.PermissionService;
|
||||
import com.njcn.rdms.module.system.service.permission.RoleService;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
@@ -22,6 +23,9 @@ public class PermissionApiImpl implements PermissionApi {
|
||||
@Resource
|
||||
private PermissionService permissionService;
|
||||
|
||||
@Resource
|
||||
private RoleService roleService;
|
||||
|
||||
@Override
|
||||
public CommonResult<Set<Long>> getUserRoleIdListByRoleIds(Collection<Long> roleIds) {
|
||||
return success(permissionService.getUserRoleIdListByRoleId(roleIds));
|
||||
@@ -37,5 +41,14 @@ public class PermissionApiImpl implements PermissionApi {
|
||||
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 org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
@@ -23,4 +24,22 @@ public interface OrgLeaderRelationMapper extends BaseMapperX<OrgLeaderRelationDO
|
||||
.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);
|
||||
|
||||
/**
|
||||
* 获得指定部门集合及其所有子孙部门的 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);
|
||||
|
||||
/**
|
||||
* 按 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.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
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.util.collection.CollectionUtils.convertSet;
|
||||
@@ -251,6 +253,36 @@ public class DeptServiceImpl implements DeptService {
|
||||
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
|
||||
public void validateDeptList(Collection<Long> 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 java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 组织负责人关系 Service
|
||||
@@ -49,4 +50,12 @@ public interface OrgLeaderRelationService {
|
||||
*/
|
||||
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.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.module.system.enums.ErrorCodeConstants.*;
|
||||
import static java.util.Collections.singleton;
|
||||
@@ -68,6 +70,12 @@ public class OrgLeaderRelationServiceImpl implements OrgLeaderRelationService {
|
||||
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
|
||||
public List<AdminUserDO> getCandidateUsersByDeptId(Long 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.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 后台用户 Service 接口
|
||||
@@ -243,4 +244,12 @@ public interface AdminUserService {
|
||||
* @return 用户列表
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
@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
|
||||
public List<AdminUserDO> getAllUserByDeptId(Long 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