Files
aiot-platform-ui/docs/design/2026-04-23-user-project-binding.md
lzh ba8509dc66 style(@vben/docs): markdownlint 自动格式化设计稿
lint-md 对上一条设计稿做了表格管道对齐和多空格归一化,
收进单独提交保持每次提交可 diff。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:27:56 +08:00

400 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<Long>`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<Long>}` | `Boolean` |
| POST | `/assign-project-users` | `system:project:assign-user` | `{projectId, userIds: Set<Long>}` | `Boolean` |
| GET | `/list-project-ids-by-user` | `system:user:assign-project` | `?userId=` | `Set<Long>` |
| GET | `/list-user-ids-by-project` | `system:project:assign-user` | `?projectId=` | `Set<Long>` |
两个 `assign-*` 都是**幂等覆盖写入**传全集Service 内 diff
#### 2. 新建 `UserProjectService` + `UserProjectServiceImpl`
路径:`service/project/`
```java
public interface UserProjectService {
// 覆盖写入:用户 userId 所绑定的项目集合设为 projectIds
void assignUserProjects(Long userId, Set<Long> projectIds);
// 覆盖写入:项目 projectId 下的成员设为 userIds
void assignProjectUsers(Long projectId, Set<Long> userIds);
// 查:用户已绑定的项目 ID 集合
Set<Long> getProjectIdsByUserId(Long userId);
// 查:项目下的用户 ID 集合
Set<Long> getUserIdsByProjectId(Long projectId);
}
```
实现参考 `PermissionServiceImpl.assignUserRole``PermissionServiceImpl.java:208`)的 diff 思路:
```java
// 1. 查当前集合
Set<Long> currentIds = ...selectListByUserId(userId).stream().map(getProjectId).collect(toSet());
// 2. 计算增删
Set<Long> toInsert = CollUtil.subtract(projectIds, currentIds);
Set<Long> 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<ProjectDO> list = projectService.getProjectListByStatus(ENABLE);
// 应改为 —— 在 ProjectService 加新方法
Long userId = SecurityFrameworkUtils.getLoginUserId();
List<ProjectDO> 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<List<ProjectRespVO>> getAllProjectSimpleList() {
List<ProjectDO> 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<number[]>(
`/system/user-project/list-project-ids-by-user?userId=${userId}`,
);
}
export function getUserIdsByProjectId(projectId: number) {
return requestClient.get<number[]>(
`/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。如实施发现新风险回填此处。