Files

910 lines
25 KiB
Markdown
Raw Permalink Normal View History

# IT智能服务台 — 统一入口(Portal)技术设计文档
**版本**: v1.1
**日期**: 2026-06-13
**作者**: 宋献
**状态**: 实施中
---
## 〇、测试环境说明
**⚠️ 重要约束**
- 本地开发环境无法完成企微 OAuth2 认证
- 所有前端都通过企微认证,不支持独立登录页面
- **所有登录相关验证必须在生产服务器 `10.90.5.110` 上进行**
**测试流程**
1. 将代码部署到生产服务器
2. 通过企微工作台访问应用
3. 完成 OAuth2 认证后验证功能
---
## 一、概述
### 1.1 背景
当前 IT智能服务台 存在三个独立入口:
- **用户端** `/itdesk/` — 员工提交工单、查看进度
- **坐席端** `/itagent/` — IT坐席处理会话、AI辅助
- **管理端** `/itadmin/` — 系统配置、数据分析
**问题**
1. 各端认证方式不统一(用户端OAuth2、坐席端用户名+通讯录验证)
2. 公网可直接访问登录页面,存在暴力破解风险
3. 用户需要记住多个URL,体验不佳
### 1.2 方案目标
**统一入口架构**
- 所有用户必须通过 **企微工作台 → IT智能服务台应用** 进入
- 进入时自动检测账户关联的角色
- 提供卡片选择页面,让用户选择进入哪个端
- 无坐席/管理角色的用户直接进入用户端
**安全目标**
- 消除公网可直接访问的登录页面
- 统一使用企微 OAuth2 认证
- 管理端仅限内网/VPN访问
- API端点保留独立认证通道(API Key + IP白名单)
---
## 二、系统架构
### 2.1 整体架构图
```
┌─────────────────────────────────────────────────────────────┐
│ 用户访问流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 企微工作台 → IT智能服务台应用 │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ OAuth2 授权 │ ← 统一入口,唯一认证点 │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 角色检测 + 路由 │ ← 查询数据库中的角色 │
│ └────────┬────────┘ │
│ │ │
│ ┌─────┴─────┬──────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │用户端 │ │坐席端│ │管理端│ │
│ └──────┘ └──────┘ └──────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 角色路由逻辑
```
OAuth2 授权完成
查询角色列表: GET /api/portal/roles
├── 仅 user 角色 → 直接跳转 /itdesk/(不显示选择页)
├── user + agent → 显示选择页(2张卡片)
├── user + admin → 显示选择页(2张卡片)
└── user + agent + admin → 显示选择页(3张卡片)
```
### 2.3 URL 路径规划
| 端 | 路径 | 说明 |
|---|------|------|
| 统一入口 | `/itportal/` | 路由选择页 |
| 用户端 | `/itdesk/` | 员工提交工单、查看进度 |
| 坐席端 | `/itagent/` | IT坐席处理会话 |
| 管理端 | `/itadmin/` | 系统配置、数据分析 |
| API | `/api/` | 后端接口 |
---
## 三、数据库设计
### 3.1 角色表 (roles)
```sql
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE, -- 角色标识: user/agent/admin
display_name VARCHAR(100) NOT NULL, -- 显示名称: 用户/坐席/管理员
description TEXT, -- 角色描述
permissions JSONB DEFAULT '[]', -- 权限列表(JSON数组)
is_default BOOLEAN DEFAULT FALSE, -- 是否默认角色(user=true
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 预置角色
INSERT INTO roles (name, display_name, description, is_default) VALUES
('user', '用户', '所有在职员工默认角色,可提交工单、查看进度、浏览知识库', TRUE),
('agent', '坐席', 'IT支持人员,可处理会话、使用AI辅助、管理工单', FALSE),
('admin', '管理员', '系统管理员,可配置系统、管理权限、查看数据分析', FALSE);
```
### 3.2 用户角色关联表 (user_roles)
```sql
CREATE TABLE user_roles (
id SERIAL PRIMARY KEY,
employee_id VARCHAR(100) NOT NULL, -- 企微 UserID
role_id INTEGER NOT NULL REFERENCES roles(id),
source VARCHAR(50) NOT NULL, -- 来源: auto/tag/ehr/manual
assigned_by VARCHAR(100), -- 分配者(手动分配时记录)
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP, -- 过期时间(可选)
UNIQUE(employee_id, role_id)
);
-- 索引
CREATE INDEX idx_user_roles_employee ON user_roles(employee_id);
CREATE INDEX idx_user_roles_role ON user_roles(role_id);
```
### 3.3 角色映射规则表 (role_mapping_rules)
```sql
CREATE TABLE role_mapping_rules (
id SERIAL PRIMARY KEY,
role_id INTEGER NOT NULL REFERENCES roles(id),
source_type VARCHAR(50) NOT NULL, -- 来源类型: wecom_tag/ehr_position/manual
source_value VARCHAR(200) NOT NULL, -- 来源值: 标签名/岗位关键词
priority INTEGER DEFAULT 0, -- 优先级(数值越大优先级越高)
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 预置映射规则
INSERT INTO role_mapping_rules (role_id, source_type, source_value, priority) VALUES
((SELECT id FROM roles WHERE name='agent'), 'wecom_tag', 'IT坐席', 10),
((SELECT id FROM roles WHERE name='agent'), 'ehr_position', 'IT支持', 10),
((SELECT id FROM roles WHERE name='agent'), 'ehr_position', 'IT运维', 10),
((SELECT id FROM roles WHERE name='agent'), 'ehr_position', '技术支持', 10);
```
### 3.4 Token 存储(Redis
**统一 Token 格式**
```
Key: user:token:{token}
Value: {
"employee_id": "zhangsan",
"name": "张三",
"department": "IT支持组",
"avatar": "https://...",
"roles": ["user", "agent"],
"current_role": "agent",
"login_source": "portal",
"created_at": "2026-06-12T22:00:00",
"last_active": "2026-06-12T22:30:00"
}
TTL: 8 小时(28800秒)
```
---
## 四、API 设计
### 4.1 Portal API
#### 4.1.1 获取当前用户角色
```
GET /api/portal/roles
Authorization: Bearer {token}
Response:
{
"code": 0,
"data": {
"employee_id": "zhangsan",
"name": "张三",
"department": "IT支持组",
"avatar": "https://...",
"roles": [
{
"name": "user",
"display_name": "用户",
"description": "所有在职员工默认角色"
},
{
"name": "agent",
"display_name": "坐席",
"description": "IT支持人员"
}
],
"current_role": "user"
}
}
```
#### 4.1.2 切换当前角色
```
POST /api/portal/switch-role
Authorization: Bearer {token}
Content-Type: application/json
{
"new_role": "agent"
}
Response:
{
"code": 0,
"data": {
"current_role": "agent",
"redirect_url": "/itagent/"
}
}
```
#### 4.1.3 获取角色对应的入口 URL
```
GET /api/portal/entry/{role_name}
Authorization: Bearer {token}
Response:
{
"code": 0,
"data": {
"role": "agent",
"url": "/itagent/",
"display_name": "坐席端"
}
}
```
### 4.2 角色管理 API(管理端)
#### 4.2.1 获取所有角色
```
GET /api/admin/roles
Authorization: Bearer {token}
X-Forwarded-For: {内网IP}
Response:
{
"code": 0,
"data": [
{
"id": 1,
"name": "user",
"display_name": "用户",
"is_default": true,
"user_count": 1500
},
{
"id": 2,
"name": "agent",
"display_name": "坐席",
"is_default": false,
"user_count": 15
},
{
"id": 3,
"name": "admin",
"display_name": "管理员",
"is_default": false,
"user_count": 3
}
]
}
```
#### 4.2.2 手动分配角色
```
POST /api/admin/roles/assign
Authorization: Bearer {token}
Content-Type: application/json
{
"employee_id": "zhangsan",
"role_name": "admin",
"reason": "新任IT组长"
}
Response:
{
"code": 0,
"message": "角色分配成功"
}
```
#### 4.2.3 撤销角色
```
POST /api/admin/roles/revoke
Authorization: Bearer {token}
Content-Type: application/json
{
"employee_id": "zhangsan",
"role_name": "admin",
"reason": "岗位调整"
}
Response:
{
"code": 0,
"message": "角色撤销成功"
}
```
#### 4.2.4 获取角色映射规则
```
GET /api/admin/roles/mapping-rules
Authorization: Bearer {token}
Response:
{
"code": 0,
"data": [
{
"id": 1,
"role_name": "agent",
"source_type": "wecom_tag",
"source_value": "IT坐席",
"priority": 10,
"is_active": true
}
]
}
```
#### 4.2.5 创建/更新映射规则
```
POST /api/admin/roles/mapping-rules
Authorization: Bearer {token}
Content-Type: application/json
{
"role_name": "agent",
"source_type": "wecom_tag",
"source_value": "IT运维",
"priority": 10,
"is_active": true
}
Response:
{
"code": 0,
"message": "映射规则创建成功",
"data": {
"id": 4
}
}
```
### 4.3 认证中间件改造
#### 4.3.1 统一认证依赖
```python
async def get_current_user(
token: str = Depends(oauth2_scheme),
redis_client: Redis = Depends(dep_redis)
) -> UserInfo:
"""统一认证中间件:从 Redis 获取用户信息和角色"""
data = await redis_client.get(f"user:token:{token}")
if not data:
raise AppException(1002, "Token 已过期或无效")
user_data = json.loads(data)
# 更新最后活跃时间
user_data["last_active"] = datetime.now().isoformat()
await redis_client.setex(
f"user:token:{token}",
TOKEN_TTL,
json.dumps(user_data)
)
return UserInfo(
employee_id=user_data["employee_id"],
name=user_data["name"],
department=user_data["department"],
roles=user_data["roles"],
current_role=user_data["current_role"]
)
```
#### 4.3.2 角色验证装饰器
```python
def require_role(*required_roles: str):
"""角色验证装饰器:检查用户是否拥有指定角色之一"""
def decorator(func):
@wraps(func)
async def wrapper(
*args,
current_user: UserInfo = Depends(get_current_user),
**kwargs
):
# 检查用户是否有任一所需角色
user_roles = set(current_user.roles)
required = set(required_roles)
if not user_roles.intersection(required):
raise AppException(
4003,
f"需要以下角色之一: {', '.join(required_roles)}"
)
return await func(*args, current_user=current_user, **kwargs)
return wrapper
return decorator
# 使用示例
@router.get("/api/agent/conversations")
@require_role("agent")
async def get_conversations(current_user: UserInfo = Depends(get_current_user)):
# 只有 agent 角色才能访问
pass
@router.get("/api/admin/dashboard")
@require_role("admin")
async def get_dashboard(current_user: UserInfo = Depends(get_current_user)):
# 只有 admin 角色才能访问
pass
@router.get("/api/h5/tickets")
@require_role("user", "agent", "admin")
async def get_tickets(current_user: UserInfo = Depends(get_current_user)):
# 所有角色都可以访问
pass
```
---
## 五、前端设计
### 5.1 Portal 前端应用
**项目结构**
```
frontend-portal/
├── src/
│ ├── App.vue
│ ├── main.ts
│ ├── router/
│ │ └── index.ts
│ ├── stores/
│ │ └── portal.ts # Portal 状态管理
│ ├── api/
│ │ └── portal.ts # Portal API 调用
│ ├── views/
│ │ ├── PortalSelect.vue # 角色选择页
│ │ └── PortalLoading.vue # 加载页
│ └── components/
│ └── RoleCard.vue # 角色卡片组件
├── index.html
├── vite.config.ts
└── package.json
```
### 5.2 角色选择页 UI
**页面布局**
```
┌─────────────────────────────────────────────────────────┐
│ │
│ IT智能服务台 │
│ │
│ 选择您要进入的工作台 │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ │ │ │ │ │ │
│ │ 👤 用户 │ │ 🎧 坐席 │ │ ⚙️ 管理 │ │
│ │ │ │ │ │ │ │
│ │ 提交工单 │ │ 处理会话 │ │ 系统配置 │ │
│ │ 查看进度 │ │ AI 辅助 │ │ 数据分析 │ │
│ │ 知识库 │ │ 知识库 │ │ 权限管理 │ │
│ │ │ │ │ │ │ │
│ │ [进入] │ │ [进入] │ │ [进入] │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ 当前账号:张三 (IT支持组) │
│ │
└─────────────────────────────────────────────────────────┘
```
**RoleCard 组件**
```vue
<template>
<div
class="role-card"
:class="{ 'role-card--disabled': !available }"
@click="handleClick"
>
<div class="role-card__icon">
<el-icon :size="48">
<component :is="icon" />
</el-icon>
</div>
<div class="role-card__title">{{ role.display_name }}</div>
<div class="role-card__desc">{{ role.description }}</div>
<el-button
v-if="available"
type="primary"
class="role-card__btn"
>
进入
</el-button>
<el-tag v-else type="info">无权限</el-tag>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Role {
name: string
display_name: string
description: string
}
const props = defineProps<{
role: Role
available: boolean
}>()
const emit = defineEmits<{
(e: 'select', role: Role): void
}>()
const icon = computed(() => {
switch (props.role.name) {
case 'user': return 'User'
case 'agent': return 'Headset'
case 'admin': return 'Setting'
default: return 'User'
}
})
const handleClick = () => {
if (props.available) {
emit('select', props.role)
}
}
</script>
```
### 5.3 坐席端改造
**认证流程改造**
```typescript
// 坐席端路由守卫
router.beforeEach(async (to, from, next) => {
const agentStore = useAgentStore()
// 检查是否已认证
if (!agentStore.token) {
// 尝试从 URL 参数获取 token(从 Portal 跳转时传递)
const token = to.query.token as string
if (token) {
agentStore.setToken(token)
// 清除 URL 中的 token 参数
next({ ...to, query: {} })
return
}
// 未认证,跳转到 Portal
window.location.href = '/itportal/'
return
}
// 验证角色权限
try {
const userInfo = await agentStore.validateRole('agent')
if (!userInfo.roles.includes('agent')) {
// 无坐席角色,跳转到 Portal
window.location.href = '/itportal/'
return
}
next()
} catch (error) {
// Token 无效,跳转到 Portal
window.location.href = '/itportal/'
}
})
```
**角色切换入口**
```vue
<!-- 坐席端右上角菜单 -->
<template>
<el-dropdown @command="handleCommand">
<div class="user-info">
<el-avatar :src="userStore.avatar" :size="32" />
<span class="user-name">{{ userStore.name }}</span>
<el-icon><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="switch-user">
<el-icon><User /></el-icon>
切换到用户端
</el-dropdown-item>
<el-dropdown-item v-if="hasAdminRole" command="switch-admin">
<el-icon><Setting /></el-icon>
切换到管理端
</el-dropdown-item>
<el-dropdown-item divided command="logout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const hasAdminRole = computed(() => {
return userStore.roles.includes('admin')
})
const handleCommand = async (command: string) => {
switch (command) {
case 'switch-user':
await switchRole('user')
break
case 'switch-admin':
await switchRole('admin')
break
case 'logout':
await logout()
break
}
}
const switchRole = async (role: string) => {
try {
const { redirect_url } = await api.switchRole(role)
window.location.href = redirect_url
} catch (error) {
ElMessage.error('切换失败')
}
}
const logout = async () => {
userStore.clearAuth()
window.location.href = '/itportal/'
}
</script>
```
---
## 六、安全设计
### 6.1 认证安全
| 安全措施 | 说明 | 状态 |
|----------|------|------|
| OAuth2 静默授权 | scope=snsapi_base,用户无感知 | ✅ 已实现 |
| state 参数防 CSRF | 随机 state,回调时验证 | ✅ 已实现 |
| Token 密码学安全 | secrets.token_urlsafe(32) | ✅ 已实现 |
| Token TTL 8小时 | Redis 自动过期 | ✅ 已实现 |
| redirect_uri 白名单 | 生产环境仅允许正式域名 | ⚠️ 需收紧 |
| 企微 UA 检测 | 非企微 WebView 拒绝访问 | ✅ 已实现 |
### 6.2 角色安全
| 安全措施 | 说明 | 状态 |
|----------|------|------|
| 角色最小权限 | 默认仅 user 角色 | ✅ 设计完成 |
| 角色来源追溯 | user_roles 表记录 source 和 assigned_by | ✅ 设计完成 |
| 管理端 IP 白名单 | 仅内网/VPN 可访问 | ⚠️ 待配置 |
| 管理端紧急通道 | 管理员密码 + 二次验证 | ⚠️ 待设计 |
### 6.3 API 安全
| 安全措施 | 说明 | 状态 |
|----------|------|------|
| API Key 认证 | 外部系统独立认证通道 | ⚠️ 待实现 |
| API IP 白名单 | 外部系统限制来源 IP | ⚠️ 待实现 |
| 速率限制 | slowapi 中间件 | ✅ 已实现 |
### 6.4 管理端访问控制
**Nginx 配置**
```nginx
# 管理端:仅限内网/VPN 访问
location /itadmin/ {
# 允许内网网段
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
# 允许 VPN 网段
allow 10.212.0.0/16;
# 拒绝其他来源
deny all;
# 反向指向前端应用
try_files $uri $uri/ /itadmin/index.html;
}
# 管理端 API:同样限制访问来源
location /api/admin/ {
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
allow 10.212.0.0/16;
deny all;
proxy_pass http://backend;
}
```
---
## 七、角色映射机制
### 7.1 映射流程
```
用户 OAuth2 登录
获取企微 access_token
查询企微通讯录 → 获取标签、部门、岗位
查询 role_mapping_rules 表
├─ 标签匹配 → agent
├─ 岗位匹配 → agent
├─ 数据库 user_roles 表有 admin 记录 → admin
└─ 默认 → user
合并角色列表 → 存入 Redis Token
```
### 7.2 企微标签配置
**标签组**`IT服务台角色`
**标签值**
- `IT坐席` — 映射为 agent 角色
- `IT管理员` — 映射为 admin 角色(备用,实际以手动分配为准)
**配置步骤**
1. 登录企微管理后台
2. 通讯录 → 标签 → 新建标签组
3. 添加标签值
4. 为坐席人员打上 `IT坐席` 标签
### 7.3 eHR 字段映射(后续阶段)
**映射规则**
- 岗位包含 `IT支持``IT运维``技术支持` → agent
**实现方式**
- 对接北森 eHR OAuth2 API
- 定时同步员工岗位信息
- 根据岗位关键词自动映射角色
---
## 八、实施计划
### 阶段一:角色系统 + 统一 Token(1.5 周)
| 序号 | 任务 | 说明 | 工时 |
|------|------|------|------|
| 1 | 创建角色表 | roles + user_roles + role_mapping_rules | 2h |
| 2 | 实现角色 CRUD API | 管理后台管理角色分配 | 4h |
| 3 | 实现角色映射服务 | 企微标签 → 角色 | 4h |
| 4 | 改造 Token 存储 | 统一为 user:token:{token} | 4h |
| 5 | 改造认证中间件 | 统一 get_current_user,支持角色验证 | 4h |
| 6 | 坐席端认证改造 | 使用统一的 get_current_user | 4h |
| 7 | 单元测试 | 角色系统测试 | 4h |
**小计**:约 26 小时
### 阶段二:路由选择页(1 周)
| 序号 | 任务 | 说明 | 工时 |
|------|------|------|------|
| 8 | 创建 Portal Vue 应用 | 前端项目初始化 | 2h |
| 9 | 实现角色选择 UI | 卡片选择组件 | 4h |
| 10 | 改造 OAuth2 回调 | 授权后跳转到 Portal | 4h |
| 11 | 实现角色切换 API | POST /api/portal/switch-role | 2h |
| 12 | 坐席端切换入口 | 右上角菜单切换角色 | 2h |
| 13 | 前端测试 | 端到端测试 | 4h |
**小计**:约 18 小时
### 阶段三:管理端访问控制(0.5 周)
| 序号 | 任务 | 说明 | 工时 |
|------|------|------|------|
| 14 | Nginx IP 白名单配置 | 限制 /itadmin/ 访问来源 | 2h |
| 15 | 管理端 OAuth2 接入 | 主通道:企微授权 | 4h |
| 16 | 管理员角色分配 | 初始管理员配置 | 1h |
| 17 | 测试验证 | 内网/外网访问测试 | 2h |
**小计**:约 9 小时
### 阶段四:安全加固 + 测试(1 周)
| 序号 | 任务 | 说明 | 工时 |
|------|------|------|------|
| 18 | 收紧 redirect_uri 白名单 | 生产环境仅允许正式域名 | 1h |
| 19 | 移除所有降级逻辑 | 生产环境禁用 Mock 登录等 | 2h |
| 20 | 端到端测试 | 完整流程测试 | 4h |
| 21 | 安全审计 | 第三方安全测试 | 4h |
| 22 | 文档更新 | 更新部署文档、用户手册 | 2h |
**小计**:约 13 小时
---
**总工时**:约 66 小时(约 2.5 周全职工作)
---
## 九、风险与缓解措施
| 风险 | 等级 | 缓解措施 |
|------|------|----------|
| 企微 OAuth2 故障 | 高 | 管理端保留紧急通道 |
| 企微标签配置错误 | 中 | 角色映射有日志,可追溯 |
| Token 切换时的竞态条件 | 低 | Redis 事务保证原子性 |
| 内网 IP 白名单误配 | 中 | 配置前在测试环境验证 |
| 现有坐席登录方式变更 | 中 | 变更前手动通知坐席 |
---
## 十、附录
### 10.1 术语表
| 术语 | 说明 |
|------|------|
| Portal | 统一入口,路由选择页 |
| OAuth2 | 开放授权协议,用于企微身份验证 |
| Bearer Token | 用于 API 认证的令牌 |
| RBAC | 基于角色的访问控制 |
| eHR | 人力资源管理系统(北森) |
### 10.2 参考文档
- [企业微信 OAuth2 开发文档](https://developer.work.weixin.qq.com/document/path/91022)
- [企业微信 通讯录管理](https://developer.work.weixin.qq.com/document/path/90193)
- [FastAPI 安全最佳实践](https://fastapi.tiangolo.com/tutorial/security/)