成员管理不是一个独立的用户体系,而是站在用户管理和企业管理的肩膀上,解决「谁属于当前企业、在当前企业里做什么」的问题。工作台则是平台的入口面板------让用户一登录就能看到「我的项目、我的待办、我的预警」,而不是面对一个空白的首页。本篇记录成员管理的设计思路(如何复用底座而非重复造轮子)和工作台的分层架构(当前全部为 Mock 占位数据、未来如何逐步替换)。
一、成员管理:基于底座的「加法」而非「重建」
1.1 为什么成员管理不等于用户管理
平台的用户管理设计为全局视角------管理员能看到所有用户,不区分企业。但多企业隔离要求企业 A 的管理员不应该看到企业 B 的成员,也不应该把企业 B 的人拉进自己的项目。
如果直接在用户管理上加企业过滤,会导致全局管理员和企业管理员看到的数据范围混淆。因此,平台选择了一条更清晰的路:保留全局用户管理,新增一套企业维度的成员管理接口,复用同一张用户表,但在查询时强制按企业隔离过滤。
markdown
成员管理 与 用户管理 的关系
全局用户管理(用户列表查询)
├─ 视角:平台管理员
├─ 数据范围:全部用户
└─ 不受企业隔离约束
企业成员管理(当前企业成员查询)
├─ 视角:企业管理员
├─ 数据范围:仅当前企业的成员
├─ 复用同一张 sys_user 表
└─ 强制 @CompanyIsolated 隔离
底座共享
├─ sys_user 用户主表(账号、密码、状态)
├─ sys_user_company 用户-企业关联(多对多)
├─ sys_user_dept 用户-部门关联
├─ sys_user_team 用户-团队关联
├─ sys_user_position 用户-职位关联
└─ sys_user_role 用户-角色关联
功能截图:

1.2 企业隔离的实现机制
成员管理的所有接口都标注了 @CompanyIsolated 注解。这个注解的作用是:拦截器检查请求头中的 X-Company-Code 与 JWT 中存储的企业编码是否一致,一致则通过 CompanyScope.enable() 开启当前请求的企业隔离。
开启后,SysUserServiceImpl.selectPage() 在执行查询时会做以下处理:
scss
企业隔离查询流程
1. 检查 CompanyScope.isEnabled()
├─ 否 → 不按企业过滤(全局用户管理走这条路)
└─ 是 → 读取 LoginHelper.getCompanyId()
├─ 为 null → 返回空列表(未选择企业)
└─ 有值 → 查询 sys_user_company 获取该企业关联的 userIds
└─ wrapper.in(SysUser::getId, compUserIds)
这意味着成员管理的列表查询本质上是一个「带关联表过滤的分页查询」------先查 sys_user_company 拿到当前企业的用户 ID 集合,再用这个集合作为 IN 条件查 sys_user 主表。
1.3 批量填充关联信息
成员列表不只展示用户名,还需要展示该用户的企业/部门、团队、职位、角色、锁定状态等信息。如果逐条查询会产生严重的 N+1 问题,因此采用批量填充策略:
scss
批量填充流程(单次分页查询后)
查询 sys_user 分页结果(10 条)
│
├─ fillRoles() 批量查 sys_user_role → sys_role,填充角色列表
├─ fillPositions() 批量查 sys_user_position → sys_position,填充职位列表
├─ fillCompanies() 批量查 sys_user_company → sys_company + sys_user_dept → sys_dept
│ 填充企业-部门关联(按企业分组)
├─ fillTeams() 批量查 sys_user_team → sys_team,填充团队列表
└─ fillLocks() 批量查 sys_user_lock,填充锁定状态
总 SQL 次数:约 8-10 次(固定),与分页大小无关
功能截图:

1.4 添加成员:从已有账号「拉人」而非「注册」
成员管理的添加逻辑不是「新建用户」,而是「从平台已有账号中拉人加入当前企业」。这个设计基于一个前提:平台是内部系统,用户通常已经在其他企业注册过账号了。
scss
添加成员流程
前端打开「添加成员」抽屉
│
├─ 调用「可挑选成员」接口
│ └─ selectPickablePage():不按企业过滤,但标记 inCurrentCompany 字段
│ └─ 已在当前企业的成员:勾选框禁用且默认选中
│ └─ 不在当前企业的成员:可勾选
│
├─ 用户勾选要添加的成员(已在企业的不可取消)
│
└─ 提交 → 「加入当前企业」接口
└─ addUsersToCurrentCompany()
├─ 校验每个 userId 是否存在
└─ ensureCurrentCompanyRelation()
├─ 检查 sys_user_company 是否已存在关联
├─ 已存在 → 跳过
└─ 不存在 → 写入关联记录(isDefault = false)
关键设计点: selectPickablePage 故意不按当前企业过滤------因为它需要展示全部用户,让企业管理员看到「谁还没加入」。但通过 fillInCurrentCompany() 方法为每个用户标记 inCurrentCompany 字段,前端据此禁用已加入成员的勾选框。
功能截图:

1.5 移出成员:解关联不删账号
移出成员的核心原则是只解除关联,不删除账号。用户可能属于多个企业,从企业 A 移出不应影响其在企业 B 的数据。
scss
移出成员流程
removeUsersFromCurrentCompany(userIds)
│
└─ 遍历 userIds
└─ removeCompanyRelation(userId, companyId)
├─ 1. 删除 sys_user_company 关联记录
├─ 2. 查询该企业下的所有部门 IDs
│ └─ 删除 sys_user_dept 中该用户 + 这些部门的关联
├─ 3. 查询这些部门下的所有团队 IDs
│ └─ 删除 sys_user_team 中该用户 + 这些团队的关联
└─ 全程事务保护 @Transactional
级联清理的必要性: 用户从企业移出后,其在该企业下的部门和团队关联就失去了意义。如果不清理,会导致「用户不属于企业但仍属于该企业下的团队」这种数据不一致。
1.6 团队管理:成员管理的子模块
团队(SysTeam)挂在部门下,一个部门可以有多个团队。团队管理是成员管理的侧栏功能,支持:
scss
团队管理功能树
团队 CRUD
├─ 新增团队(指定所属部门)
├─ 修改团队信息
├─ 删除团队
└─ 查询团队列表(按当前企业)
团队成员管理
├─ 查询团队成员(企业隔离 + 团队过滤)
├─ 批量添加团队成员
│ └─ 校验:用户必须属于当前企业才能加入团队
├─ 批量移出团队成员
└─ 同步团队成员(增量模式)
├─ 对比勾选集合 vs 已有集合
├─ 新增勾选 → addTeamMembers()
└─ 取消勾选 → removeTeamMembers()
团队管理在添加成员时也有一个校验:assertUsersInCurrentCompany() 确保被添加的用户已经属于当前企业。这是防止「把不属于企业的人拉进企业下的团队」的安全兜底。
功能截图:

1.7 成员管理的操作能力
成员管理复用了用户管理的全部操作接口,但通过 /current-company/* 路径提供企业隔离版本:
- 启用/禁用:禁用时需填写原因,支持操作日志 Diff
- 锁定/解锁:手动锁定,与密码策略的自动锁定互不冲突
- 重置密码:由企业管理员代为重置,触发强制改密
- 编辑成员:修改职位、团队等企业维度信息
- 移出企业:解除关联,不删除账号
所有操作均通过 @LogRecord 注解记录操作日志,通过 @CompanyIsolated 确保企业隔离。
二、工作台:平台入口的数据编排
2.1 工作台的定位
工作台是用户登录后看到的第一屏。它的设计目标不是「功能大全」,而是让用户 3 秒内知道「我现在要做什么」。因此,工作台的内容按优先级从上到下排列:
scss
工作台布局(自上而下)
┌─────────────────────────────────────────┐
│ 问候区 │
│ 「上午好,张明 👋 当前租户:XX企业」 │
├──────────────────┬──────────────────────┤
│ 个人数据概览 │ 企业数据概览 │
│ (今日/本周/本月) │ (今日/本周/本月) │
├──────────────────┴──────────────────────┤
│ 我的项目(卡片网格) │
│ ┌────┐ ┌────┐ ┌────┐ │
│ │项目A│ │项目B│ │项目C│ │
│ └────┘ └────┘ └────┘ │
├─────────────────────────────────────────┤
│ 我的工作项(选项卡表格) │
│ 全部 | 我负责的 | 我协作的 | 我创建的 │
├──────────────────┬──────────────────────┤
│ 我的待办 │ 预警中心 │
│ (审批/任务) │ (项目延期/缺陷超时) │
└──────────────────┴──────────────────────┘
2.2 数据来源分层
工作台当前的数据全部为 Mock 占位数据,按区域分为以下几类:
| 区域 | 数据来源 | 说明 |
|---|---|---|
| 问候区 | Mock | 从前端 Store 读取用户信息和企业信息 |
| 个人数据概览 | Mock | 工作项统计待工作项模块开发后替换 |
| 企业数据概览 | Mock | 同上 |
| 我的项目 | Mock | 项目数据待项目模块完善后替换 |
| 项目卡片统计 | Mock | 成员数、进行中/已完成/逾期数前端推算 |
| 我的工作项 | Mock | 前端硬编码数据,待工作项模块开发后替换 |
| 我的待办 | Mock | 前端硬编码数据,待流程中心开发后替换 |
| 预警中心 | Mock | 前端硬编码数据,待预警引擎开发后替换 |
注意: 工作台当前所有数据均为 Mock 占位,包括项目卡片中的项目信息、统计数字(成员数、进行中工作项数等)全部是前端推算或硬编码的 Mock 值。后续各业务模块开发完成后逐步替换为真实数据。
2.3 我的项目:Mock 数据链路
「我的项目」区域当前同样使用 Mock 数据,其数据链路如下:
scss
我的项目 Mock 数据链路
前端 workbench/index.vue
│
├─ onMounted() → loadProjects()
│ └─ getMyProjects() → 工作台「我的项目」接口
│ └─ 当前返回 Mock 项目数据
│
后端工作台接口
│
├─ @CompanyIsolated(企业隔离)
├─ 获取当前用户 ID
└─ 项目服务查询用户参与的项目
│
└─ 当前阶段:返回 Mock 项目卡片数据
包含:项目名称、状态、进度、我在项目中的身份、
部门名称、项目经理姓名、创建时间
→ 后续项目模块完善后替换为真实查询
核心设计: 工作台「我的项目」接口已按未来真实查询的契约设计------通过项目成员关联表,以当前用户 ID 为条件过滤,只返回该用户参与的项目,同时联查部门表和项目经理信息。当前阶段返回 Mock 数据,后续项目模块完善后直接替换为真实查询即可。
2.4 Mock 数据的分层策略
工作台的 Mock 数据不是随意堆砌的,而是按未来业务模块的边界分层放置:
markdown
Mock 数据分层与未来替换计划
第一层:前端 Mock(硬编码在组件中)
├─ WorkbenchStats.vue 个人/企业数据概览
│ → 待工作项统计接口替换
├─ MyWorkItems.vue 我的工作项列表
│ → 待工作项列表接口替换
├─ WorkbenchTodo.vue 我的待办
│ → 待流程中心待办接口替换
└─ WorkbenchAlert.vue 预警中心
→ 待预警引擎接口替换
第二层:后端 Mock(Controller 返回硬编码数据)
├─ 待办聚合接口 待办聚合
├─ 预警聚合接口 预警聚合
├─ 全局数据接口 全局数据速览
├─ 待办列表接口 待办列表
└─ 预警列表接口 预警列表
→ 后续各业务模块开发完成后,替换为真实查询
第三层:前端推算(基于 Mock 项目数据派生)
└─ WorkbenchProjects.vue 项目卡片统计
├─ 成员数:随机推算
├─ 进行中:基于进度推算
├─ 已完成:基于进度推算
└─ 逾期数:基于状态推算
→ 待项目模块完善后,改为项目统计接口返回
为什么后端也要放 Mock? 因为前端的 Mock 数据无法被多个客户端共享(比如移动端也需要工作台)。后端 Mock 数据统一了接口契约,前端只需要调用接口,未来替换时只需改后端实现,前端无需调整。
2.5 后端 Mock 的接口设计
后端的 Mock 接口并非随意返回数据,而是按照未来接口的契约设计 VO 结构:
待办聚合 VO(TodoSummaryVO)
├─ pendingFirstReview 待初审数
├─ pendingReview 待评审数
├─ pendingApproval 待审批数
├─ pendingTask 待处理任务数
└─ pendingRegression 待回归数
预警聚合 VO(AlertSummaryVO)
├─ projectDelay 项目延期数
├─ defectTimeout 缺陷超时数
└─ highRisk 高风险事项数
全局数据 VO(DashboardStatVO)
├─ requirementThroughput 本月需求吞吐量
├─ throughputTrend 吞吐量环比
├─ deliveryRate 项目交付率
├─ deliveryTrend 交付率环比
├─ defectRate 缺陷率
├─ defectTrend 缺陷率环比
├─ delayRate 延期率
└─ delayTrend 延期率环比
这些 VO 的字段设计已经对齐了未来业务模块的数据模型。当需求管理、缺陷管理、项目迭代管理等模块开发完成后,只需将 Mock 返回替换为真实查询,前端无需任何改动。
功能截图:

2.6 企业隔离在工作台的体现
DashboardController 标注了类级别的 @CompanyIsolated,这意味着工作台的所有接口都要求用户已选择企业。如果用户未选择企业就访问工作台:
scss
企业隔离校验流程
请求到达 DashboardController
│
├─ 拦截器检查 X-Company-Code 请求头
│ ├─ 与 JWT 中的 companyCode 不一致 → 403
│ └─ 一致 → CompanyScope.enable()
│
├─ myProjects() 方法执行
│ └─ LoginHelper.getUserId() 获取用户 ID
│ └─ 如果未登录 → 返回空列表
│
└─ 项目查询通过 biz_project_member 关联
└─ 项目本身也有企业隔离(创建时绑定了企业)
2.7 问候区:用户感知的第一触点
问候区虽然简单,但它是用户感知最直接的区域。展示逻辑如下:
- 问候语:根据当前时间动态生成(上午好/下午好/晚上好)
- 用户名 :从 Pinia Store 读取
userInfo.nickname,优先昵称 - 当前租户 :从 Store 读取
currentCompany.companyName - 日期:实时计算的中文格式日期
这部分数据全部来自前端 Store,不产生额外网络请求,确保首屏加载速度。
三、成员管理与工作台的协作关系
3.1 成员是项目参与的前提
工作台「我的项目」展示的是当前用户参与的项目。而用户能参与项目的前提是------他必须先被添加为当前企业的成员。这形成了一条数据链路:
markdown
数据依赖链路
企业管理员添加成员
└─ sys_user_company 写入关联
└─ 用户成为当前企业成员
└─ 项目管理员添加项目成员
└─ biz_project_member 写入关联
└─ 用户出现在该项目中
└─ 工作台「我的项目」展示该项目
如果用户没有被添加为企业成员,即使他在平台有账号,也不会出现在项目成员的候选名单中(项目成员管理也有企业隔离校验)。
3.2 核心实现伪代码 --- 成员列表查询
scss
// 成员管理列表查询(企业隔离)
function selectPage(条件, 分页参数) {
wrapper = 构建查询条件
// 关键词搜索(用户名 OR 姓名)
applyKeyword(wrapper, 条件)
// 企业隔离过滤
if (企业隔离已开启) {
companyId = 获取当前企业ID()
if (companyId 为空) 返回空列表
// 先查关联表获取该企业的用户 ID 集合
userIds = 查询用户企业关联表(企业ID).map(取userId)
if (userIds 为空) 返回空列表
wrapper.in(用户ID, userIds)
}
// 分页查询主表
result = 分页查询(用户表, wrapper, 分页参数)
// 批量填充关联信息(避免 N+1)
ids = result.记录列表.map(取ID)
批量填充角色(记录列表, ids)
批量填充职位(记录列表, ids)
批量填充企业部门(记录列表, ids)
批量填充团队(记录列表, ids)
批量填充锁定状态(记录列表, ids)
返回 分页结果(记录列表, 总数)
}
3.3 核心实现伪代码 --- 添加已有账号到当前企业
scss
// 从已有账号添加成员到当前企业
@Transactional
function addUsersToCurrentCompany(用户ID列表) {
// 前置校验:企业隔离必须开启
requireCompanyScope()
if (列表为空) 抛出异常("请选择要添加的成员")
for (userId in 用户ID列表) {
if (userId 为空) continue
// 校验用户存在
if (根据ID查用户(userId) 为空) 抛出异常("用户不存在")
// 写入关联(已存在则跳过)
ensureCurrentCompanyRelation(userId)
}
}
// 确保用户与当前企业的关联记录存在
function ensureCurrentCompanyRelation(userId) {
if (企业隔离未开启) return
companyId = 获取当前企业ID()
if (companyId 为空) 抛出异常("未选择企业")
// 查询是否已存在关联
exists = 查询关联表数量(userId, companyId) > 0
if (!exists) {
rel = 新建关联记录()
rel.userId = userId
rel.companyId = companyId
rel.isDefault = false
rel.createTime = 当前时间()
插入关联表(rel)
}
}
3.4 核心实现伪代码 --- 移出企业(级联清理)
scss
// 移出企业:解除关联 + 级联清理部门/团队关系
@Transactional
function removeCompanyRelation(userId, companyId) {
// 校验用户和企业存在
if (根据ID查用户(userId) 为空) 抛出异常("用户不存在")
if (根据ID查企业(companyId) 为空) 抛出异常("企业不存在")
// 校验关联存在
if (查询关联表数量(userId, companyId) == 0)
抛出异常("该用户不属于该企业")
// 1. 删除用户-企业关联
删除关联表(userId, companyId)
// 2. 查询该企业下的所有部门 IDs
deptIds = 查询部门表(企业ID).map(取ID)
if (deptIds 非空) {
// 删除用户在这些部门下的关联
删除用户部门关联(userId, deptIds)
// 3. 查询这些部门下的所有团队 IDs
teamIds = 查询团队表(deptIds).map(取ID)
if (teamIds 非空) {
// 删除用户在这些团队下的关联
删除用户团队关联(userId, teamIds)
}
}
}
3.5 核心实现伪代码 --- 我的项目查询
vbnet
// 工作台:查询当前用户参与的项目卡片
function selectMyProjects(userId) {
return 项目Mapper.联查用户参与的项目卡片(userId)
}
// 联查逻辑(伪 SQL 描述)
/*
SELECT
项目ID, 项目名称, 项目模式, 状态, 进度,
我在项目中的身份,
项目描述, 部门名称, 缺陷策略,
项目经理姓名, 创建时间
FROM 项目表
INNER JOIN 项目成员表 ON 项目ID匹配 AND 用户ID = 当前用户 AND 未删除
LEFT JOIN 部门表 ON 部门ID匹配 AND 未删除
LEFT JOIN 项目成员表(项目经理) ON 项目ID匹配 AND 身份 = PM AND 未删除
LEFT JOIN 用户表(项目经理) ON 用户ID匹配 AND 未删除
WHERE 项目未删除
ORDER BY 更新时间倒序
*/
四、总结与展望
4.1 当前已实现
- 成员管理:完整的列表查询(企业隔离)、添加成员(从已有账号拉入)、移出成员(级联清理)、启用/禁用/锁定/解锁/重置密码、编辑成员信息
- 团队管理:完整的 CRUD + 团队成员管理(增量同步模式)
- 工作台:问候区、数据概览、我的项目(Mock)、我的工作项(Mock)、待办(Mock)、预警中心(Mock)
- 企业隔离:
@CompanyIsolated+CompanyScope+LoginHelper三件套贯穿全部接口
4.2 后续替换计划
markdown
Mock 替换路线图
Phase 1:工作项模块
├─ 替换 MyWorkItems.vue → 真实工作项列表接口
├─ 替换 WorkbenchStats.vue → 真实工作项统计接口
└─ 替换项目卡片统计 → 项目维度工作项聚合
Phase 2:流程中心
├─ 替换 WorkbenchTodo.vue → 真实待办列表接口
└─ 替换待办相关后端 Mock
Phase 3:预警引擎
├─ 替换 WorkbenchAlert.vue → 真实预警列表接口
└─ 替换预警相关后端 Mock
Phase 4:全局统计
└─ 替换全局数据接口 → 真实数据聚合接口
每一层 Mock 的替换都只需修改对应组件的数据源,不影响工作台的整体布局和交互逻辑。这是分层架构的核心价值------接口契约先行,实现可插拔。