# Design: 用户 ↔ 项目 绑定功能 - Generated by /office-hours on 2026-04-23 - Frontend branch: `feat/multi-tenant-project`(yudao-ui-admin-vben) - Backend branch: `feat/multi-tenant`(aiot-platform-cloud) - Status: APPROVED - Mode: 内部项目(双端联动) --- ## Problem Statement 前后端已经铺好了"租户 → 项目 → 业务数据"两级隔离的骨架,顶栏也有了项目切换器,但**谁能访问哪个项目**这件事目前只有一个隐性来源:租户创建时 `createDefaultProject()` 把所有用户绑到 `CODE=DEFAULT` 项目上。 一旦新建第二个项目,或新增一个用户,就没有任何管理界面去维护 `system_user_project` 这张中间表。HR 要给新员工开权限、项目经理要组建项目团队,目前都做不到。 要补的是:**用户 ↔ 项目绑定关系的管理界面和专用 API**。 --- ## 现状梳理(调研结论) ### 后端(aiot-platform-cloud · `viewsh-module-system`)已就位 | 组件 | 路径 | 说明 | | --- | --- | --- | | `system_project` 表 | `sql/mysql/project/01-create-tables.sql` | 含租户隔离、`CODE=DEFAULT` 约定 | | `system_user_project` 表 | 同上 | 中间表 `(user_id, project_id)` 唯一约束 + `tenant_id` | | `ProjectDO` / `UserProjectDO` | `dal/dataobject/project/` | UserProjectDO 只有二元组字段 | | `ProjectMapper` / `UserProjectMapper` | `dal/mysql/project/` | UserProjectMapper 已有 `selectListByUserId`、`selectListByProjectId`、`deleteByUserIdAndProjectId` | | `ProjectService.getAuthorizedProjectIds(userId)` | `service/project/ProjectServiceImpl.java:143` | 已实现 | | `ProjectService.getDefaultProjectId(userId)` | 同上 `:149` | 已实现(DEFAULT 优先,否则最小 ID) | | `ProjectContextHolder` | `framework/tenant/core/context/` | ThreadLocal 项目上下文 | | `ProjectDatabaseInterceptor` | `framework/tenant/core/db/` | MyBatis Plus 自动拼 `WHERE project_id = ?` | | `ProjectContextWebFilter` | `framework/tenant/core/web/` | 从 HTTP header `project-id` 注入到 context | | `ProjectSecurityWebFilter` | `framework/tenant/core/security/` | **已做"用户对项目的访问权"校验**(含默认项目回填 + 非授权项目 403) | ### 前端(yudao-ui-admin-vben · `apps/web-antd`)已就位 | 组件 | 路径 | | --- | --- | | 项目 CRUD 页面 | `views/system/project/{index.vue, modules/form.vue, data.ts}` | | 项目 API | `api/system/project/index.ts` | | 顶栏项目切换器 | `packages/effects/layouts/src/widgets/project-dropdown/project-dropdown.vue` | | 请求拦截器 | axios 自动注入 `project-id` 请求头 | ### Gap 1. **无 UserProject 管理 API** —— 后端 `UserProjectMapper` 有,但没封装 Service,没暴露 Controller。 2. **无用户 ↔ 项目绑定管理界面** —— 用户页没"分配项目"、项目页没"管理成员"。 3. **`/system/project/simple-list` 是 bug** - `ProjectController.getProjectSimpleList`(`ProjectController.java:77-81`)当前查的是 `projectService.getProjectListByStatus(ENABLE)`——**返回整租户启用项目**。 - 正确行为:只返回**当前登录用户授权的**启用项目(否则顶栏下拉里会出现无权访问的项目名,点了才 403)。 --- ## Premises(已确认) 1. 后端表和 `ProjectSecurityWebFilter` 都已到位,**不新建任何表、不改任何字段**。 2. UI 采用**双入口**:用户页「分配项目」+ 项目页「管理成员」。 3. **不引入"项目内角色"**:`user_project` 保持二元组,未来需要时再加 `role_id` 字段。 4. **顺带修 `simple-list` bug**:改成只返回登录用户授权的项目。 5. **沿用 yudao "assignXxx" 风格**:幂等覆盖写入,Body 传 `Set`,Service 内部 diff 出增删。参照 `PermissionServiceImpl.assignUserRole`(`PermissionServiceImpl.java:208`)。 6. **边界守卫**:禁止管理员把自己从当前正在访问的项目移除;禁止清空 superadmin(用户 id=1)的项目分配。 --- ## Approaches Considered ### Approach A: 一次到位(已选) 一个 PR 同时交付:UserProject 管理 API + 双入口前端弹窗 + `simple-list` bug 修复。 - Completeness: 9/10 - 人力估:~1 天 / CC 估:~35 分钟 - Pros: 一次发布闭环;用户体验一致;bug 不遗留 - Cons: PR 稍大;需要前后端同时 review - Reuses: `UserProjectMapper`、`PermissionController.assign-user-role` 模式、`assign-role-form.vue` 模板 ### Approach B: 分两步交付(备选) PR1 只做"用户视角"(后端 2 个接口 + 修 bug + 前端用户页入口);PR2 做"项目视角"。 - Completeness: 8/10 - 人力估:PR1 ~0.4 天 + PR2 ~0.5 天 - Pros: PR 小好 review;风险可控 - Cons: 项目经理视角的体验要等下个迭代 ### Approach C: 极简版(备选) 在用户编辑表单里直接加"所属项目"多选下拉框(类似 `deptId`/`postIds`),不做独立弹窗,不改项目页。 - Completeness: 5/10 - 人力估:~0.25 天 - Pros: 文件改动最少 - Cons: 项目经理组团队要一个个用户翻;与 yudao "assign-role" 交互不一致 --- ## Recommended Approach: A(一次到位) --- ## 详细设计 ### 后端改动(aiot-platform-cloud · `viewsh-module-system-server`) #### 1. 新建 `UserProjectController` 路径:`controller/admin/project/UserProjectController.java` 基础路径:`/system/user-project` | 方法 | 路径 | 权限 | Body / Param | 返回 | | --- | --- | --- | --- | --- | | POST | `/assign-user-projects` | `system:user:assign-project` | `{userId, projectIds: Set}` | `Boolean` | | POST | `/assign-project-users` | `system:project:assign-user` | `{projectId, userIds: Set}` | `Boolean` | | GET | `/list-project-ids-by-user` | `system:user:assign-project` | `?userId=` | `Set` | | GET | `/list-user-ids-by-project` | `system:project:assign-user` | `?projectId=` | `Set` | 两个 `assign-*` 都是**幂等覆盖写入**(传全集,Service 内 diff)。 #### 2. 新建 `UserProjectService` + `UserProjectServiceImpl` 路径:`service/project/` ```java public interface UserProjectService { // 覆盖写入:用户 userId 所绑定的项目集合设为 projectIds void assignUserProjects(Long userId, Set projectIds); // 覆盖写入:项目 projectId 下的成员设为 userIds void assignProjectUsers(Long projectId, Set userIds); // 查:用户已绑定的项目 ID 集合 Set getProjectIdsByUserId(Long userId); // 查:项目下的用户 ID 集合 Set getUserIdsByProjectId(Long projectId); } ``` 实现参考 `PermissionServiceImpl.assignUserRole`(`PermissionServiceImpl.java:208`)的 diff 思路: ```java // 1. 查当前集合 Set currentIds = ...selectListByUserId(userId).stream().map(getProjectId).collect(toSet()); // 2. 计算增删 Set toInsert = CollUtil.subtract(projectIds, currentIds); Set toDelete = CollUtil.subtract(currentIds, projectIds); // 3. 执行 if (!toInsert.isEmpty()) userProjectMapper.insertBatch(buildDOs(userId, toInsert)); if (!toDelete.isEmpty()) userProjectMapper.delete(wrapper by userId + projectId in toDelete); ``` #### 3. 修复 `ProjectController.getProjectSimpleList` `ProjectController.java:77-81`: ```java // 当前实现(错) List list = projectService.getProjectListByStatus(ENABLE); // 应改为 —— 在 ProjectService 加新方法 Long userId = SecurityFrameworkUtils.getLoginUserId(); List list = projectService.getAuthorizedEnabledProjects(userId); ``` **预防回归**:在修复前,**全量搜索 `getSimpleProjectList` / `getProjectSimpleList` 的所有调用点**(前后端都要搜),挨个确认它们的语义诉求是"当前用户授权的"还是"本租户全部"。找到的每个调用点都要在 PR 描述里列出并说明是否受影响。**初步预期**:只有顶栏项目切换器用它;但后端日志/报表/下拉如果也在用原语义("本租户全部启用项目"),需要改指向本 PR 新增的 `/system/project/all-simple-list`(见下一节)。 #### 3.1 新增 `/system/project/all-simple-list`(管理员专用) **用途**:给"管理员为其他人分配项目"场景提供**全量**项目下拉。权限点复用 `system:project:query`,返回体和当前 `getProjectSimpleList` 一致。 ```java @GetMapping("/all-simple-list") @Operation(summary = "获取本租户全部启用项目(用于管理员分配场景)") @PreAuthorize("@ss.hasPermission('system:project:query')") public CommonResult> getAllProjectSimpleList() { List list = projectService.getProjectListByStatus(ENABLE); return success(convertList(list, p -> new ProjectRespVO().setId(p.getId()).setName(p.getName()).setCode(p.getCode()))); } ``` 前端"分配项目"弹窗的项目下拉**必须用此接口**,不能用 `getSimpleProjectList`(后者已改成只返回当前登录人授权项目)。 #### 4. 边界守卫(Service 层) - **`assignUserProjects`**:若 `projectIds` 不包含用户当前 `ProjectContextHolder.getProjectId()`,**且调用者 `SecurityFrameworkUtils.getLoginUserId()` 就是 `userId` 本人**(即自己改自己的分配),抛 `USER_PROJECT_CANNOT_REMOVE_SELF_CURRENT` 业务异常。 - _注释要写清_:本守卫只阻止自己把自己踢出当前项目;管理员给别人改分配不受此影响(即使被改的用户当前正在访问某项目)。 - **`assignProjectUsers`**: - _superadmin 守卫_:若 `userIds` 计算出的"要移除的子集"中任何一个持有超管角色,拒绝。**不能用 `userId == 1` 判别**(不同租户的管理员 id 不一定是 1)。正确做法:注入 `PermissionService.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode())` 或类似的现有工具;yudao 里可查 `AdminUserService.isSuperAdmin()` 的实现并复用。 - _自踢守卫_:若当前登录人 id 在被移除列表 **且** 当前 `ProjectContextHolder.getProjectId()` 等于目标 `projectId`,拒绝。 - **项目删除级联**: - `ProjectService.deleteProject` 需在**同一个 `@Transactional` 方法内**:先 `userProjectMapper.delete(lambdaQuery.eq(projectId, id))`(走 MyBatis Plus 自动软删标记 `deleted=1`),再 `projectMapper.deleteById(id)`。 - 项目 `disable` 不触发级联(用户关系保留,恢复启用后仍生效)。 #### 5. 菜单权限种子(SQL) 新增两条菜单权限(放 `sql/mysql/migrations/2026-04-23_user_project_permissions.sql`)。 **关键:`parent_id` 不能写死**,因为不同环境菜单 id 可能不同。用子查询动态取: ```sql -- 依赖条件: -- 1) 用户管理菜单存在,通过 permission='system:user:list' 或 name='用户管理' 定位 -- 2) 项目管理菜单存在,通过 permission='system:project:query' 或 name='项目管理' 定位 -- (若项目主菜单尚不存在,先插一条主菜单再插按钮) -- system:user:assign-project —— 用户管理下的"分配项目"按钮 INSERT INTO system_menu (name, permission, type, parent_id, sort, status, creator, create_time, updater, update_time, deleted) SELECT '用户分配项目', 'system:user:assign-project', 3, m.id, 10, 0, '1', NOW(), '1', NOW(), 0 FROM system_menu m WHERE m.permission = 'system:user:list' AND m.deleted = 0 LIMIT 1; -- system:project:assign-user —— 项目管理下的"管理成员"按钮 INSERT INTO system_menu (name, permission, type, parent_id, sort, status, creator, create_time, updater, update_time, deleted) SELECT '项目管理成员', 'system:project:assign-user', 3, m.id, 10, 0, '1', NOW(), '1', NOW(), 0 FROM system_menu m WHERE m.permission = 'system:project:query' AND m.deleted = 0 LIMIT 1; -- 幂等:若已存在同 permission 的按钮,忽略(需要 DBA 先 DELETE 或加 ON DUPLICATE KEY; -- 若项目有多环境,推荐改成 Flyway/Liquibase 迁移脚本由 CI 控制幂等) ``` 迁移脚本跑完后人工核对:两条记录的 `parent_id` 非 NULL 且能在 "菜单管理" 页面看到层级正确。 --- ### 前端改动(yudao-ui-admin-vben · `apps/web-antd`) #### 1. 新建 API 封装 路径:`src/api/system/user-project/index.ts` ```typescript import { requestClient } from '#/api/request'; export namespace SystemUserProjectApi { export interface AssignUserProjectsReq { userId: number; projectIds: number[]; } export interface AssignProjectUsersReq { projectId: number; userIds: number[]; } } export function assignUserProjects( data: SystemUserProjectApi.AssignUserProjectsReq, ) { return requestClient.post('/system/user-project/assign-user-projects', data); } export function assignProjectUsers( data: SystemUserProjectApi.AssignProjectUsersReq, ) { return requestClient.post('/system/user-project/assign-project-users', data); } export function getProjectIdsByUserId(userId: number) { return requestClient.get( `/system/user-project/list-project-ids-by-user?userId=${userId}`, ); } export function getUserIdsByProjectId(projectId: number) { return requestClient.get( `/system/user-project/list-user-ids-by-project?projectId=${projectId}`, ); } ``` #### 2. 用户管理页:「分配项目」弹窗 **新建** `views/system/user/modules/assign-project-form.vue` —— 直接照搬 `assign-role-form.vue`(同目录)的结构,替换: - import:`assignUserRole, getUserRoleList` → `assignUserProjects, getProjectIdsByUserId` - 数据源:**必须用** `getAllProjectSimpleList`(后端 3.1 节新建的管理员专用接口),**不要用** `getSimpleProjectList`(已改成只返回调用者自己授权的项目) - schema:`useAssignRoleFormSchema` → 新建 `useAssignProjectFormSchema`(在 `data.ts` 里)。字段 `projectIds`,`component: 'Select'` + `mode: 'multiple'`,`options` 由 `getAllProjectSimpleList()` 填充 **空集二次确认**:`onConfirm` 提交前若 `values.projectIds.length === 0`,弹 `Modal.confirm({ title: '确认清空该用户的所有项目分配?' })`。仅"确认"后才调 API。避免误点保存把用户所有项目全删。 **修改** `views/system/user/index.vue`:在行操作按钮区加一个「分配项目」,照抄现有「分配角色」按钮的写法。 **修改** `views/system/user/data.ts`:新增 `useAssignProjectFormSchema()`。 #### 3. 项目管理页:「管理成员」弹窗 **新建** `views/system/project/modules/assign-user-form.vue` 交互:穿梭框(transfer)样式或多选 Select,左侧全量用户(通过 `getSimpleUserList`),右侧已绑定。支持搜索 username/nickname。 ``` ┌──────────────────────────────────────────────────┐ │ 项目:10号线巡检项目 │ │ │ │ ┌─── 所有用户 ───┐ ┌─── 项目成员 ───┐ │ │ │ □ 张三 (ops) │ →→→ │ ☑ 李四 (mgr) │ │ │ │ □ 王五 (iot) │ ←←← │ ☑ 赵六 (clean) │ │ │ └───────────────┘ └───────────────┘ │ │ │ │ [取消] [保存] │ └──────────────────────────────────────────────────┘ ``` 简化版实现:直接用 ant-design-vue 的 `a-transfer`;或者用 `a-select` multiple + `getSimpleUserList` 的下拉。第一版用 `a-select multiple` 最快。 **修改** `views/system/project/index.vue`:行操作加「管理成员」按钮。 **修改** `views/system/project/data.ts`:新增 `useAssignUserFormSchema()`。 --- ## 数据模型 **不新增表,不改字段。** 沿用: ```sql system_user_project ( id BIGINT PK AUTO_INCREMENT, user_id BIGINT NOT NULL, project_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, creator, create_time, updater, update_time, deleted, UNIQUE KEY uk_user_project (user_id, project_id, deleted), INDEX idx_project (project_id) ) ``` --- ## 成功标准(Acceptance) 1. 管理员在用户页对用户"分配项目"保存后,该用户立刻能通过顶栏切换到被分配项目,业务接口返回数据。 2. 管理员在项目页"管理成员"增删后,被移除的用户切到该项目会被 `ProjectSecurityWebFilter` 403。 3. 顶栏项目下拉只显示当前登录用户**授权且启用**的项目。 4. 管理员不能把**自己**从当前正在访问的项目移除(UI 禁用 + 后端守卫)。 5. 不能清空 superadmin 的项目分配。 6. 分配/取消分配后,`ProjectService.getAuthorizedProjectIds(userId)` 立刻反映最新状态(无缓存或缓存有清理)。 --- ## Open Questions(实施前再 settle) 1. ~~管理员分配时用哪个接口拉"所有项目"下拉数据?~~ **已决**:新增 `/system/project/all-simple-list`,权限点复用 `system:project:query`(见后端 3.1 节)。 2. ~~删除项目时,`system_user_project` 要不要级联软删?~~ **已决**:在同一 `@Transactional` 内级联软删,本 PR 一起做(见后端 4 节)。 3. **缓存?** `ProjectSecurityWebFilter` 每次请求都调 `getAuthorizedProjectIds`——高并发下要不要加 Redis 缓存?本 PR 先不做,只做能用的 V1。给 ops 团队留条 TODO。 4. **业务表历史数据的 `project_id` 谁填?** 这是另一个迁移任务,不在本设计范围。假定各业务模块自己有迁移脚本(看 `sql/mysql/project/03-alter-business-tables.sql`)。 5. **GET 查询接口与 `ProjectSecurityWebFilter` 的交互。** 4 个新增 API 都是管理员在"某个项目上下文"里调用,理论上不需要 `@ProjectIgnore` —— Filter 会用 context 里的 `project-id` 正常通过。但若出现"调用者刚被移除所有授权项目,自己还想查"的边缘场景会 403。**本 PR 不处理,留给实施时观察日志**。若要求必然能通,后端给 4 个接口加 `@ProjectIgnore` 注解。 --- ## Distribution Plan(交付通路) - **backend**: 在 `feat/multi-tenant` 上拆子分支 `feat/multi-tenant/user-project-api`,合并后通过现有镜像构建 → 部署。 - **frontend**: 在当前 `feat/multi-tenant-project` 上直接开工,通过 `Dockerfile.deploy` 构建 → 部署。 - CI 跑 backend 单测(新增 `UserProjectServiceImplTest`)+ 前端 lint + typecheck。 --- ## 实施顺序(The Assignment) 1. **后端先发**(~25 min): 1. 建 `UserProjectService` 接口 + 实现 + 测试(diff 逻辑用参数化测试覆盖增量/减量/全替换/空集四种情况) 2. 建 `UserProjectController` 3. 修 `ProjectController.getProjectSimpleList`(+ 单独测试用例) 4. 加菜单权限种子 SQL 5. 本地跑一遍:用 `superadmin` 给 `user-xxx` 分配两个项目 → Postman 断言 `list-project-ids-by-user` 返回 2. **前端接入**(~10 min): 1. `api/system/user-project/index.ts` 2. `assign-project-form.vue` + 用户页按钮 3. `assign-user-form.vue` + 项目页按钮 3. **手工验证清单**: - 分配 → 切项目 → 业务接口受保护 ✅ - 取消分配 → 切项目被 403 ✅ - 顶栏下拉只有授权项目 ✅ - 自踢守卫 ✅ - superadmin 守卫 ✅ --- ## Reviewer Concerns(第 1 轮 spec review 已处理) - ~~superadmin 守卫用 `userId==1` 不安全~~ → 已改为角色判别(4 节) - ~~菜单 parent_id 占位符未填~~ → 已改为 `SELECT FROM system_menu WHERE permission=...` 子查询(5 节) - ~~`simple-list` 修复可能破坏其他调用点~~ → 已加"修前全量搜索调用点"要求(3 节) - ~~"管理员分配用哪个下拉接口"有两个方案未定~~ → 已定为新增 `/all-simple-list`(3.1 节) - ~~前端空集清空缺少二次确认~~ → 已补 `Modal.confirm` 流程(前端 2 节) - ~~级联删除事务边界不清晰~~ → 已明确 `@Transactional` 同一方法内(4 节) 综合质量分从 6.5/10 → 预估 8.5/10。如实施发现新风险,回填此处。