# 外包服务模式设计方案 > 场景:客户企业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 结构 ```typescript interface JwtPayload { userId: string orgId: string // 用户所属企业(provider 或 client) orgRole: string // 角色 actingOrgId?: string // 外包专员当前操作的客户企业(仅 provider) allowedModules?: string[] // 授权模块列表(仅 provider) isProvider: boolean } ``` ### 3.2 权限中间件 ```typescript // 现有: 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 ``` #### 读写权限控制 ```typescript // 模块读写权限配置 const MODULE_ACCESS: Record> = { 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 被拒绝 ``` #### 中间件使用示例 ```typescript // 社保路由 — 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)`: ```typescript // 改造前 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`。 新增字段: ```typescript // 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 风险提醒过滤 外包专员看到的风险提醒只包含授权模块相关的风险: ```typescript // getDashboardData 中过滤 if (req.user.isProvider) { // 按授权模块映射到风险类型 const moduleTypeMap: Record = { '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 企业切换器组件 ```tsx function OrgSwitcher({ bindings, currentOrgId, onSwitch }) { return ( ) } ``` - 切换时调用 `POST /auth/switch-org/:orgId` - 刷新页面数据(invalidate all queries) - 切换后菜单按新企业的授权模块重新渲染 ### 4.4 菜单配置(按角色过滤) ```typescript 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 前端状态管理 ```typescript // 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 路由守卫 ```tsx function PrivateRoute({ module, children }) { const { user } = useAuth() // 非 provider 直接放行 if (!user.isProvider) return children // provider 检查模块权限 if (module && !user.allowedModules?.includes(module)) { return } return children } ``` ## 五、外包企业注册与初始化流程 ### 5.1 外包企业创建 1. **平台管理员创建**:由系统 SUPER_ADMIN 在管理后台创建外包企业账号,设置 `isProvider: true` 2. **创建管理员账号**:为外包企业创建第一个 `PROVIDER_ADMIN` 用户 3. **外包管理员登录**:PROVIDER_ADMIN 登录后进入外包企业管理界面 ### 5.2 客户绑定流程 ``` 外包管理员 → /provider/clients → 搜索客户企业(按企业名称/统一社会信用代码) → 选择客户企业 → 选择授权模块 → 创建绑定 → 客户企业 ADMIN 收到绑定通知 → 确认/拒绝 → 确认后绑定状态变为 ACTIVE ``` > 绑定需要客户企业确认,防止未经授权的外包企业接入。 ### 5.3 外包专员账号创建 PROVIDER_ADMIN 在外包企业内创建 PROVIDER_STAFF 账号: 1. 填写用户名、密码、姓名 2. 可选:设置 `allowedModules`(为空则继承绑定关系的全部授权模块) 3. 可选:分配特定客户企业(默认可见全部已绑定客户) ### 5.4 登录后自动进入逻辑 ```typescript // 外包专员登录后的路由策略 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天** | ## 八、安全要点 1. **后端独立校验**:前端隐藏菜单 ≠ 后端放权,每个 API 都经过 `moduleAccessMiddleware` 校验 2. **绑定关系校验**:每次企业切换都验证 `ServiceProviderBinding` 的有效性(status=ACTIVE、未过期) 3. **数据隔离不变**:所有业务数据仍按 `actingOrgId`(客户企业)隔离,外包企业本身不存储业务数据 4. **审计可追溯**:外包专员的每次操作都记录操作者企业和被操作企业 5. **会话隔离**:JWT 中 `actingOrgId` 不可篡改,切换企业必须走 `/auth/switch-org` 接口 ## 九、不改动部分 - 现有所有业务逻辑不变(合同、薪酬、考勤、社保等) - 员工、合同、薪酬等数据仍归属客户企业 - 数据库表结构基本不变(只新增字段和表) - 现有客户HR的使用体验完全不变