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

25 KiB
Raw Permalink Blame 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)

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)

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)

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 统一认证依赖

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 角色验证装饰器

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 组件

<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 坐席端改造

认证流程改造

// 坐席端路由守卫
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/'
  }
})

角色切换入口

<!-- 坐席端右上角菜单 -->
<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 配置

# 管理端:仅限内网/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 参考文档