Files
wecom_it_smart_desk/docs/统一入口技术设计文档.md
T

910 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/)