From 6b8a05cc4d3880ab451c4c927bbf69c8e2d19937 Mon Sep 17 00:00:00 2001 From: lzh Date: Thu, 23 Apr 2026 20:27:09 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E7=94=A8=E6=88=B7-?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E7=BB=91=E5=AE=9A=E5=8A=9F=E8=83=BD=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录本轮 feat/multi-tenant-project 分支的需求背景、双入口绑定 方案与前后端联动约定,供后续回溯决策。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../design/2026-04-23-user-project-binding.md | 393 ++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 docs/design/2026-04-23-user-project-binding.md diff --git a/docs/design/2026-04-23-user-project-binding.md b/docs/design/2026-04-23-user-project-binding.md new file mode 100644 index 000000000..40da7afb9 --- /dev/null +++ b/docs/design/2026-04-23-user-project-binding.md @@ -0,0 +1,393 @@ +# 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。如实施发现新风险,回填此处。