记录本轮 feat/multi-tenant-project 分支的需求背景、双入口绑定 方案与前后端联动约定,供后续回溯决策。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
20 KiB
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
- 无 UserProject 管理 API —— 后端
UserProjectMapper有,但没封装 Service,没暴露 Controller。 - 无用户 ↔ 项目绑定管理界面 —— 用户页没"分配项目"、项目页没"管理成员"。
/system/project/simple-list是 bugProjectController.getProjectSimpleList(ProjectController.java:77-81)当前查的是projectService.getProjectListByStatus(ENABLE)——返回整租户启用项目。- 正确行为:只返回当前登录用户授权的启用项目(否则顶栏下拉里会出现无权访问的项目名,点了才 403)。
Premises(已确认)
- 后端表和
ProjectSecurityWebFilter都已到位,不新建任何表、不改任何字段。 - UI 采用双入口:用户页「分配项目」+ 项目页「管理成员」。
- 不引入"项目内角色":
user_project保持二元组,未来需要时再加role_id字段。 - 顺带修
simple-listbug:改成只返回登录用户授权的项目。 - 沿用 yudao "assignXxx" 风格:幂等覆盖写入,Body 传
Set<Long>,Service 内部 diff 出增删。参照PermissionServiceImpl.assignUserRole(PermissionServiceImpl.java:208)。 - 边界守卫:禁止管理员把自己从当前正在访问的项目移除;禁止清空 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/
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 思路:
// 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:
// 当前实现(错)
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 一致。
@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,拒绝。
- superadmin 守卫:若
- 项目删除级联:
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 可能不同。用子查询动态取:
-- 依赖条件:
-- 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
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()。
数据模型
不新增表,不改字段。 沿用:
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)
- 管理员在用户页对用户"分配项目"保存后,该用户立刻能通过顶栏切换到被分配项目,业务接口返回数据。
- 管理员在项目页"管理成员"增删后,被移除的用户切到该项目会被
ProjectSecurityWebFilter403。 - 顶栏项目下拉只显示当前登录用户授权且启用的项目。
- 管理员不能把自己从当前正在访问的项目移除(UI 禁用 + 后端守卫)。
- 不能清空 superadmin 的项目分配。
- 分配/取消分配后,
ProjectService.getAuthorizedProjectIds(userId)立刻反映最新状态(无缓存或缓存有清理)。
Open Questions(实施前再 settle)
-
管理员分配时用哪个接口拉"所有项目"下拉数据?已决:新增/system/project/all-simple-list,权限点复用system:project:query(见后端 3.1 节)。 -
删除项目时,已决:在同一system_user_project要不要级联软删?@Transactional内级联软删,本 PR 一起做(见后端 4 节)。 -
缓存?
ProjectSecurityWebFilter每次请求都调getAuthorizedProjectIds——高并发下要不要加 Redis 缓存?本 PR 先不做,只做能用的 V1。给 ops 团队留条 TODO。 -
业务表历史数据的
project_id谁填? 这是另一个迁移任务,不在本设计范围。假定各业务模块自己有迁移脚本(看sql/mysql/project/03-alter-business-tables.sql)。 -
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)
- 后端先发(~25 min):
- 建
UserProjectService接口 + 实现 + 测试(diff 逻辑用参数化测试覆盖增量/减量/全替换/空集四种情况) - 建
UserProjectController - 修
ProjectController.getProjectSimpleList(+ 单独测试用例) - 加菜单权限种子 SQL
- 本地跑一遍:用
superadmin给user-xxx分配两个项目 → Postman 断言list-project-ids-by-user返回
- 建
- 前端接入(~10 min):
api/system/user-project/index.tsassign-project-form.vue+ 用户页按钮assign-user-form.vue+ 项目页按钮
- 手工验证清单:
- 分配 → 切项目 → 业务接口受保护 ✅
- 取消分配 → 切项目被 403 ✅
- 顶栏下拉只有授权项目 ✅
- 自踢守卫 ✅
- superadmin 守卫 ✅
Reviewer Concerns(第 1 轮 spec review 已处理)
superadmin 守卫用→ 已改为角色判别(4 节)userId==1不安全菜单 parent_id 占位符未填→ 已改为SELECT FROM system_menu WHERE permission=...子查询(5 节)→ 已加"修前全量搜索调用点"要求(3 节)simple-list修复可能破坏其他调用点"管理员分配用哪个下拉接口"有两个方案未定→ 已定为新增/all-simple-list(3.1 节)前端空集清空缺少二次确认→ 已补Modal.confirm流程(前端 2 节)级联删除事务边界不清晰→ 已明确@Transactional同一方法内(4 节)
综合质量分从 6.5/10 → 预估 8.5/10。如实施发现新风险,回填此处。