Files
aiot-platform-ui/docs/design/2026-04-23-user-project-binding.md
lzh 6b8a05cc4d docs: 补充用户-项目绑定功能设计稿
记录本轮 feat/multi-tenant-project 分支的需求背景、双入口绑定
方案与前后端联动约定,供后续回溯决策。

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

20 KiB
Raw Blame History

Design: 用户 ↔ 项目 绑定功能

  • Generated by /office-hours on 2026-04-23
  • Frontend branch: feat/multi-tenant-projectyudao-ui-admin-vben
  • Backend branch: feat/multi-tenantaiot-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 已有 selectListByUserIdselectListByProjectIddeleteByUserIdAndProjectId
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.getProjectSimpleListProjectController.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.assignUserRolePermissionServiceImpl.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: UserProjectMapperPermissionController.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" 交互不一致


详细设计

后端改动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.assignUserRolePermissionServiceImpl.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,拒绝。
  • 项目删除级联
    • 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(同目录)的结构,替换:

  • importassignUserRole, getUserRoleListassignUserProjects, getProjectIdsByUserId
  • 数据源:必须用 getAllProjectSimpleList(后端 3.1 节新建的管理员专用接口),不要用 getSimpleProjectList(已改成只返回调用者自己授权的项目)
  • schemauseAssignRoleFormSchema → 新建 useAssignProjectFormSchema(在 data.ts 里)。字段 projectIdscomponent: 'Select' + mode: 'multiple'optionsgetAllProjectSimpleList() 填充

空集二次确认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

  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. 本地跑一遍:用 superadminuser-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-list3.1 节)
  • 前端空集清空缺少二次确认 → 已补 Modal.confirm 流程(前端 2 节)
  • 级联删除事务边界不清晰 → 已明确 @Transactional 同一方法内4 节)

综合质量分从 6.5/10 → 预估 8.5/10。如实施发现新风险回填此处。