diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0d830a9..1a10daf 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 *)" ] } } diff --git a/AGENTS.md b/AGENTS.md index 0d4978c..754b3d3 100644 --- a/AGENTS.md +++ b/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 ` 或 `git log <主线>..` 把输出贴回来。 +2. **工作区是否干净**:`git status`。 +3. **先挂救生圈**:建议用 `git tag backup-xxx ` 锁定当前 SHA,**再执行**破坏性命令。 +4. **明示翻车回滚路径**:例如"如果不对,`git reset --hard backup-xxx` 即可回到此处"。 + ## 测试指引 先定义验证方式,再实施修改。默认通过以下方式验证: diff --git a/CLAUDE.md b/CLAUDE.md index 9544a81..880075c 100644 --- a/CLAUDE.md +++ b/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 ` 或 `git log <主线>..` 把输出贴回来。 +2. **工作区是否干净**:`git status`。 +3. **先挂救生圈**:建议用 `git tag backup-xxx ` 锁定当前 SHA,**再执行**破坏性命令。 +4. **明示翻车回滚路径**:例如"如果不对,`git reset --hard backup-xxx` 即可回到此处"。 + +与 memory `feedback_no_git_commands.md`、`feedback_main_branch_workflow.md` 衔接:用户已要求"不主动跑 git 子命令(含只读)"+"在 main 上工作";本节进一步约束——**即使是让用户执行的建议命令**,也必须先满足上述核实清单。 + ## 验证默认动作 先定义验证方式,再做修改。默认静态验证: diff --git a/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java b/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java index 186baea..47aa465 100644 --- a/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java +++ b/rdms-project/rdms-project-api/src/main/java/com/njcn/rdms/module/project/enums/ErrorCodeConstants.java @@ -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, "执行不存在"); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java index eea54f6..ebc773a 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ObjectActivityConstants.java @@ -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 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 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; }; } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectTaskConstants.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectTaskConstants.java index 3aa7b6f..8bc269d 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectTaskConstants.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/constant/ProjectTaskConstants.java @@ -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"; + /** * 任务业务类型。 */ diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberRespVO.java index 857727f..5439813 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberRespVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/member/ProductMemberRespVO.java @@ -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 additionalRoleNames = Collections.emptyList(); + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextRoleRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextRoleRespVO.java index a544378..90560a3 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextRoleRespVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductContextRoleRespVO.java @@ -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 additionalRoleNames = Collections.emptyList(); + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductCreateWithTeamReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductCreateWithTeamReqVO.java index d4d9b84..c136ead 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductCreateWithTeamReqVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/product/vo/product/ProductCreateWithTeamReqVO.java @@ -39,4 +39,14 @@ public class ProductCreateWithTeamReqVO { @Valid private List members; + /** + * 关心人 user ID 列表(创建时手动添加,可选)。 + * + *

跟 members 是平行字段:watcher 不参与团队管理,只是被授予"产品关心人"角色(product_watcher)拿 + * 数据可见性。允许跟 members 的 user 重叠(多角色合法);后端按 (user, object, role) 三元组写入, + * 重复跳过 / INACTIVE 复活,业务侧不强校验。 + */ + @Schema(description = "关心人用户ID列表(可选,可与团队成员重叠)", example = "101,102") + private List watcherUserIds; + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/ProjectTaskController.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/ProjectTaskController.java index 5e0ffe4..9f46211 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/ProjectTaskController.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/ProjectTaskController.java @@ -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 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 changeStatus(@PathVariable("projectId") Long projectId, diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskBoardPageReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskBoardPageReqVO.java new file mode 100644 index 0000000..fda33d1 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskBoardPageReqVO.java @@ -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。 + *

过滤口径(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; + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskBoardPageRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskBoardPageRespVO.java new file mode 100644 index 0000000..37aea3d --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/task/vo/ProjectTaskBoardPageRespVO.java @@ -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。 + *

每个 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 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 list; + + @Schema(description = "该列在当前过滤条件下的总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "12") + private Long total; + + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectContextRoleRespVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectContextRoleRespVO.java index b1d7884..df7268e 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectContextRoleRespVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectContextRoleRespVO.java @@ -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 additionalRoleNames = Collections.emptyList(); } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectCreateWithTeamReqVO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectCreateWithTeamReqVO.java index ea77267..e8c65fe 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectCreateWithTeamReqVO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/controller/admin/project/vo/project/ProjectCreateWithTeamReqVO.java @@ -40,4 +40,14 @@ public class ProjectCreateWithTeamReqVO { @Valid private List members; + /** + * 关心人 user ID 列表(创建时手动添加,可选)。 + * + *

跟 members 是平行字段:watcher 不参与团队管理,只是被授予"项目关心人"角色(project_watcher)拿 + * 数据可见性。允许跟 members 的 user 重叠(多角色合法);后端按 (user, object, role) 三元组写入, + * 重复跳过 / INACTIVE 复活,业务侧不强校验。 + */ + @Schema(description = "关心人用户ID列表(可选,可与团队成员重叠)", example = "101,102") + private List watcherUserIds; + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/status/ObjectStatusModelDO.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/status/ObjectStatusModelDO.java index 63c5ea1..e8595c5 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/status/ObjectStatusModelDO.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/dataobject/status/ObjectStatusModelDO.java @@ -51,6 +51,10 @@ public class ObjectStatusModelDO extends BaseDO { * 是否允许编辑对象主数据 */ private Boolean allowEdit; + /** + * 是否不参与上层进度统计。 + */ + private Boolean progressExcludedFlag; /** * 备注 */ diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/member/UserObjectRoleMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/member/UserObjectRoleMapper.java index df95124..f16975a 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/member/UserObjectRoleMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/member/UserObjectRoleMapper.java @@ -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 { .eq(UserObjectRoleDO::getStatus, 0)); } + /** + * multi-role:拿 user 在某对象内全部 ACTIVE 角色行(含 manager + creator + watcher 等所有显式角色)。 + * 用于对象域鉴权(ProductObjectPermissionService / ProjectObjectPermissionService 的 anyMatch 权限聚合) + * 与 Context 主角色挑选(按 sort 升序排,主角色 + additionalRoleNames)。 + */ + default List selectActiveListByObjectAndUserId(String objectType, Long objectId, Long userId) { + return selectList(new LambdaQueryWrapperX() + .eq(UserObjectRoleDO::getObjectType, objectType) + .eq(UserObjectRoleDO::getObjectId, objectId) + .eq(UserObjectRoleDO::getUserId, userId) + .eq(UserObjectRoleDO::getStatus, 0)); + } + default List selectListByIdsAndObject(List ids, String objectType, Long objectId) { if (ids == null || ids.isEmpty()) { return Collections.emptyList(); @@ -59,4 +73,42 @@ public interface UserObjectRoleMapper extends BaseMapperX { .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() + .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() + .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 selectListByUserIdsAndObjectType(Collection userIds, String objectType) { + if (userIds == null || userIds.isEmpty()) { + return Collections.emptyList(); + } + return selectList(new LambdaQueryWrapperX() + .in(UserObjectRoleDO::getUserId, userIds) + .eq(UserObjectRoleDO::getObjectType, objectType) + .eq(UserObjectRoleDO::getStatus, 0)); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductMapper.java index 0b54ee1..66e1a30 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/product/ProductMapper.java @@ -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 { - default PageResult selectPage(ProductPageReqVO reqVO) { - LambdaQueryWrapperX 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); } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectMapper.java index 6e86fde..a9f48a5 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/ProjectMapper.java @@ -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 { - default PageResult selectPage(ProjectPageReqVO reqVO) { - LambdaQueryWrapperX 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); } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java index 8bcb7ec..7d63bbb 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/project/task/ProjectTaskMapper.java @@ -125,28 +125,40 @@ public interface ProjectTaskMapper extends BaseMapperX { * 取直接子任务的 progressRate 列表(用于父任务进度简单平均汇总)。 * 只读取必要字段,避免拉回整行;逻辑删除的子任务由 BaseMapper 自动过滤。 */ - default List selectChildrenProgressByParentTaskId(Long parentTaskId) { + default List selectChildrenProgressByParentTaskId(Long parentTaskId, + Collection excludedStatusCodes) { if (parentTaskId == null) { return Collections.emptyList(); } - return selectList(new LambdaQueryWrapperX() - .select(ProjectTaskDO::getId, ProjectTaskDO::getProgressRate) - .eq(ProjectTaskDO::getParentTaskId, parentTaskId)); + LambdaQueryWrapperX 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(""" + """) BigDecimal selectRootTaskAvgProgressByExecutionId(@Param("projectId") Long projectId, - @Param("executionId") Long executionId); + @Param("executionId") Long executionId, + @Param("excludedStatusCodes") Collection excludedStatusCodes); /** * 执行分页进度:按当前页 executionId 批量聚合一级任务 progressRate,避免列表 N+1。 @@ -160,12 +172,70 @@ public interface ProjectTaskMapper extends BaseMapperX { AND execution_id IN #{id} AND parent_task_id IS NULL + + AND status_code NOT IN + #{statusCode} + GROUP BY execution_id """) List> selectRootTaskAvgProgressGroupByExecutionIds( @Param("projectId") Long projectId, - @Param("executionIds") Collection executionIds); + @Param("executionIds") Collection executionIds, + @Param("excludedStatusCodes") Collection excludedStatusCodes); + + /** + * 执行详情完成态聚合:返回根任务总数与"已完成"数,用于 execution complete 按钮判定。 + * 与 selectRootTaskAvgProgressByExecutionId 共用同一根任务筛选口径(execution_id + parent_task_id IS NULL + excludedStatusCodes)。 + * 业务侧判定 totals > 0 && totals == completedCount 即视为"根任务全部已完成",空集(totals = 0)按"不全部完成"处理。 + */ + @Select(""" + + """) + Map selectRootTaskCompletionStateByExecutionId(@Param("projectId") Long projectId, + @Param("executionId") Long executionId, + @Param("completedStatusCode") String completedStatusCode, + @Param("excludedStatusCodes") Collection excludedStatusCodes); + + /** + * 执行分页完成态批量聚合:按 executionId 一次性返回 (totals, completedCount),避免列表 N+1。 + * 筛选口径与 selectRootTaskCompletionStateByExecutionId 同源。 + */ + @Select(""" + + """) + List> selectRootTaskCompletionStateGroupByExecutionIds( + @Param("projectId") Long projectId, + @Param("executionIds") Collection executionIds, + @Param("completedStatusCode") String completedStatusCode, + @Param("excludedStatusCodes") Collection excludedStatusCodes); /** * 仅更新单个任务的 progressRate,不动其他字段(避免污染 lastStatusReason 等)。 diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/status/ObjectStatusModelMapper.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/status/ObjectStatusModelMapper.java index a8ff065..3341c91 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/status/ObjectStatusModelMapper.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/dal/mysql/status/ObjectStatusModelMapper.java @@ -57,4 +57,17 @@ public interface ObjectStatusModelMapper extends BaseMapperX selectProgressExcludedStatusCodesByObjectTypeEnabled(String objectType) { + return selectList(new LambdaQueryWrapperX() + .eq(ObjectStatusModelDO::getObjectType, objectType) + .eq(ObjectStatusModelDO::getStatus, 0) + .eq(ObjectStatusModelDO::getProgressExcludedFlag, true)) + .stream() + .map(ObjectStatusModelDO::getStatusCode) + .collect(Collectors.toList()); + } + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/rpc/config/RpcConfiguration.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/rpc/config/RpcConfiguration.java index adc0b7e..a750fdd 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/rpc/config/RpcConfiguration.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/rpc/config/RpcConfiguration.java @@ -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 { } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionService.java index 2227974..9941621 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionService.java @@ -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 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); } } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionService.java index cf2b5e2..2d9ee43 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionService.java @@ -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 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); } } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/datascope/ObjectDataScope.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/datascope/ObjectDataScope.java new file mode 100644 index 0000000..fd7d6ed --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/datascope/ObjectDataScope.java @@ -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 ids; // 仅 ID_LIST 时有值 + private final Set directionCodes; // 仅 ID_LIST 时有值 + + private ObjectDataScope(State state, Set ids, Set 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 ids, Set 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; + } + } +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/datascope/ObjectDataScopeService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/datascope/ObjectDataScopeService.java new file mode 100644 index 0000000..d36b845 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/datascope/ObjectDataScopeService.java @@ -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); +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/datascope/ObjectDataScopeServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/datascope/ObjectDataScopeServiceImpl.java new file mode 100644 index 0000000..4c3461c --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/datascope/ObjectDataScopeServiceImpl.java @@ -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 ids = new HashSet<>(); + Set 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 computeChannel1(Long userId, String objectType) { + return userObjectRoleMapper.selectActiveListByObjectTypeAndUserId(objectType, userId).stream() + .map(UserObjectRoleDO::getObjectId) + .collect(Collectors.toSet()); + } + + /** + * 通道 2:组织负责人反推。 + * 通过 OrgLeaderApi 拿到当前用户作为负责人可覆盖的下属 userId 集合, + * 再查这批下属参与了哪些同类型对象,合并进 ids。 + */ + Set computeChannel2(Long userId, String objectType) { + Set reachableUserIds = orgLeaderApi.getReachableUserIds(userId).getCheckedData(); + if (CollUtil.isEmpty(reachableUserIds)) { + return Set.of(); + } + return userObjectRoleMapper.selectListByUserIdsAndObjectType(reachableUserIds, objectType).stream() + .map(UserObjectRoleDO::getObjectId) + .collect(Collectors.toSet()); + } +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/member/ObjectRoleAutoAssignService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/member/ObjectRoleAutoAssignService.java new file mode 100644 index 0000000..3d23bc8 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/member/ObjectRoleAutoAssignService.java @@ -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 watcherUserIds, String watcherRoleCode); + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/member/ObjectRoleAutoAssignServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/member/ObjectRoleAutoAssignServiceImpl.java new file mode 100644 index 0000000..2254fad --- /dev/null +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/member/ObjectRoleAutoAssignServiceImpl.java @@ -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 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); + } + } + +} diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImpl.java index 99753ec..e4d6598 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImpl.java @@ -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,24 +67,78 @@ public class ProductMemberServiceImpl implements ProductMemberService { List members = userObjectRoleMapper.selectListByObject(ProductObjectConstants.OBJECT_TYPE, productId); Map roleMap = getRoleMap(members.stream().map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet())); Map userMap = getUserMap(members.stream().map(UserObjectRoleDO::getUserId).collect(Collectors.toSet())); - return members.stream().map(member -> { - ProductMemberRespVO respVO = new ProductMemberRespVO(); - respVO.setId(member.getId()); - respVO.setUserId(member.getUserId()); - AdminUserRespDTO user = userMap.get(member.getUserId()); - respVO.setUserNickname(user == null ? null : user.getNickname()); - respVO.setRoleId(member.getRoleId()); - ObjectRoleRespDTO role = roleMap.get(member.getRoleId()); - respVO.setRoleName(role == null ? null : role.getName()); - respVO.setRoleCode(role == null ? null : role.getCode()); - respVO.setManagerFlag(Objects.equals(member.getUserId(), product.getManagerUserId()) - && Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)); - respVO.setStatus(member.getStatus()); - respVO.setJoinedTime(member.getJoinedTime()); - respVO.setLeftTime(member.getLeftTime()); - respVO.setRemark(member.getRemark()); - return respVO; - }).collect(Collectors.toList()); + + // 拆分 ACTIVE / INACTIVE: + // - ACTIVE 行按 userId 聚合,同人多角色合并成一行(manager 优先做主),非主角色名放 additionalRoleNames + // - INACTIVE 行保持独立(历史角色行各自留痕,便于审计) + List activeRows = new ArrayList<>(); + List inactiveRows = new ArrayList<>(); + for (UserObjectRoleDO m : members) { + if (Objects.equals(m.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) { + activeRows.add(m); + } else { + inactiveRows.add(m); + } + } + Map> activeByUser = activeRows.stream() + .collect(Collectors.groupingBy(UserObjectRoleDO::getUserId, LinkedHashMap::new, Collectors.toList())); + + List result = new ArrayList<>(); + activeByUser.forEach((userId, rows) -> { + UserObjectRoleDO primary = pickPrimaryRow(rows, roleMap); + List 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 rows, Map 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 roleMap, + Map userMap, + ProductDO product, + List additionalRoleNames) { + ProductMemberRespVO respVO = new ProductMemberRespVO(); + respVO.setId(member.getId()); + respVO.setUserId(member.getUserId()); + AdminUserRespDTO user = userMap.get(member.getUserId()); + respVO.setUserNickname(user == null ? null : user.getNickname()); + respVO.setRoleId(member.getRoleId()); + ObjectRoleRespDTO role = roleMap.get(member.getRoleId()); + respVO.setRoleName(role == null ? null : role.getName()); + respVO.setRoleCode(role == null ? null : role.getCode()); + respVO.setManagerFlag(Objects.equals(member.getUserId(), product.getManagerUserId()) + && Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)); + respVO.setStatus(member.getStatus()); + respVO.setJoinedTime(member.getJoinedTime()); + respVO.setLeftTime(member.getLeftTime()); + respVO.setRemark(member.getRemark()); + respVO.setAdditionalRoleNames(additionalRoleNames); + return respVO; } @Override @@ -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; - } + 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()); } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java index 78b0e2a..4addaf8 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/product/ProductServiceImpl.java @@ -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 userRoles = userObjectRoleMapper + .selectActiveListByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, id, loginUserId); + if (!userRoles.isEmpty()) { + List 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 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 menus = permissionDetail == null || permissionDetail.getMenus() == null ? Collections.emptyList() : permissionDetail.getMenus(); - respVO.setCurrentRole(buildCurrentRole(currentMember, currentRole)); + List 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 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 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 ids = scope.getIds(); + Set 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> 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> buildStatusCountRows(ObjectDataScope scope) { + if (scope.getState() == ObjectDataScope.State.EMPTY) { + return Collections.emptyList(); + } + if (scope.getState() == ObjectDataScope.State.ALL) { + return productMapper.selectStatusCountList(); + } + // ID_LIST + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX<>(); + wrapper.select(ProductDO::getStatusCode); + Set ids = scope.getIds(); + Set 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 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 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; } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectMemberServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectMemberServiceImpl.java index c268bce..818c6b8 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectMemberServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectMemberServiceImpl.java @@ -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,24 +71,78 @@ public class ProjectMemberServiceImpl implements ProjectMemberService { List members = userObjectRoleMapper.selectListByObject(ProjectObjectConstants.OBJECT_TYPE, projectId); Map roleMap = getRoleMap(members.stream().map(UserObjectRoleDO::getRoleId).collect(Collectors.toSet())); Map userMap = getUserMap(members.stream().map(UserObjectRoleDO::getUserId).collect(Collectors.toSet())); - return members.stream().map(member -> { - ProjectMemberRespVO respVO = new ProjectMemberRespVO(); - respVO.setId(member.getId()); - respVO.setUserId(member.getUserId()); - AdminUserRespDTO user = userMap.get(member.getUserId()); - respVO.setUserNickname(user == null ? null : user.getNickname()); - respVO.setRoleId(member.getRoleId()); - ObjectRoleRespDTO role = roleMap.get(member.getRoleId()); - respVO.setRoleName(role == null ? null : role.getName()); - respVO.setRoleCode(role == null ? null : role.getCode()); - respVO.setManagerFlag(Objects.equals(member.getUserId(), project.getManagerUserId()) - && Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)); - respVO.setStatus(member.getStatus()); - respVO.setJoinedTime(member.getJoinedTime()); - respVO.setLeftTime(member.getLeftTime()); - respVO.setRemark(member.getRemark()); - return respVO; - }).collect(Collectors.toList()); + + // 拆分 ACTIVE / INACTIVE: + // - ACTIVE 行按 userId 聚合,同人多角色合并成一行(manager 优先做主),非主角色名放 additionalRoleNames + // - INACTIVE 行保持独立(历史角色行各自留痕,便于审计) + List activeRows = new ArrayList<>(); + List inactiveRows = new ArrayList<>(); + for (UserObjectRoleDO m : members) { + if (Objects.equals(m.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)) { + activeRows.add(m); + } else { + inactiveRows.add(m); + } + } + Map> activeByUser = activeRows.stream() + .collect(Collectors.groupingBy(UserObjectRoleDO::getUserId, LinkedHashMap::new, Collectors.toList())); + + List result = new ArrayList<>(); + activeByUser.forEach((userId, rows) -> { + UserObjectRoleDO primary = pickPrimaryRow(rows, roleMap); + List 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 rows, Map 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 roleMap, + Map userMap, + ProjectDO project, + List additionalRoleNames) { + ProjectMemberRespVO respVO = new ProjectMemberRespVO(); + respVO.setId(member.getId()); + respVO.setUserId(member.getUserId()); + AdminUserRespDTO user = userMap.get(member.getUserId()); + respVO.setUserNickname(user == null ? null : user.getNickname()); + respVO.setRoleId(member.getRoleId()); + ObjectRoleRespDTO role = roleMap.get(member.getRoleId()); + respVO.setRoleName(role == null ? null : role.getName()); + respVO.setRoleCode(role == null ? null : role.getCode()); + respVO.setManagerFlag(Objects.equals(member.getUserId(), project.getManagerUserId()) + && Objects.equals(member.getStatus(), ObjectRoleConstants.MEMBER_STATUS_ACTIVE)); + respVO.setStatus(member.getStatus()); + respVO.setJoinedTime(member.getJoinedTime()); + respVO.setLeftTime(member.getLeftTime()); + respVO.setRemark(member.getRemark()); + respVO.setAdditionalRoleNames(additionalRoleNames); + return respVO; } @Override @@ -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; - } + 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()); } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java index 7bf6d7a..aa75552 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectServiceImpl.java @@ -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 userRoles = userObjectRoleMapper + .selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, id, loginUserId); + if (!userRoles.isEmpty()) { + List 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, - ProjectObjectConstants.OBJECT_TYPE) - .getCheckedData(); - if (visitorRole == null || visitorRole.getId() == null) { - return buildProjectContextWithoutMenus(project, true); + // 显式角色为空:走 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 buildProjectContext(project, visitorRole.getId(), true, visitorRole); + return buildImplicitObserverContext(project); } - private ProjectContextRespVO buildProjectContext(ProjectDO project, Long roleId, boolean guestFlag, + /** + * 隐式 observer 兜底上下文:用户无显式项目角色但在 scope 范围内,按 implicit_observer_project 角色渲染菜单/权限。 + */ + private ProjectContextRespVO buildImplicitObserverContext(ProjectDO project) { + ObjectRoleRespDTO observerRole = objectPermissionApi + .getObjectRoleByCode( + com.njcn.rdms.module.system.enums.permission.ObjectRoleConstants.IMPLICIT_OBSERVER_PROJECT.getCode(), + ObjectRoleConstants.ROLE_SCOPE_OBJECT, + ProjectObjectConstants.OBJECT_TYPE) + .getCheckedData(); + if (observerRole == null || observerRole.getId() == null) { + // 角色未配置,降级为无菜单的 guest 上下文(不应发生,属配置缺失) + return buildProjectContextWithoutMenus(project, true); + } + return buildProjectContext(project, List.of(observerRole.getId()), true, observerRole); + } + + private ProjectContextRespVO buildProjectContext(ProjectDO project, List 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 menus = permissionDetail == null || permissionDetail.getMenus() == null ? Collections.emptyList() : permissionDetail.getMenus(); - respVO.setCurrentRole(buildCurrentRole(roleId, currentRole, guestFlag)); + List 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 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 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 ids = scope.getIds(); + Set 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> 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> buildStatusCountRows(ObjectDataScope scope) { + if (scope.getState() == ObjectDataScope.State.EMPTY) { + return Collections.emptyList(); + } + if (scope.getState() == ObjectDataScope.State.ALL) { + return projectMapper.selectStatusCountList(); + } + // ID_LIST + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX<>(); + wrapper.select(ProjectDO::getStatusCode); + Set ids = scope.getIds(); + Set 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 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 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; } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardService.java index e126906..1bc418a 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardService.java @@ -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); + /** + * 看板视图任务分页:一次请求返回若干状态列,每列附带当前页切片 + 该列总数。 + *

statusCode 缺省=按状态字典返回全部列(空列也回,list=[]、total=0);传若干个=只返回这些状态的列, + * 字典外的值静默忽略。pageNo / pageSize 应用到返回的所有列(每列各自分页但页码统一)。 + *

list 元素结构与 /tasks/page 完全一致(共享 {@code assembleTaskRespVOPage} 装配方法)。 + */ + ProjectTaskBoardPageRespVO getTaskBoardPage(Long projectId, Long executionId, ProjectTaskBoardPageReqVO reqVO); + } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardServiceImpl.java index e4fdb8d..19eecc5 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/ProjectStatusBoardServiceImpl.java @@ -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 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 statusModels = objectStatusModelMapper + .selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE); + + // 列选择:入参为空 → 全集;非空 → 与字典做交集(字典外 statusCode 静默忽略) + Set selected = collectSelectedStatusCodes(reqVO.getStatusCode()); + List targetStatusModels = selected.isEmpty() + ? statusModels + : statusModels.stream() + .filter(model -> selected.contains(model.getStatusCode())) + .collect(Collectors.toList()); + + ProjectTaskBoardPageRespVO respVO = new ProjectTaskBoardPageRespVO(); + List 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 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 doPage = projectTaskMapper.selectPageByExecutionId(projectId, executionId, scope, innerReq); + PageResult 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, diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionAssigneeServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionAssigneeServiceImpl.java index 1b38064..3cf7f4a 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionAssigneeServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionAssigneeServiceImpl.java @@ -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); } } diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java index 7215877..a15ea8f 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImpl.java @@ -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 rootTasksAllCompletedMap = loadExecutionRootTasksAllCompletedMap(projectId, list); // 批量补负责人昵称,避免 N+1 Set 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 excludedStatusCodes = loadProgressExcludedTaskStatusCodes(); + return normalizeProgress(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId, + excludedStatusCodes)); } private void fillExecutionProgress(Long projectId, List list) { @@ -698,7 +707,8 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService { .map(ProjectExecutionRespVO::getId) .filter(Objects::nonNull) .collect(Collectors.toCollection(LinkedHashSet::new)); - Map progressMap = loadExecutionProgressMap(projectId, executionIds); + List excludedStatusCodes = loadProgressExcludedTaskStatusCodes(); + Map 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 loadExecutionProgressMap(Long projectId, Collection executionIds) { + private Map loadExecutionProgressMap(Long projectId, Collection executionIds, + Collection excludedStatusCodes) { if (executionIds == null || executionIds.isEmpty()) { return Collections.emptyMap(); } List> 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 loadProgressExcludedTaskStatusCodes() { + List statusCodes = objectStatusModelMapper + .selectProgressExcludedStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE); + return statusCodes == null ? Collections.emptyList() : statusCodes; + } + + /** + * 执行详情完成态:判断"参与聚合的根任务"是否全部为 completed。 + * 筛选口径与 loadExecutionProgress 同源;空集(无参与聚合的根任务)返回 false,禁止下发 complete。 + */ + private boolean loadExecutionRootTasksAllCompleted(Long projectId, Long executionId) { + List excludedStatusCodes = loadProgressExcludedTaskStatusCodes(); + Map row = projectTaskMapper.selectRootTaskCompletionStateByExecutionId(projectId, executionId, + ProjectTaskConstants.STATUS_COMPLETED, excludedStatusCodes); + return isRootTasksAllCompleted(row); + } + + /** + * 列表口径批量聚合:一次 GROUP BY 取本页所有执行的根任务完成态。 + * 未命中的 executionId(执行下无参与聚合的根任务)不入 map,由调用方按缺省 false 处理(不下发 complete)。 + */ + private Map loadExecutionRootTasksAllCompletedMap(Long projectId, + List list) { + Set executionIds = list.stream() + .map(ProjectExecutionRespVO::getId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (executionIds.isEmpty()) { + return Collections.emptyMap(); + } + List excludedStatusCodes = loadProgressExcludedTaskStatusCodes(); + List> rows = projectTaskMapper.selectRootTaskCompletionStateGroupByExecutionIds( + projectId, executionIds, ProjectTaskConstants.STATUS_COMPLETED, excludedStatusCodes); + if (rows == null || rows.isEmpty()) { + return Collections.emptyMap(); + } + Map result = new HashMap<>(rows.size()); + for (Map 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 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(); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionStatusViewService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionStatusViewService.java index 6a17a14..a440070 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionStatusViewService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionStatusViewService.java @@ -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 *

  • 剔除系统级动作 {@code auto_start}(不出现在 UI 按钮)。
  • *
  • 对 owner-only 状态推进动作(complete / cancel / pause / resume),按 {@code execution.ownerId == currentUserId} 字段硬卡过滤, * 与 {@link ProjectExecutionServiceImpl} 中 {@code validateOwnerForAction} 同款判定。
  • + *
  • 对 {@code complete} 动作叠加进度过滤,执行进度未达到 100 时不下发完成按钮。
  • + *
  • 对 {@code complete} 动作再叠加根任务完成态过滤:要求该执行下"参与聚合的根任务"全部为 completed + * 才下发;筛选口径与进度聚合同源(execution_id + parent_task_id IS NULL + excludedStatusCodes), + * 空集(无参与聚合的根任务)视为"未全部完成",不下发完成按钮,避免任务仍在进行中时执行被闭环。
  • * * 非状态动作(delete / change-owner / update / assignee)的权限码 / 字段过滤未纳入本字段, * 前端按各动作对应权限码与 owner 字段独立判断(spec §6.5 允许条件矩阵); @@ -39,13 +44,16 @@ public class ProjectExecutionStatusViewService { * 状态推进动作中要求 owner-only 字段硬卡的集合,与 ProjectExecutionServiceImpl#validateOwnerForAction 一致。 */ private static final Set 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 buildAvailableActions(String statusCode, Long ownerId) { + private List buildAvailableActions(String statusCode, Long ownerId, + BigDecimal progressRate, + boolean rootTasksAllCompleted) { List 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, diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java index 5aa52e4..dd990fd 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskService.java @@ -31,6 +31,14 @@ public interface ProjectTaskService { */ PageResult getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO); + /** + * 把任务 DO 分页结果整体装配成 RespVO 分页结果。 + *

    提供给"看板分页接口"复用同款装配口径,保证两个接口列元素结构 / 序列化完全一致; + * 看板分页内部按状态列循环时,应调用本方法装配每列,不要自行重复装配。 + */ + PageResult assembleTaskRespVOPage(Long projectId, Long executionId, + PageResult doPage); + void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO); /** diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java index f8d58f3..45aae55 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImpl.java @@ -402,6 +402,17 @@ public class ProjectTaskServiceImpl implements ProjectTaskService { @Override public PageResult getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) { PageResult pageResult = getTaskPage(projectId, executionId, reqVO); + return assembleTaskRespVOPage(projectId, executionId, pageResult); + } + + /** + * 把 ProjectTaskDO 分页结果整体装配成 RespVO 分页结果(含 ownerNickname / assignees / 工时合计 / 父任务 owner / 执行 owner / 生命周期)。 + *

    提供给 /tasks/page 与 /tasks/board-page 共用,保证两个接口的列元素结构与序列化口径完全一致; + * /tasks/board-page 不应自行重复装配,避免字段演进时漂移。 + */ + @Override + public PageResult assembleTaskRespVOPage(Long projectId, Long executionId, + PageResult pageResult) { PageResult voPageResult = BeanUtils.toBean(pageResult, ProjectTaskRespVO.class); List 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 excludedStatusCodes = loadProgressExcludedTaskStatusCodes(); while (current != null) { ProjectTaskDO parent = projectTaskMapper.selectById(current); if (parent == null) { return; } - List children = projectTaskMapper.selectChildrenProgressByParentTaskId(current); - if (children.isEmpty()) { - return; - } + List 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 loadProgressExcludedTaskStatusCodes() { + List 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()); diff --git a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewService.java b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewService.java index df160da..c1db60b 100644 --- a/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewService.java +++ b/rdms-project/rdms-project-boot/src/main/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewService.java @@ -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 *

  • 剔除系统级动作 {@code auto_start}(由工时填报内部触发,不出现在 UI 按钮)。
  • *
  • 对 owner-only 状态推进动作(complete / cancel / pause / resume),按 {@code task.ownerId == currentUserId} 字段硬卡过滤, * 与 {@link ProjectTaskServiceImpl#validateOwnerForAction} 同款判定。
  • + *
  • 对 {@code complete} 动作叠加进度过滤,任务进度未达到 100 时不下发完成按钮。
  • * * 非状态动作(delete / 加协办人 / 工时填报等)的权限码 / 字段过滤未纳入本字段,前端按各动作对应权限码与 owner 字段独立判断; * 全量化纳入 {@code availableActions} 留待后续阶段统一改造。 @@ -38,13 +40,15 @@ public class ProjectTaskStatusViewService { * 状态推进动作中要求 owner-only 字段硬卡的集合,与 ProjectTaskServiceImpl#validateOwnerForAction 一致。 */ private static final Set 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 buildAvailableActions(String statusCode, Long ownerId) { + private List buildAvailableActions(String statusCode, Long ownerId, + BigDecimal progressRate) { List 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, diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionServiceTest.java index 5d072ab..53005ff 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionServiceTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProductObjectPermissionServiceTest.java @@ -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 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 mockedStatic = mockLoginUser(loginUserId)) { ServiceException ex = assertThrows(ServiceException.class, diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionServiceTest.java index 6c5e4eb..fe8d419 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionServiceTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/framework/security/service/ProjectObjectPermissionServiceTest.java @@ -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 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 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"))); diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/datascope/ObjectDataScopeServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/datascope/ObjectDataScopeServiceImplTest.java new file mode 100644 index 0000000..932bcd5 --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/datascope/ObjectDataScopeServiceImplTest.java @@ -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 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); + } +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/datascope/ObjectDataScopeTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/datascope/ObjectDataScopeTest.java new file mode 100644 index 0000000..afb3f0f --- /dev/null +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/datascope/ObjectDataScopeTest.java @@ -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")); + } +} diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImplTest.java index 22745b3..5cef0c9 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductMemberServiceImplTest.java @@ -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 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 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()); + } + } diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductServiceImplTest.java index 1599928..6d49d15 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/product/ProductServiceImplTest.java @@ -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 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 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 mocked = mockStatic(SecurityFrameworkUtils.class)) { + mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L); + when(objectDataScopeService.compute(1L, "product")).thenReturn(ObjectDataScope.empty()); + + PageResult result = productService.getProductPage(new ProductPageReqVO()); + + assertThat(result.getList()).isEmpty(); + verify(productMapper, never()).selectPage(any(ProductPageReqVO.class), any()); + } + } + + @Test + void getProductPage_passesIdListIntoSql_whenScopeIsIdList() { + try (MockedStatic 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 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(); diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectMemberServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectMemberServiceImplTest.java index 5428395..743273a 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectMemberServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectMemberServiceImplTest.java @@ -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 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 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()); + } + } diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java index 15f3001..f9a5991 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/ProjectServiceImplTest.java @@ -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 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 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 mocked = mockStatic(SecurityFrameworkUtils.class)) { + mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1L); + when(objectDataScopeService.compute(1L, "project")).thenReturn(ObjectDataScope.empty()); + + PageResult result = projectService.getProjectPage(new ProjectPageReqVO()); + + assertThat(result.getList()).isEmpty(); + verify(projectMapper, never()).selectPage(any(ProjectPageReqVO.class), any()); + } + } + + @Test + void getProjectPage_passesIdListIntoSql_whenScopeIsIdList() { + try (MockedStatic 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 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; diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionAssigneeServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionAssigneeServiceImplTest.java index 8c24570..0461d32 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionAssigneeServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionAssigneeServiceImplTest.java @@ -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)); diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java index 31add4d..825a57e 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionServiceImplTest.java @@ -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 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> 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 result = projectExecutionService.getExecutionRespVOPage(projectId, reqVO); diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionStatusViewServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionStatusViewServiceTest.java index 73ea60a..bee2ce4 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionStatusViewServiceTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/execution/ProjectExecutionStatusViewServiceTest.java @@ -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 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 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 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()); } diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImplTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImplTest.java index 235e3ad..c31eef1 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImplTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskServiceImplTest.java @@ -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 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 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 mockLoginUser(Long loginUserId) { + MockedStatic mockedStatic = mockStatic(SecurityFrameworkUtils.class); + mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId); + return mockedStatic; + } + } diff --git a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewServiceTest.java b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewServiceTest.java index 123dc5c..bd56c4d 100644 --- a/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewServiceTest.java +++ b/rdms-project/rdms-project-boot/src/test/java/com/njcn/rdms/module/project/service/project/task/ProjectTaskStatusViewServiceTest.java @@ -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 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 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()); } diff --git a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/dept/OrgLeaderApi.java b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/dept/OrgLeaderApi.java new file mode 100644 index 0000000..479c14b --- /dev/null +++ b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/dept/OrgLeaderApi.java @@ -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> getReachableUserIds(@RequestParam("currentUserId") Long currentUserId); +} diff --git a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/permission/ObjectPermissionApi.java b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/permission/ObjectPermissionApi.java index 8d87646..daf07bd 100644 --- a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/permission/ObjectPermissionApi.java +++ b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/permission/ObjectPermissionApi.java @@ -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 getObjectRolePermissionDetailMerged(@RequestParam("roleIds") Collection roleIds, + @RequestParam("scopeType") String scopeType, + @RequestParam("objectType") String objectType); + /** * 按角色 ID 返回对象作用域角色摘要映射,便于业务模块批量对齐本地成员数据。 * diff --git a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/permission/PermissionApi.java b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/permission/PermissionApi.java index f6571a9..e0dfd11 100644 --- a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/permission/PermissionApi.java +++ b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/permission/PermissionApi.java @@ -24,4 +24,8 @@ public interface PermissionApi extends PermissionCommonApi { @Parameter(name = "roleIds", description = "角色编号集合", example = "1,2", required = true) CommonResult> getUserRoleIdListByRoleIds(@RequestParam("roleIds") Collection roleIds); + @GetMapping(PREFIX + "/is-super-admin") + @Operation(summary = "判断用户是否超管") + CommonResult isSuperAdmin(@RequestParam("userId") Long userId); + } \ No newline at end of file diff --git a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/permission/UserVisibilityConfigApi.java b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/permission/UserVisibilityConfigApi.java new file mode 100644 index 0000000..45d1ddb --- /dev/null +++ b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/permission/UserVisibilityConfigApi.java @@ -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 getConfig(@RequestParam("userId") Long userId); +} diff --git a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/permission/dto/ObjectRolePermissionRespDTO.java b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/permission/dto/ObjectRolePermissionRespDTO.java index 3738c13..bdb0f53 100644 --- a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/permission/dto/ObjectRolePermissionRespDTO.java +++ b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/permission/dto/ObjectRolePermissionRespDTO.java @@ -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 permissions; + /** + * 非主角色的中文名列表(多角色场景)。单角色或无角色时为空数组。 + * 前端展示"创建者"等次要角色标签时读这个字段;权限判断仍按 currentRole.code。 + */ + @ArraySchema(schema = @Schema(description = "非主角色的中文名列表,多角色场景使用", example = "创建者")) + private List additionalRoleNames = Collections.emptyList(); + } diff --git a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/permission/dto/UserVisibilityConfigRespDTO.java b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/permission/dto/UserVisibilityConfigRespDTO.java new file mode 100644 index 0000000..9237cba --- /dev/null +++ b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/api/permission/dto/UserVisibilityConfigRespDTO.java @@ -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 directionCodes; + + @ArraySchema(schema = @Schema(description = "type=projects 时为项目 id 集合(保留位,当前业务不消费)", example = "1")) + private Set projectIds; +} diff --git a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/ErrorCodeConstants.java b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/ErrorCodeConstants.java index 7841cb8..4064126 100644 --- a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/ErrorCodeConstants.java +++ b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/ErrorCodeConstants.java @@ -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={}, 详情:{}"); + } diff --git a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/permission/ObjectRoleConstants.java b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/permission/ObjectRoleConstants.java new file mode 100644 index 0000000..def299e --- /dev/null +++ b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/permission/ObjectRoleConstants.java @@ -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(); + } + +} diff --git a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/permission/RoleCodeEnum.java b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/permission/RoleCodeEnum.java index 56791d7..a4899e7 100644 --- a/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/permission/RoleCodeEnum.java +++ b/rdms-system/rdms-system-api/src/main/java/com/njcn/rdms/module/system/enums/permission/RoleCodeEnum.java @@ -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()); } } diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/api/dept/OrgLeaderApiImpl.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/api/dept/OrgLeaderApiImpl.java new file mode 100644 index 0000000..64f5a06 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/api/dept/OrgLeaderApiImpl.java @@ -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> getReachableUserIds(Long currentUserId) { + // 1. 当前用户作为 leader 生效中的 dept_id 集合 + Set leaderDeptIds = orgLeaderRelationService.listEffectiveDeptIdsByUserId(currentUserId); + if (CollUtil.isEmpty(leaderDeptIds)) { + return success(Collections.emptySet()); + } + + // 2. 含递归子孙节点的 dept_id 集合(按 path 前缀匹配,一次 SQL 完成) + Set allDeptIds = deptService.listDescendantDeptIds(leaderDeptIds); + + // 3. 这些 dept 下启用且未离职的 user_id 集合 + Set userIds = adminUserService.listEnabledUserIdsByDeptIds(allDeptIds); + + // 4. 移除自己(自己看自己走通道 1,不在本结果集里) + userIds.remove(currentUserId); + return success(userIds); + } + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/api/permission/ObjectPermissionApiImpl.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/api/permission/ObjectPermissionApiImpl.java index b926f57..daacdc2 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/api/permission/ObjectPermissionApiImpl.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/api/permission/ObjectPermissionApiImpl.java @@ -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 getObjectRolePermissionDetailMerged( + Collection roleIds, String scopeType, String objectType) { + if (roleIds == null || roleIds.isEmpty()) { + return success(emptyPermissionDetail()); + } + + // 拿全部启用的角色,过滤 null 和未启用 + List activeRoles = roleIds.stream() + .distinct() + .map(id -> getEnabledScopedRole(id, scopeType, objectType)) + .filter(r -> r != null) + .toList(); + if (activeRoles.isEmpty()) { + return success(emptyPermissionDetail()); + } + + // 主角色:按 sort 升序,决胜按 id 升序 + Comparator 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 additionalRoleNames = activeRoles.stream() + .filter(r -> !r.getId().equals(primaryRole.getId())) + .sorted(rolePriority) + .map(RoleDO::getName) + .toList(); + + // 菜单 union(按 menu.id 去重,按 menu.sort 排序) + Map mergedMenus = new LinkedHashMap<>(); + for (RoleDO role : activeRoles) { + for (MenuDO menu : permissionService.getScopedMenusByRoleId(role.getId(), scopeType, objectType)) { + mergedMenus.putIfAbsent(menu.getId(), menu); + } + } + List menus = mergedMenus.values().stream() + .sorted(Comparator.comparingInt(m -> m.getSort() == null ? Integer.MAX_VALUE : m.getSort())) + .map(this::convertMenu) + .toList(); + + // 权限码 union(LinkedHashSet 保持稳定顺序) + Set 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); diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/api/permission/PermissionApiImpl.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/api/permission/PermissionApiImpl.java index 4d978a7..bbb9f75 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/api/permission/PermissionApiImpl.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/api/permission/PermissionApiImpl.java @@ -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> getUserRoleIdListByRoleIds(Collection roleIds) { return success(permissionService.getUserRoleIdListByRoleId(roleIds)); @@ -37,5 +41,14 @@ public class PermissionApiImpl implements PermissionApi { return success(permissionService.hasAnyRoles(userId, roles)); } + @Override + public CommonResult isSuperAdmin(Long userId) { + Set roleIds = permissionService.getUserRoleIdListByUserId(userId); + if (roleIds.isEmpty()) { + return success(false); + } + return success(roleService.hasAnySuperAdmin(roleIds)); + } + } diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/api/permission/UserVisibilityConfigApiImpl.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/api/permission/UserVisibilityConfigApiImpl.java new file mode 100644 index 0000000..f7f88a2 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/api/permission/UserVisibilityConfigApiImpl.java @@ -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 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); + } + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/permission/UserVisibilityConfigController.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/permission/UserVisibilityConfigController.java new file mode 100644 index 0000000..b92bde5 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/permission/UserVisibilityConfigController.java @@ -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 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 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 deleteUserVisibilityConfig(@RequestParam("userId") Long userId) { + userVisibilityConfigService.deleteByUserId(userId); + return success(true); + } + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/permission/vo/userVisibilityConfig/UserVisibilityConfigRespVO.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/permission/vo/userVisibilityConfig/UserVisibilityConfigRespVO.java new file mode 100644 index 0000000..68e1082 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/permission/vo/userVisibilityConfig/UserVisibilityConfigRespVO.java @@ -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 visibleDirectionIds; + + @Schema(description = "补充可见项目ID集合", example = "[1001, 1002]") + private List visibleProjectIds; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "创建时间") + private LocalDateTime createTime; + + @Schema(description = "更新时间") + private LocalDateTime updateTime; + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/permission/vo/userVisibilityConfig/UserVisibilityConfigSaveReqVO.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/permission/vo/userVisibilityConfig/UserVisibilityConfigSaveReqVO.java new file mode 100644 index 0000000..e05b91d --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/permission/vo/userVisibilityConfig/UserVisibilityConfigSaveReqVO.java @@ -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 visibleDirectionIds; + + @Schema(description = "补充可见项目ID集合(type=projects 时必填,业务暂不引导)", example = "[1001, 1002]") + private List visibleProjectIds; + + @Schema(description = "备注") + private String remark; + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/dataobject/permission/UserVisibilityConfigDO.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/dataobject/permission/UserVisibilityConfigDO.java new file mode 100644 index 0000000..548992d --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/dataobject/permission/UserVisibilityConfigDO.java @@ -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 visibleDirectionIds; + + /** + * 可见的项目 ID 列表(JSON 存储) + * + * visibilityType = "projects" 时有效,存储用户有权查看的项目 ID 集合。 + * 业务暂未消费,预留字段。 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List visibleProjectIds; + + /** + * 备注 + */ + private String remark; + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/dept/OrgLeaderRelationMapper.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/dept/OrgLeaderRelationMapper.java index efc94ca..48c62a2 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/dept/OrgLeaderRelationMapper.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/dept/OrgLeaderRelationMapper.java @@ -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 selectEffectiveListByUserId(Long userId, LocalDateTime now) { + return selectList(new LambdaQueryWrapperX() + .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))); + } + } diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/permission/UserVisibilityConfigMapper.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/permission/UserVisibilityConfigMapper.java new file mode 100644 index 0000000..c2aa049 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/permission/UserVisibilityConfigMapper.java @@ -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 { + + /** + * 按 user_id 查单条配置(唯一索引保证一人一条)。 + */ + default UserVisibilityConfigDO selectByUserId(Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(UserVisibilityConfigDO::getUserId, userId)); + } + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/framework/permission/ObjectRoleStartupValidator.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/framework/permission/ObjectRoleStartupValidator.java new file mode 100644 index 0000000..3bea08c --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/framework/permission/ObjectRoleStartupValidator.java @@ -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 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); + } + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/dept/DeptService.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/dept/DeptService.java index d395c8c..22dca8e 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/dept/DeptService.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/dept/DeptService.java @@ -108,6 +108,15 @@ public interface DeptService { */ Set getChildDeptIdListFromCache(Long id); + /** + * 获得指定部门集合及其所有子孙部门的 ID 集合。 + * 基于 system_dept.path 字段前缀匹配,一次 SQL 查询完成,避免递归。 + * + * @param rootDeptIds 根部门 ID 集合 + * @return 含根节点本身及所有子孙节点的 ID 集合 + */ + Set listDescendantDeptIds(Collection rootDeptIds); + /** * 校验部门们是否有效 * @@ -115,4 +124,15 @@ public interface DeptService { */ void validateDeptList(Collection ids); + /** + * 按 id 集合批量查询部门 code 集合。 + * + * code 为空(null / 空字符串)的记录会被过滤掉。 + * 用于 API 端将 directionId → directionCode 转换,避免业务侧再 join system_dept。 + * + * @param ids 部门 id 集合 + * @return 非空 code 的集合 + */ + Set listCodesByIds(Collection ids); + } diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/dept/DeptServiceImpl.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/dept/DeptServiceImpl.java index 69db374..9a87724 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/dept/DeptServiceImpl.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/dept/DeptServiceImpl.java @@ -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 listDescendantDeptIds(Collection rootDeptIds) { + if (CollUtil.isEmpty(rootDeptIds)) { + return Collections.emptySet(); + } + Set result = new HashSet<>(rootDeptIds); + // 逐个根节点按 path 前缀匹配子孙节点,避免递归查询 + for (Long rootId : rootDeptIds) { + DeptDO root = deptMapper.selectById(rootId); + if (root == null || StrUtil.isBlank(root.getPath())) { + continue; + } + List descendants = deptMapper.selectListByPathPrefix(root.getPath()); + descendants.forEach(d -> result.add(d.getId())); + } + return result; + } + + @Override + public Set listCodesByIds(Collection 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 ids) { if (CollUtil.isEmpty(ids)) { diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/dept/OrgLeaderRelationService.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/dept/OrgLeaderRelationService.java index 3bd566b..550e117 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/dept/OrgLeaderRelationService.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/dept/OrgLeaderRelationService.java @@ -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 getCandidateUsersByDeptId(Long deptId); + /** + * 查询指定用户当前生效的负责人关系所对应的 dept_id 集合 + * + * @param userId 用户 ID + * @return 当前生效的组织 ID 集合,无关系时返回空集 + */ + Set listEffectiveDeptIdsByUserId(Long userId); + } diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/dept/OrgLeaderRelationServiceImpl.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/dept/OrgLeaderRelationServiceImpl.java index 635309d..090fbdc 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/dept/OrgLeaderRelationServiceImpl.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/dept/OrgLeaderRelationServiceImpl.java @@ -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 listEffectiveDeptIdsByUserId(Long userId) { + List relations = orgLeaderRelationMapper.selectEffectiveListByUserId(userId, LocalDateTime.now()); + return convertSet(relations, OrgLeaderRelationDO::getDeptId); + } + @Override public List getCandidateUsersByDeptId(Long deptId) { validateDeptExists(deptId); diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/permission/UserVisibilityConfigService.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/permission/UserVisibilityConfigService.java new file mode 100644 index 0000000..9994496 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/permission/UserVisibilityConfigService.java @@ -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); + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/permission/UserVisibilityConfigServiceImpl.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/permission/UserVisibilityConfigServiceImpl.java new file mode 100644 index 0000000..fb5bab8 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/permission/UserVisibilityConfigServiceImpl.java @@ -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"); + } + } + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/user/AdminUserService.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/user/AdminUserService.java index e4d0e43..b69ec22 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/user/AdminUserService.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/user/AdminUserService.java @@ -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 getAllUserByDeptId(Long deptId); + + /** + * 获得指定部门集合下启用且未离职的用户 ID 集合 + * + * @param deptIds 部门 ID 集合 + * @return 可用用户 ID 集合 + */ + Set listEnabledUserIdsByDeptIds(Collection deptIds); } diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/user/AdminUserServiceImpl.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/user/AdminUserServiceImpl.java index f451684..a708dca 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/user/AdminUserServiceImpl.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/user/AdminUserServiceImpl.java @@ -496,6 +496,15 @@ public class AdminUserServiceImpl implements AdminUserService { && !isUserResigned(user); } + @Override + public Set listEnabledUserIdsByDeptIds(Collection deptIds) { + List users = getUserListByDeptIds(deptIds); + return users.stream() + .filter(this::isUserAvailable) + .map(AdminUserDO::getId) + .collect(java.util.stream.Collectors.toSet()); + } + @Override public List getAllUserByDeptId(Long deptId) { Set deptCondition = getDeptCondition(deptId); diff --git a/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/api/dept/OrgLeaderApiImplTest.java b/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/api/dept/OrgLeaderApiImplTest.java new file mode 100644 index 0000000..fbdf7e6 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/api/dept/OrgLeaderApiImplTest.java @@ -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> 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> 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> 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> result = orgLeaderApi.getReachableUserIds(currentUserId); + + assertFalse(result.getCheckedData().contains(currentUserId), "结果集不能包含 currentUserId 自身"); + assertTrue(result.getCheckedData().contains(otherId)); + } + +} diff --git a/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/api/permission/PermissionApiImplTest.java b/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/api/permission/PermissionApiImplTest.java new file mode 100644 index 0000000..63c7550 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/api/permission/PermissionApiImplTest.java @@ -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 roleIds = Set.of(100L); + when(permissionService.getUserRoleIdListByUserId(userId)).thenReturn(roleIds); + when(roleService.hasAnySuperAdmin(roleIds)).thenReturn(true); + + CommonResult result = permissionApi.isSuperAdmin(userId); + + assertTrue(result.getCheckedData()); + } + + @Test + void isSuperAdmin_returnsFalse_otherwise() { + Long userId = 2L; + Set roleIds = Set.of(200L); + when(permissionService.getUserRoleIdListByUserId(userId)).thenReturn(roleIds); + when(roleService.hasAnySuperAdmin(roleIds)).thenReturn(false); + + CommonResult result = permissionApi.isSuperAdmin(userId); + + assertFalse(result.getCheckedData()); + } + +} diff --git a/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/api/permission/UserVisibilityConfigApiImplTest.java b/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/api/permission/UserVisibilityConfigApiImplTest.java new file mode 100644 index 0000000..a94ce4d --- /dev/null +++ b/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/api/permission/UserVisibilityConfigApiImplTest.java @@ -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 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 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 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 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 projectIds = List.of(201L, 202L); + UserVisibilityConfigDO cfg = buildDO(3L, "projects", null, projectIds); + when(userVisibilityConfigService.getByUserId(3L)).thenReturn(cfg); + + CommonResult 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 directionIds, + List projectIds) { + UserVisibilityConfigDO do_ = new UserVisibilityConfigDO(); + do_.setUserId(userId); + do_.setVisibilityType(type); + do_.setVisibleDirectionIds(directionIds); + do_.setVisibleProjectIds(projectIds); + return do_; + } + +} diff --git a/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/service/permission/UserVisibilityConfigServiceImplTest.java b/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/service/permission/UserVisibilityConfigServiceImplTest.java new file mode 100644 index 0000000..5d03f9f --- /dev/null +++ b/rdms-system/rdms-system-boot/src/test/java/com/njcn/rdms/module/system/service/permission/UserVisibilityConfigServiceImplTest.java @@ -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 directionIds, + List 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; + } + +}