18 KiB
18 KiB
外包服务模式设计方案
场景:客户企业HR与外包社保专员共用本系统,外包公司负责客户企业的社保公积金、特殊状态员工服务等业务。
一、核心问题
| 维度 | 客户HR | 外包社保专员 |
|---|---|---|
| 管理范围 | 本企业员工 | 多个客户企业的员工 |
| 可操作模块 | 全部(合同/薪酬/考勤/解聘/社保等) | 仅社保公积金、特殊状态 |
| 数据可见性 | 本企业全部 | 被授权的客户企业的指定模块 |
| 登录后视图 | 直接进入本企业Dashboard | 选择客户企业 → 进入受限Dashboard |
二、数据模型变更
2.1 Organization 表新增字段
| 字段 | 类型 | 说明 |
|---|---|---|
isProvider |
Boolean (default: false) | 标记是否为外包服务商企业 |
providerType |
String? | 服务商类型:SOCIAL_INSURANCE / COMPREHENSIVE 等 |
2.2 新增 ServiceProviderBinding 表
外包企业与客户企业的绑定关系,控制授权范围。
| 字段 | 类型 | 说明 |
|---|---|---|
id |
String (cuid) | 主键 |
providerOrgId |
String | 外包服务商企业ID → Organization.id |
clientOrgId |
String | 客户企业ID → Organization.id |
modules |
String[] | 授权模块列表:SOCIAL_INSURANCE, HOUSING_FUND, SPECIAL_STATUS, PAYSLIP |
status |
String | ACTIVE / SUSPENDED / TERMINATED |
startDate |
DateTime | 服务开始日期 |
endDate |
DateTime? | 服务结束日期(null = 长期) |
createdBy |
String | 创建人 |
createdAt |
DateTime | 创建时间 |
updatedAt |
DateTime | 更新时间 |
唯一约束: (providerOrgId, clientOrgId) 联合唯一
2.3 User 表扩展角色
现有角色:ADMIN、HR
新增角色:
| 角色 | 说明 | 归属 |
|---|---|---|
PROVIDER_ADMIN |
外包企业管理员,可管理本企业员工、绑定客户企业 | 外包企业 |
PROVIDER_STAFF |
外包专员,仅可操作授权模块 | 外包企业 |
2.4 User 表变更
现有 User 表已有 role 字段(值为 ADMIN / HR),直接扩展该字段的枚举值,无需新增 orgRole。
| 字段 | 类型 | 说明 |
|---|---|---|
role |
String | 现有字段,扩展枚举值:ADMIN / HR / PROVIDER_ADMIN / PROVIDER_STAFF |
allowedModules |
String[]? | PROVIDER_STAFF 的模块级授权。为 null 时回退到 ServiceProviderBinding.modules 的并集;有值时覆盖绑定关系默认授权,用于精细控制 |
三、后端设计
3.1 认证与会话
登录流程
POST /auth/login
→ 验证用户名密码
→ 返回 JWT,payload 包含:
{
userId, orgId (所属企业), orgRole,
isProvider: boolean
}
企业切换(仅 PROVIDER 角色)
POST /auth/switch-org/:clientOrgId
→ 校验: ServiceProviderBinding(providerOrgId=用户所属企业, clientOrgId, status=ACTIVE)
→ 返回新 JWT,payload 增加:
{
actingOrgId: clientOrgId, // 当前操作的企业
allowedModules: [...] // 从绑定关系获取
}
→ 原有 orgId 保持为 providerOrgId
Session 结构
interface JwtPayload {
userId: string
orgId: string // 用户所属企业(provider 或 client)
orgRole: string // 角色
actingOrgId?: string // 外包专员当前操作的客户企业(仅 provider)
allowedModules?: string[] // 授权模块列表(仅 provider)
isProvider: boolean
}
3.2 权限中间件
// 现有: authMiddleware → req.user.orgId
// 改造: authMiddleware → 设置 req.user.orgId 和 req.user.actingOrgId
// 数据隔离统一使用 actingOrgId(有值时)或 orgId
function getEffectiveOrgId(req: Request): string {
return req.user.actingOrgId || req.user.orgId
}
// 新增: moduleAccessMiddleware(moduleName, accessLevel?)
// 1. 非 provider 用户直接放行
// 2. provider 用户检查 moduleName 是否在 allowedModules 中
// 3. accessLevel 可选: 'read' / 'write',PROVIDER_ADMIN 对部分模块只有 read 权限
// 4. 不在则返回 403
读写权限控制
// 模块读写权限配置
const MODULE_ACCESS: Record<string, Record<string, 'read' | 'write' | 'none'>> = {
ROSTER: { ADMIN: 'write', HR: 'write', PROVIDER_ADMIN: 'read', PROVIDER_STAFF: 'none' },
REPORT: { ADMIN: 'write', HR: 'write', PROVIDER_ADMIN: 'read', PROVIDER_STAFF: 'none' },
SOCIAL_INSURANCE: { ADMIN: 'write', HR: 'write', PROVIDER_ADMIN: 'write', PROVIDER_STAFF: 'write' },
// ... 其他模块
}
// 在路由级别区分
router.get('/roster', authMiddleware, moduleAccessMiddleware('ROSTER', 'read')) // PROVIDER_ADMIN 可访问
router.post('/roster', authMiddleware, moduleAccessMiddleware('ROSTER', 'write')) // PROVIDER_ADMIN 被拒绝
中间件使用示例
// 社保路由 — provider 有读写权限
router.use('/social-insurance', authMiddleware, moduleAccessMiddleware('SOCIAL_INSURANCE', 'write'))
// 花名册路由 — provider 只有读权限
router.get('/roster', authMiddleware, moduleAccessMiddleware('ROSTER', 'read'))
router.post('/roster', authMiddleware, moduleAccessMiddleware('ROSTER', 'write')) // provider 被拒绝
// 合同路由 — provider 无权限
router.use('/contracts', authMiddleware, moduleAccessMiddleware('CONTRACT', 'write'))
// 薪酬路由 — provider 无权限
router.use('/payroll', authMiddleware, moduleAccessMiddleware('PAYROLL', 'write'))
3.3 模块权限矩阵
| 模块 | 模块标识 | ADMIN/HR | PROVIDER_ADMIN | PROVIDER_STAFF |
|---|---|---|---|---|
| 概览/Dashboard | DASHBOARD |
✅ | ✅ | ✅ |
| 花名册 | ROSTER |
✅ | ✅(只读) | ❌ |
| 合同管理 | CONTRACT |
✅ | ❌ | ❌ |
| 薪酬工资 | PAYROLL |
✅ | ❌ | ❌ |
| 工资条 | PAYSLIP |
✅ | ❌ | ❌ |
| 考勤工时 | ATTENDANCE |
✅ | ❌ | ❌ |
| 社保公积金 | SOCIAL_INSURANCE |
✅ | ✅ | ✅ |
| 公积金 | HOUSING_FUND |
✅ | ✅ | ✅ |
| 特殊状态 | SPECIAL_STATUS |
✅ | ✅ | ✅ |
| 解聘管理 | TERMINATION |
✅ | ❌ | ❌ |
| 规章制度 | POLICY |
✅ | ❌ | ❌ |
| 证据链 | EVIDENCE |
✅ | ❌ | ❌ |
| 年度报告 | REPORT |
✅ | ✅(只读) | ❌ |
| 风险提醒 | RISK |
✅ | ✅(仅授权模块相关) | ✅(仅授权模块相关) |
| 系统设置 | SETTINGS |
✅ | ❌ | ❌ |
3.4 API 路由变更
新增路由
| 方法 | 路径 | 说明 | 权限 |
|---|---|---|---|
| GET | /provider/clients |
外包企业的客户列表 | PROVIDER_ADMIN |
| POST | /provider/bindings |
创建客户绑定 | PROVIDER_ADMIN |
| PATCH | /provider/bindings/:id |
修改绑定(模块/状态) | PROVIDER_ADMIN |
| DELETE | /provider/bindings/:id |
解除绑定 | PROVIDER_ADMIN |
| GET | /provider/bindings |
绑定列表 | PROVIDER_ADMIN |
| POST | /auth/switch-org/:orgId |
切换操作企业 | PROVIDER_* |
| GET | /auth/current-org |
当前操作企业信息 | ALL |
现有路由改造
所有业务路由的数据隔离从 req.user.orgId 改为 getEffectiveOrgId(req):
// 改造前
const orgId = req.user.orgId
// 改造后
const orgId = req.user.actingOrgId || req.user.orgId
3.5 数据操作边界
| 操作 | PROVIDER_ADMIN | PROVIDER_STAFF |
|---|---|---|
| 增删员工 | ❌ | ❌ |
| 签订/修改合同 | ❌ | ❌ |
| 发起解聘 | ❌ | ❌ |
| 修改社保基数/缴纳状态 | ✅ | ✅ |
| 录入特殊状态(孕期/工伤/医疗期) | ✅ | ✅ |
| 查看花名册 | ✅(只读) | ❌ |
| 查看年度报告 | ✅(只读) | ❌ |
| 系统设置 | ❌ | ❌ |
3.6 审计日志增强
现有 AuditLog 表已有:id, orgId, userId, action, target, detail, createdAt。
新增字段:
// AuditLog 新增字段
{
operatorOrgId: string // 操作者所属企业(外包操作时 ≠ orgId)
actingOrgId: string // 被操作的企业(= getEffectiveOrgId)
operatorRole: string // 操作者角色(PROVIDER_STAFF 等)
}
- 非 provider 用户操作时:
operatorOrgId = orgId,actingOrgId = orgId - 外包专员操作时:
operatorOrgId = 外包企业ID,actingOrgId = 客户企业ID - 客户HR可在审计日志中筛选
operatorOrgId ≠ orgId查看外包操作记录
3.7 风险提醒过滤
外包专员看到的风险提醒只包含授权模块相关的风险:
// getDashboardData 中过滤
if (req.user.isProvider) {
// 按授权模块映射到风险类型
const moduleTypeMap: Record<string, string[]> = {
'SOCIAL_INSURANCE': ['MONTHLY'], // 社保月度任务(title 包含「社保」)
'HOUSING_FUND': ['MONTHLY'], // 公积金月度任务(title 包含「公积金」)
'SPECIAL_STATUS': ['TERMINATION', 'CONTRACT'], // 特殊状态:解聘受限 + 合同到期不得终止
}
const allowedTypes = req.user.allowedModules
.flatMap(m => moduleTypeMap[m] || [])
todos = todos.filter(t => allowedTypes.includes(t.type))
// 对 MONTHLY 类型还需按 title 二次过滤(社保 vs 公积金 vs 工资 vs 个税)
if (allowedTypes.includes('MONTHLY')) {
const monthlyKeywords: string[] = []
if (req.user.allowedModules.includes('SOCIAL_INSURANCE')) monthlyKeywords.push('社保')
if (req.user.allowedModules.includes('HOUSING_FUND')) monthlyKeywords.push('公积金')
todos = todos.filter(t => {
if (t.type !== 'MONTHLY') return true
return monthlyKeywords.some(kw => t.title.includes(kw))
})
}
}
四、前端设计
4.1 路由结构
现有路由(不变):
/ → Dashboard
/roster → 花名册
/contracts → 合同管理
...
新增路由:
/provider/clients → 客户企业管理(PROVIDER_ADMIN)
/provider/bindings → 绑定关系管理
4.2 顶部导航栏改造
客户HR(不变)
[Logo] [工作台] [花名册] [合同] [薪酬] [考勤] [社保] [解聘] [制度] [证据链] [设置] [用户]
外包专员
[Logo] [当前服务企业 ▼] [工作台] [社保公积金] [特殊状态] [用户]
- 顶部增加「当前服务企业」下拉切换器
- 菜单只显示授权模块
- 隐藏所有未授权模块的菜单项和路由入口
4.3 企业切换器组件
function OrgSwitcher({ bindings, currentOrgId, onSwitch }) {
return (
<Select value={currentOrgId} onValueChange={onSwitch}>
{bindings.map(b => (
<SelectItem key={b.clientOrgId} value={b.clientOrgId}>
{b.clientOrg.name}
<span className="text-xs text-gray-400 ml-2">
{b.modules.join('、')}
</span>
</SelectItem>
))}
</Select>
)
}
- 切换时调用
POST /auth/switch-org/:orgId - 刷新页面数据(invalidate all queries)
- 切换后菜单按新企业的授权模块重新渲染
4.4 菜单配置(按角色过滤)
const ALL_MENUS = [
{ key: 'dashboard', label: '工作台', path: '/', module: 'DASHBOARD' },
{ key: 'roster', label: '花名册', path: '/roster', module: 'ROSTER' },
{ key: 'contract', label: '合同管理', path: '/contracts', module: 'CONTRACT' },
{ key: 'payroll', label: '薪酬工资', path: '/money', module: 'PAYROLL' },
{ key: 'attendance', label: '考勤工时', path: '/attendance', module: 'ATTENDANCE' },
// 社保和公积金在前端合为一个菜单项,但后端模块标识分离
// 菜单可见条件:用户有 SOCIAL_INSURANCE 或 HOUSING_FUND 任一权限
{ key: 'social', label: '社保公积金', path: '/social', module: 'SOCIAL_INSURANCE', altModule: 'HOUSING_FUND' },
{ key: 'special', label: '特殊状态', path: '/special-status', module: 'SPECIAL_STATUS' },
{ key: 'termination', label: '解聘管理', path: '/termination', module: 'TERMINATION' },
{ key: 'policy', label: '规章制度', path: '/policies', module: 'POLICY' },
{ key: 'evidence', label: '证据链', path: '/evidence', module: 'EVIDENCE' },
{ key: 'report', label: '年度报告', path: '/tools/annual-value', module: 'REPORT' },
{ key: 'settings', label: '系统设置', path: '/settings', module: 'SETTINGS' },
]
function getVisibleMenus(user: User): Menu[] {
if (!user.isProvider) return ALL_MENUS
return ALL_MENUS.filter(m => {
// 主模块或有备选模块任一在授权列表中即可
const hasMain = user.allowedModules?.includes(m.module)
const hasAlt = m.altModule && user.allowedModules?.includes(m.altModule)
return hasMain || hasAlt
})
}
注意:社保和公积金在前端合为一个页面
/social,但后端 API 路由分离为SOCIAL_INSURANCE和HOUSING_FUND两个模块。前端页面内根据allowedModules控制 Tab 或区块的显隐。
4.5 Dashboard 差异
客户HR Dashboard
- 完整概览:员工数、合同、薪酬、考勤、社保、风险
- 全部 Tab:概览 / 风险提醒 / 月度任务
- 所有统计卡片
外包专员 Dashboard
- 精简概览:在管员工数(只读)、社保缴纳状态、公积金缴纳状态、特殊状态员工数
- Tab:概览 / 风险提醒(仅社保/公积金/特殊状态相关)
- 隐藏:薪酬汇总、合同到期预警、考勤统计、解聘动态等卡片
- 统计卡片只显示授权模块相关的
4.6 前端状态管理
// useAuth hook 扩展
interface AuthState {
user: User
isProvider: boolean
actingOrgId: string | null
allowedModules: string[]
bindings: ServiceBinding[] // 可切换的客户企业列表
}
// 切换企业
function switchOrg(orgId: string) {
await api.post(`/auth/switch-org/${orgId}`)
// 更新 token
// invalidate 所有 query
queryClient.invalidateQueries()
// 重新加载菜单
}
4.7 路由守卫
function PrivateRoute({ module, children }) {
const { user } = useAuth()
// 非 provider 直接放行
if (!user.isProvider) return children
// provider 检查模块权限
if (module && !user.allowedModules?.includes(module)) {
return <Navigate to="/" replace />
}
return children
}
五、外包企业注册与初始化流程
5.1 外包企业创建
- 平台管理员创建:由系统 SUPER_ADMIN 在管理后台创建外包企业账号,设置
isProvider: true - 创建管理员账号:为外包企业创建第一个
PROVIDER_ADMIN用户 - 外包管理员登录:PROVIDER_ADMIN 登录后进入外包企业管理界面
5.2 客户绑定流程
外包管理员 → /provider/clients → 搜索客户企业(按企业名称/统一社会信用代码)
→ 选择客户企业 → 选择授权模块 → 创建绑定
→ 客户企业 ADMIN 收到绑定通知 → 确认/拒绝
→ 确认后绑定状态变为 ACTIVE
绑定需要客户企业确认,防止未经授权的外包企业接入。
5.3 外包专员账号创建
PROVIDER_ADMIN 在外包企业内创建 PROVIDER_STAFF 账号:
- 填写用户名、密码、姓名
- 可选:设置
allowedModules(为空则继承绑定关系的全部授权模块) - 可选:分配特定客户企业(默认可见全部已绑定客户)
5.4 登录后自动进入逻辑
// 外包专员登录后的路由策略
if (user.isProvider) {
const bindings = await getBindings(user.orgId)
if (bindings.length === 0) {
// 无绑定:跳转到「等待绑定」提示页
navigate('/provider/no-clients')
} else if (bindings.length === 1) {
// 仅一个客户:自动切换并进入 Dashboard
await switchOrg(bindings[0].clientOrgId)
navigate('/')
} else {
// 多个客户:跳转到客户选择页
navigate('/provider/select-client')
}
}
六、通知机制
6.1 外包操作通知客户HR
外包专员完成以下操作时,客户HR收到站内通知:
| 事件 | 通知内容 |
|---|---|
| 社保缴纳完成 | 「XX外包公司已完成 2026-07 月社保缴纳」 |
| 公积金缴纳完成 | 「XX外包公司已完成 2026-07 月公积金缴纳」 |
| 特殊状态录入 | 「XX外包公司录入了员工张三的孕期状态」 |
| 特殊状态变更 | 「XX外包公司更新了员工李四的工伤状态」 |
6.2 客户HR操作通知外包专员
客户HR完成以下操作时,外包专员收到站内通知:
| 事件 | 通知内容 |
|---|---|
| 新员工入职 | 「客户企业新增员工王五,请及时办理社保增员」 |
| 员工离职 | 「客户企业员工赵六已离职,请及时办理社保减员」 |
| 社保基数变更 | 「客户企业修改了员工孙七的社保基数」 |
6.3 通知实现
- 复用现有
Notification表(如有)或新增Notification表 - 字段:
userId,orgId,type,title,content,isRead,createdAt - 前端 Header 增加通知铃铛图标,轮询或 WebSocket 推送
七、实施步骤
| 阶段 | 内容 | 预估工作量 |
|---|---|---|
| 1 | 数据库迁移:Organization.isProvider、ServiceProviderBinding 表、User.role 扩展、User.allowedModules、Notification 表 | 0.5天 |
| 2 | 后端:JWT 扩展、企业切换接口、权限中间件(含读写级别) | 1天 |
| 3 | 后端:现有路由数据隔离改造(orgId → actingOrgId) | 0.5天 |
| 4 | 后端:provider 管理接口(绑定/解绑/列表/确认)、通知接口 | 1天 |
| 5 | 前端:企业切换器、菜单过滤、路由守卫、客户选择页 | 1天 |
| 6 | 前端:Dashboard 精简视图、模块级数据过滤、通知铃铛 | 1天 |
| 7 | 审计日志增强 + 通知机制 + 测试 | 1天 |
| 合计 | 6天 |
八、安全要点
- 后端独立校验:前端隐藏菜单 ≠ 后端放权,每个 API 都经过
moduleAccessMiddleware校验 - 绑定关系校验:每次企业切换都验证
ServiceProviderBinding的有效性(status=ACTIVE、未过期) - 数据隔离不变:所有业务数据仍按
actingOrgId(客户企业)隔离,外包企业本身不存储业务数据 - 审计可追溯:外包专员的每次操作都记录操作者企业和被操作企业
- 会话隔离:JWT 中
actingOrgId不可篡改,切换企业必须走/auth/switch-org接口
九、不改动部分
- 现有所有业务逻辑不变(合同、薪酬、考勤、社保等)
- 员工、合同、薪酬等数据仍归属客户企业
- 数据库表结构基本不变(只新增字段和表)
- 现有客户HR的使用体验完全不变